☰
把Python的类写顺手:从定义到继承、组合与dataclass
2026/10/6 10:01:39 网站建设 项目流程

很多人写 Python 写了一段时间之后,会发现一件吊诡的事:明明class语法早就看熟了,可一到真正需要设计类的时候,还是觉得别扭。不是忘了写__init__,就是在犹豫这个属性该放类变量还是实例变量,继承要不要用,接口又该怎么抽。我今天不准备讲一本正经的理论,而是把“把 Python 的类写顺手”这条线完整走一遍:从最基础的定义开始,到继承、组合、抽象基类,再到property、dataclass这些工程化常用手段,最后用一个订单类的演进案例收尾。无论你是刚装好 Python 环境、正在学 python 类对象的小白,还是已经写了几千行业务代码想优化设计的老手,这篇都值得花十分钟读完。

1. 类的定义:从基础语法到语义理解

1.1 类到底是什么:先搞清楚对象和类的关系

很多人一上来就背“类是对象的模板,对象是类的实例”,背完还是不会用。我更喜欢用一个生活化类比:类就像蛋糕模具,对象就是模具做出来的蛋糕。模具本身不能吃,但它定义了蛋糕的形状、大小、有哪些花纹;你做出来的每个蛋糕,才是真正能用的对象。换个角度说,类把“数据”和“操作数据的方法”绑在一起,形成一个内聚的整体。

举个例子,你想管理一批学生信息。如果不用类,你可能写两个列表,一个存姓名,一个存分数,然后写一堆函数去同步维护这两个列表。数据分散、逻辑暴露,一旦加字段就得改所有相关函数。用类之后,学生本身成为一个概念:姓名、分数是它的属性,算出等级、打印信息是它的行为。这个封装不是花架子,是让代码结构跟着业务走,而不是跟着函数走。

在 Python 里,类本身也是一个对象,叫“类对象”,用来创建实例。这一点在写元类时会用到,但日常开发你只需要记住:class语句制造的是一个可调用的工厂对象,调用它就能得到实例。

1.2 定义类的核心语法:init、self、属性、方法

先看一个最普通的类定义:

class Student: def __init__(self, name: str, score: int): self.name = name self.score = score def level(self) -> str: if self.score >= 90: return "A" if self.score >= 60: return "B" return "C"

这里面的几个点,我逐个拆开说。

__init__是初始化方法,作用是在实例创建之后,把传入参数绑定到实例上。很多人把它叫构造函数,严格来说不对:Python 创建实例时最先调用的是__new__,那才是真正分配内存的构造过程,__init__只是“入住后打扫房间”。日常业务里你基本不用碰__new__,所以把__init__当作“初始化钩子”理解就够了。

self是当前实例本身。它不需要你手动传,Python 在调用实例方法时自动把实例作为第一个参数塞进来。注意:你写多少个方法,每个方法都必须给 self 留位,否则调用时就会报“缺少参数”的错。这个设计常被吐槽,但它让“方法必须知道自己属于哪个对象”这件事变得非常显式。

一个类里通常有两种成员:属性和方法。属性是数据,方法是对数据的操作。比如上面Student里,name和score是通过__init__建立的实例属性,level是实例方法,它通过self.score读取数据并计算。这里有个容易混淆的点:__init__内部通过self.name = name才把name变成实例属性,而不是靠__init__的签名自动完成。刻意强调这一点,是因为我见过不少人在__init__里定义了参数,却忘了赋值,后面用的时候直接 AttributeError。

1.3 类变量 vs 实例变量:最常见的混乱点

这是新手甚至老手都容易翻车的地方。看下面这个例子:

class Dog: tricks = [] # 类变量:所有实例共享同一份列表 def __init__(self, name: str): self.name = name def add_trick(self, trick: str) -> None: self.tricks.append(trick)

你创建两只狗,分别add_trick,然后打印会发现两只狗都拥有了两只狗的技能列表。为什么?因为tricks定义在类体里,它是类命名空间下的一个变量,所有实例访问到的都是同一个列表对象。

正确的做法是,所有需要“每个实例独立持有”的数据,都放在__init__里用self.xxx = ...创建:

class Dog: def __init__(self, name: str): self.name = name self.tricks: list[str] = [] def add_trick(self, trick: str) -> None: self.tricks.append(trick)

