Python 的类语法很短,但它背后的模型并不简单:属性可以来自实例、类、基类或描述符;特殊方法把对象接入运算符和容器协议;dataclass 能生成样板代码,却不能替代领域约束。
本文以 CPython 3.14 为基线,解释类、继承、组合、属性、特殊方法和数据类,并给出适合工程代码的边界。
1. 类对象与实例对象
类语句执行后会创建类对象,调用类对象通常会创建实例:
1
2
3
4
5
6
7
8
9
10
11
12
13
| class Sensor:
unit = "°C"
def __init__(self, name: str, value: float) -> None:
self.name = name
self.value = value
def display(self) -> str:
return f"{self.name}: {self.value:.1f}{self.unit}"
sensor = Sensor("chamber", 24.5)
print(sensor.display())
|
unit 是类属性,默认由所有实例共享;name 和 value 是实例属性。方法通过描述符绑定成为“绑定方法”,调用 sensor.display() 时,实例会作为第一个参数 self 传入。
self 只是约定名称,不是关键字,但偏离约定会降低可读性。
2. 类属性与实例属性不要混淆
可变类属性会被所有未覆盖它的实例共享:
1
2
3
4
5
6
7
8
| class BadRegistry:
items: list[str] = []
first = BadRegistry()
second = BadRegistry()
first.items.append("A")
assert second.items == ["A"]
|
如果每个实例需要独立列表,应在 __init__ 中创建:
1
2
3
| class Registry:
def __init__(self) -> None:
self.items: list[str] = []
|
属性查找会沿实例、类和方法解析顺序等路径进行,但数据描述符等机制会影响具体优先级。工程中不必靠记忆所有细节写“聪明代码”;避免同名属性在多个层级承担不同含义,接口会更稳健。
3. 实例方法、类方法与静态方法
1
2
3
4
5
6
7
8
9
10
11
| class Temperature:
def __init__(self, celsius: float) -> None:
self.celsius = celsius
@classmethod
def from_fahrenheit(cls, value: float) -> "Temperature":
return cls((value - 32) * 5 / 9)
@staticmethod
def is_valid(value: float) -> bool:
return value >= -273.15
|
- 实例方法接收
self,操作具体对象; - 类方法接收
cls,常用于替代构造函数,并能正确构造子类; - 静态方法不接收隐式实例或类,适合与类型概念紧密相关、但不依赖状态的函数。
如果一个函数与类的概念没有内聚关系,把它保留为模块级函数通常更清楚。
4. 封装靠接口与约定
Python 没有与某些静态语言完全相同的私有字段机制:
_name 表示“非公开实现细节”的约定;__name 会触发名称改写,主要用于避免子类意外覆盖,并非安全边界;- 调用者在运行时仍可通过反射或改写后的名称访问对象。
封装的价值不在于阻止恶意访问,而在于给维护者稳定的公共接口。使用属性可以在保持访问语法的同时加入约束:
1
2
3
4
5
6
7
8
9
10
11
12
13
| class Progress:
def __init__(self, value: float = 0.0) -> None:
self.value = value
@property
def value(self) -> float:
return self._value
@value.setter
def value(self, new_value: float) -> None:
if not 0 <= new_value <= 100:
raise ValueError("进度必须位于 0 到 100")
self._value = new_value
|
不要把昂贵 I/O 或不可预期副作用藏进普通属性读取;调用者通常预期属性访问轻量且稳定,此时显式方法更合适。
5. 特殊方法把对象接入语言协议
双下划线包围的特殊方法由语言协议调用。一个不可变的二维向量:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| from dataclasses import dataclass
from math import hypot
@dataclass(frozen=True, slots=True)
class Vector:
x: float
y: float
def __add__(self, other: object) -> "Vector":
if not isinstance(other, Vector):
return NotImplemented
return Vector(self.x + other.x, self.y + other.y)
def __abs__(self) -> float:
return hypot(self.x, self.y)
|
返回 NotImplemented 不是抛异常,它让 Python 尝试右操作数的反向运算,若双方都不支持再抛 TypeError。不要在不支持时返回 False 或随意转换类型。
常见协议包括:
__repr__:面向开发者的表示;__str__:面向用户的文本;__eq__、__hash__:相等性与哈希;__len__、__iter__、__contains__:容器行为;__enter__、__exit__:上下文管理;__call__:可调用对象。
特殊方法通常由类型而不是实例属性解析,因此不应试图给单个实例临时挂一个 __len__ 来改变 len(obj)。
6. dataclass 生成什么
数据类根据类型标注字段生成 __init__、__repr__、比较方法等样板代码:
1
2
3
4
5
6
7
8
| from dataclasses import dataclass, field
@dataclass(slots=True)
class Batch:
batch_id: str
values: list[float] = field(default_factory=list)
enabled: bool = True
|
可变默认值使用 default_factory,确保每个实例获得新列表。直接写 values: list[float] = [] 这类不可哈希的可变默认值会被 dataclass 拒绝,但仍应理解 default_factory 创建的每个对象归单个实例所有。
常用选项:
frozen=True:阻止普通字段赋值,适合值对象;它不是深度不可变,字段指向的可变对象仍可改变;slots=True:生成槽位,通常减少实例字典开销,并限制随意增加属性;不要在未测量时承诺固定节省比例;order=True:生成顺序比较,前提是字段顺序确实代表业务排序;kw_only=True:让字段只能按关键字传入,降低构造函数参数错位风险。
派生字段和跨字段校验放到 __post_init__():
1
2
3
4
5
6
7
8
9
10
11
| from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Range:
lower: float
upper: float
def __post_init__(self) -> None:
if self.lower > self.upper:
raise ValueError("lower 不能大于 upper")
|
数据类不负责自动验证类型标注,也不会自动把嵌套字典转为领域对象。
7. 相等性、哈希与可变性必须一致
对象作为字典键或集合元素时,其哈希值必须在驻留期间稳定,并满足“相等对象具有相同哈希值”。
dataclass 会根据 eq、frozen 和 unsafe_hash 决定是否生成 __hash__。不要为了塞进集合而盲目设置 unsafe_hash=True:若参与比较的字段可变,修改后对象可能再也无法从集合中正确找到。
适合作为值对象的类型通常具有:
- 由全部有效字段定义的相等性;
- 构造后不可变;
- 稳定哈希;
- 创建新值而非原地修改的操作。
8. 继承、MRO 与 super()
继承表达“是一个”关系:
1
2
3
4
5
6
7
8
9
10
11
12
| class Device:
def __init__(self, device_id: str) -> None:
self.device_id = device_id
def start(self) -> None:
print(f"start {self.device_id}")
class Camera(Device):
def __init__(self, device_id: str, exposure_ms: float) -> None:
super().__init__(device_id)
self.exposure_ms = exposure_ms
|
super() 不简单等于“调用父类”,而是按当前类的方法解析顺序(MRO)继续查找。多重继承中,参与协作的方法应使用兼容签名并继续调用 super(),否则调用链可能断裂。
可以检查解析顺序:
多重继承适合小型、正交的 mixin;复杂业务复用通常优先组合。
9. 组合往往比继承更稳定
组合表达“拥有一个”关系,并把依赖显式放入构造函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| from dataclasses import dataclass
from typing import Protocol
class Writer(Protocol):
def write(self, message: str) -> None: ...
@dataclass(slots=True)
class AlarmService:
writer: Writer
def raise_alarm(self, message: str) -> None:
self.writer.write(f"ALARM: {message}")
|
AlarmService 不关心写入器的具体继承树,只依赖所需行为。测试时可以传入内存实现,生产中传入日志、消息队列或数据库适配器。这里的 Protocol 会在类型系统一篇深入解释。
10. 一个完整的小型领域模型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| from dataclasses import dataclass, field
from datetime import datetime, timezone
@dataclass(frozen=True, slots=True)
class Measurement:
name: str
value: float
unit: str
captured_at: datetime = field(
default_factory=lambda: datetime.now(timezone.utc)
)
def __post_init__(self) -> None:
if not self.name.strip():
raise ValueError("name 不能为空")
if self.captured_at.tzinfo is None:
raise ValueError("captured_at 必须包含时区")
def converted(self, factor: float, target_unit: str) -> "Measurement":
return Measurement(
name=self.name,
value=self.value * factor,
unit=target_unit,
captured_at=self.captured_at,
)
|
这个模型把有效性约束放在构造边界,用带时区时间避免含糊的本地时间,并通过创建新实例保持值对象不变。frozen=True 依旧不是安全边界,但它清晰表达了设计意图。
11. 小结
- 类属性由实例共享,可变状态通常应在
__init__ 或 default_factory 中创建。 - 属性适合轻量约束;昂贵操作和副作用使用显式方法。
- 特殊方法是语言协议的一部分,不支持二元运算时应返回
NotImplemented。 dataclass 减少样板代码,但不会自动提供类型验证、深度不可变或正确领域模型。super() 沿 MRO 协作;复用业务能力时通常优先组合与窄接口。
下一篇将讨论异常边界、资源释放、模块与包的组织,以及日志和配置如何进入工程结构。
参考资料