判断规则很简单:如果一份数据是“这个类所有对象都应该共享的”,比如一个全局计数器、一个统一配置常量,那适合做类变量;只要数据跟具体实例相关,一律做成实例变量。类变量还有一个用途是避免魔法数,比如定义MAX_RETRY = 3放在类里面,实例可以直接用self.MAX_RETRY访问,语义清晰。

注意:类变量如果是可变对象,修改时一定要留个心眼。共享不是问题,问题是你没意识到它在共享。写类之前花十秒钟想清楚这个属性属于“类”还是“实例”,能省下许多诡异的测试失败。

2. 类设计中的关键选择:继承、组合与接口

2.1 继承要用在“is-a”关系上,别为了复用硬凑

继承是面向对象里最有名、也最容易被滥用的机制。很多人的第一直觉是:A 类里已经有我要的方法,让 B 继承 A 就能直接用了。这个想法很危险。

继承的正确语义是“is-a”:狗是一种动物,所以class Dog(Animal)合理;圆是一种形状,所以class Circle(Shape)合理。但你如果只是因为两个类碰巧都有save方法,就让一个订单类去继承文件工具类,这个类之间的血缘关系就假了。硬凑继承会带来几个问题:一是子类被迫接受父类的全部接口,哪怕它对其中一半不感兴趣;二是父类一改,子类可能莫名其妙崩溃;三是多重继承时,方法解析顺序会变得扑朔迷离。

在实际业务里,我判断该不该用继承会问三个问题:

  1. 子类在语义上是否真的是父类的一种?
  2. 子类是否需要替换父类的行为,而不只是复用父类的现成代码?
  3. 继承深度是否保持在两层以内,顶多三层?

如果你的答案有两个以上是否,那就别用继承。哪怕复制一点代码,也好过绑定一段错误的关系。复制带来的成本是暂时的,继承带来的耦合是长期的。

2.2 组合优先:委托、Mixin 与轻量设计

既然继承不能乱用,那代码复用靠什么?最可靠的答案是组合:一个类持有另一个类的实例,需要某个能力时,把请求委托给那个实例。这就像一个公司不一定要让所有部门都汇报给 CEO,每个部门各干各的,CEO 按需协调。

举个实际例子。假设你有一个ReportGenerator类,需要从数据库读数据、然后生成 CSV 报告。很多人会写一个ReportGenerator(DatabaseHandler),让报告生成器继承数据库处理器。但报告生成和数据库访问明明是两种职责,继承把它们焊死了。更好的方式是这样的:

class ReportGenerator: def __init__(self, db_handler): self.db_handler = db_handler def generate(self) -> str: data = self.db_handler.fetch() return self._format(data)

这样ReportGenerator不关心数据怎么来的,只要传入的对象存在fetch方法就可以工作。测试的时候也可以传入假的数据源,不用真的连数据库。

Python 里还有一种轻量复用方式叫 Mixin,本质是一个只提供方法、不独立使用的类。它和真正继承的区别在于:Mixin 的语义是“混入一组能力”,而不是“是一个”。比如你写一个JSONableMixin,给类加to_json方法,然后class Order(JSONableMixin, BaseEntity)。这种用法不是不能用,但要求你小心处理命名冲突,不要有同名方法把彼此覆盖掉。我的建议是:Mixin 里最好只提供与业务无强关联、名字足够独特的方法,避免一个项目里到处是get_data和被覆盖的惨剧。

2.3 抽象基类(abc)与接口:用协议约束实现

当多个类必须遵循同一套调用约定时,你需要一个“契约”。Python 里最标准的做法是用abc模块定义抽象基类。

from abc import ABC, abstractmethod class Payment(ABC): @abstractmethod def pay(self, amount: float) -> bool: ... class WeChatPay(Payment): def pay(self, amount: float) -> bool: print(f"微信支付:{amount}") return True class AlipayPay(Payment): def pay(self, amount: float) -> bool: print(f"支付宝支付:{amount}") return True

抽象基类本身不能被实例化,它只负责规定子类必须实现哪些方法。你写一个策略模式、插件系统、或者任何“有多个实现但调用方式统一”的场景,抽象基类都很好用。需要注意,abstractmethod不能和staticmethod、classmethod、property组合得太随意,排列顺序会影响行为,建议单独写测试验证。

另一种更 Pythonic 的思路是typing.Protocol(PEP 544 引入的结构化子类型)。它不做强制继承,只检查一个对象是否有指定方法或属性,符合“鸭子类型”哲学。

from typing import Protocol class CanPay(Protocol): def pay(self, amount: float) -> bool: ...

这时任何实现了pay方法的类,即便不继承CanPay,在类型检查器看来也是一个CanPay。抽象基类是运行时强制,Protocol 是静态检查时的约定。我的经验是:对外部插件、一定要在运行时拦截错误,用ABC;项目内部团队协作、想让类型提示更友好,用Protocol更轻。

而“抽象类和普通类的区别”这个问题,其实一句话就能说清:普通类可以直接实例化,抽象类定义了必须由子类补齐的抽象方法,本身不完整。它像是建筑图纸和成品房的关系,图纸不能住人,但规定了房间该有哪些结构。

3. 类的最佳实践:命名、封装与数据管理

3.1 命名与结构:类名、方法名、单一职责

类命名要用驼峰体,名词为主,比如OrderManager、UserProfile,别用动词。方法命名用蛇形,动词或动词短语,比如fetch_data、calculate_total。这个规范不只是 PEP 8 的要求,更重要的是让读代码的人一眼看出“这是个什么东西,能干什么事”。

结构上最重要的一条是单一职责:一个类应该只有一个改变的理由。判断方法有两个:

  • 你能否用一句话说清楚这个类负责什么?如果说“它负责订单处理和邮件发送”,那就拆。
  • 它是不是已经开始变得像一个上帝对象,什么都能干?如果是,说明需要拆。

拆的时候有个常见误区:不是为了拆而拆,而是按照依赖方向拆。比如一个类里既有业务计算又有 UI 展示,应该把计算逻辑抽成纯业务类,把展示逻辑放到视图层。Python 社区对这类代码组织有大量“工程化最佳实践”的讨论,落实到类上,最核心的就是控制每个类的体积和依赖。一个类超过 300 行、方法超过 10 个的时候,我基本会停下来重新划分职责。

3.2 属性管理:property、slots 与私有属性的边界

Python 没有真正的“私有属性”。单下划线开头是约定,告诉外部“别动我”;双下划线开头会触发名称改写,看起来是私有,实际上只是换了个名字存储,子类和外部仍然能通过特殊手段访问。过度使用双下划线会让代码变得难调试,子类继承时还会出现属性改名的意外。我自己的习惯是:内部实现细节用单下划线,公开接口保持干净,不需要用双下划线。

设计属性时,property是一个非常好的“延迟校验”工具。比如价格字段不能为负数,直接在__init__里 if 判断也可以,但如果后续通过实例对象赋值,校验就不生效了。用property可以把校验集中在一个地方:

class Product: def __init__(self, price: float): self._price = price @property def price(self) -> float: return self._price @price.setter def price(self, value: float) -> None: if value < 0: raise ValueError("价格不能为负数") self._price = value

__slots__是另一个锦上添花的手段。它告诉 Python 不需要为每个实例维护一个动态字典,省内存、加属性访问速度。代价是不能随意新增属性。适合需要创建海量实例的场合,比如数据解析、机器学习中的样本对象。普通业务类没必要用,因为动态属性有时候很实用,没必要为了微乎其微的性能收益牺牲灵活性。

3.3 数据类(dataclass):写“数据容器”类的时代选择

如果你要写的类主要就是装数据,没什么复杂逻辑,传统写法有大量样板代码:__init__手动赋值、__repr__、__eq__。现在有了dataclasses.dataclass,这一切都能省掉。

from dataclasses import dataclass, field @dataclass class User: name: str age: int email: str = "" tags: list[str] = field(default_factory=list)

这个类自动获得__init__、__repr__、__eq__,还能配合类型注解做静态检查。两个容易踩的坑,我提前说:

  • 可变默认值(比如空列表)不能直接写= [],否则所有实例共享同一个列表。必须用field(default_factory=list)。
  • 需要让数据不可变时,加frozen=True。但注意frozen=True下还是能调用对象内部可变值的方法,比如user.tags.append(...)不会报错,只是不能给user.tags重新赋值。

dataclass出现之后,我的习惯是:纯数据容器一律用dataclass,有更多业务行为和约束逻辑的类才用普通类。这样一来,代码里“数据怎么组织”和“业务怎么做”分得很清楚,读起来不费劲。

4. 实操中的类项目案例:从零写一个可维护的类

4.1 需求拆解与类设计

为了把这些原则串起来,我们做一个典型案例:商品订单类。需求很简单:

  1. 每个订单有订单号、商品列表、优惠折扣。
  2. 能计算原价总金额、折后金额。
  3. 能输出一行格式化的订单摘要。

拿到这个需求,先别急着写代码。做一次职责拆解:

  • 订单的“数据状态”包括订单号、商品列表、折扣。
  • 订单的“行为”包括计算总价、计算折扣价、输出摘要。
  • 商品本身是一个更小的数据容器,有自己的名称和单价。

所以初步方案是:一个普通类管理订单,订单内部持有一组商品对象。商品类用dataclass做纯数据容器,订单类负责计算逻辑。

4.2 代码实现与演进

第一版,最直接,能跑不具备太多保护:

class Order: def __init__(self, order_id: str, items: list, discount: float = 1.0): self.order_id = order_id self.items = items self.discount = discount def original_total(self) -> float: return sum(item.price * item.quantity for item in self.items) def final_total(self) -> float: return self.original_total() * self.discount

这个版本的问题在于:外部可以随意把discount改成负数;可以先有商品再删光再调用,虽然不会崩但语义混乱;打印订单时默认的repr没法看。于是演进到第二版,用dataclass定义商品,用property约束折扣:

from dataclasses import dataclass @dataclass class Item: name: str price: float quantity: int class Order: def __init__(self, order_id: str, items: list[Item], discount: float = 1.0): self.order_id = order_id self.items = items self.discount = discount @property def discount(self) -> float: return self._discount @discount.setter def discount(self, value: float) -> None: if not 0 < value <= 1: raise ValueError("折扣必须在 0 和 1 之间") self._discount = value def original_total(self) -> float: return sum(item.price * item.quantity for item in self.items) def final_total(self) -> float: return self.original_total() * self.discount def __repr__(self) -> str: return f"Order(order_id={self.order_id}, items={self.items}, discount={self.discount})"

到这里,订单类已经能防住负折扣,商品结构也清楚。但如果后续要支持多种优惠策略,比如满减、打折、特价商品,把所有计算都写在Order里会很臃肿。于是第三版把折扣策略抽成接口:

from abc import ABC, abstractmethod class DiscountStrategy(ABC): @abstractmethod def apply(self, total: float) -> float: ... class PercentDiscount(DiscountStrategy): def __init__(self, rate: float): self.rate = rate def apply(self, total: float) -> float: return total * self.rate class FreeShippingDiscount(DiscountStrategy): def __init__(self, threshold: float): self.threshold = threshold def apply(self, total: float) -> float: return total if total >= self.threshold else total + 10

Order不再持有折扣数字,而是持有一个“策略对象”。将来新增一个“新人立减 20”的规则,只需要加一个新类,不需要改订单类。这就是“对扩展开放,对修改封闭”落到类设计上的样子。

4.3 测试与调试:类的可测试性设计

类设计得好不好,测试跑一下就知道。可测试的类通常具备几个特征:

  • 依赖通过__init__注入,而不是在类内部自己 new 一大堆对象。
  • 不依赖全局状态和真实时间,比如文件路径、网络请求都通过参数传入。
  • 对象能比较容易地构造和断言。这时dataclass的自带__eq__就很有用,可以直接比较两个对象是否相等。

给上面的订单写测试,一段 pytest 大概是这样的:

def test_final_total_with_percent_discount(): order = Order( order_id="A001", items=[Item(name="书", price=100, quantity=2)], discount_strategy=PercentDiscount(rate=0.8), ) assert order.final_total() == 160.0

因为折扣策略被独立出去,测试订单时也能很方便地传一个“原价返回”的假策略,不需要真正构造复杂的优惠逻辑。

调试层面,自定义__repr__非常关键。Python 默认的__repr__在多参数类上基本没法看,你根本分不清两个订单谁是谁。把关键字段放进repr后,打印列表或调试问题时一眼就能定位。如果你想让调试信息更强大,可以引入rich这类库,但至少先做到“打印对象能看到类名和状态”。

5. 常见问题与排查技巧实录

5.1 可变默认参数:经典坑

这个坑我已经在前面反复强调过,但它是 Python 类最常见的新手问题,值得单独再讲一次。问题代码长这样:

class Task: def __init__(self, tasks=[]): self.tasks = tasks

问题在于默认参数[]只在函数定义时被创建一次,之后所有不传tasks的实例,拿到的都是同一个列表。你往一个实例里加任务,其他实例也跟着加。正确的写法是:

class Task: def __init__(self, tasks=None): self.tasks = [] if tasks is None else tasks

或者用dataclass的field(default_factory=list)。出现这个问题的根源是 Python 函数默认参数在定义时求值,而不是每次调用时求值。理解这一点后,以后看到任何def f(x=[])都会本能地警惕。

5.2 继承中的 super() 与方法解析顺序

单继承时super()很好理解,就是调用父类的下一个方法。一旦出现多重继承,super()不再简单指“父类”,而是按方法解析顺序(MRO)找到下一个拥有该方法的类。菱形继承会让顺序变得很反直觉。

看这个例子:

class A: def hello(self): print("A") class B(A): def hello(self): print("B") super().hello() class C(A): def hello(self): print("C") super().hello() class D(B, C): def hello(self): print("D") super().hello()

调用D().hello()输出顺序是 D、B、C、A,而不是 D、B、A、C。这背后是 C3 线性化算法在起作用。要完全搞懂 MRO 需要看算法,但在工程上我的建议很简单:尽量避免多重继承,如果避免不了,就用class_name.__mro__打印出来自己看一眼顺序,确认super()的调用链路符合预期。哪怕只是 4 层的菱形继承,出错时排查成本也很高。

5.3 循环导入与类设计

两个模块互相引用对方的类,是 Python 项目里的经典报错:ImportError: cannot import name ... from partially initialized module。比如a.py里定义了class A,b.py里定义了class B,A的某个方法需要B,B的某个方法又需要A,一导入必然出问题。

解决方案分三层:

  • 最优先的思路是从设计上消除循环依赖。把两边都依赖的公共接口或数据模型抽到第三个模块,比如models.py。
  • 如果暂时不好重构,可以在方法内部延迟导入,而不是在模块顶部导入。
  • 也可以把from b import B改成import b,在方法里使用b.B,利用 Python 模块加载完成后再访问属性来绕开部分场景。

但延迟导入只是止痛药,根治还得靠重新划分职责。类与类之间的依赖应该是单向的,A 依赖 B、B 不依赖 A,这样代码才能分层演进。

5.4 常见问题速查表

问题原因解决方法
所有实例共享同一个列表/字典可变默认参数在定义时求值一次用None加判断,或用dataclass.field(default_factory=...)
类变量被实例修改后全局污染类变量是可变对象,被所有实例共享需要独立数据时放到__init__里用self.xxx创建
property递归导致RecursionError内部__init__直接给self.xxx赋值,触发 setter,setter 再赋值内部用self._xxx存值,对外用property
定义__slots__后无法动态加属性__slots__移除了实例的__dict__如果确实需要动态属性,不要再加__slots__;用它的类要注意继承关系
dataclass使用可变字段默认值报错直接写= []或= {}非法用field(default_factory=list)
多重继承下super()调用顺序诡异方法解析顺序是 C3 线性化结果尽量避免多重继承,必要时打印.__mro__确认
实例在repr里什么都看不到类没实现__repr__手写__repr__,尽量让字符串能重建对象
两个模块互相导入报错循环依赖抽公共模块、延迟导入、重新分层

这些坑我基本都踩过。每次排查到最后,绝大多数原因不是语法不会,而是对“类变量和实例变量”“共享与复制”“继承与组合”这几个底层概念理解不够扎实。我之前写过一个订单模块,因为默认参数是空列表,导致生产环境两个订单共用了商品列表,用户 A 下单成功用户 B 的表单也被填上 A 的商品。那次事故之后我才真正明白,把这些“小细节”当成大事对待有多重要。

我个人的体会是,类设计没有一劳永逸的标准答案,但有一些判断方法是可以反复用的:先想数据边界,再想行为归属;先倾向组合,再考虑继承;先保证类能测试,再追求性能优化。把这一套想清楚了,Python 的类写起来就会自然顺手,而不是每次创建新类都在试错。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询