Python 编程基础与工程实践(五):对象模型、类与 dataclass

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 是类属性,默认由所有实例共享;namevalue 是实例属性。方法通过描述符绑定成为“绑定方法”,调用 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 会根据 eqfrozenunsafe_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(),否则调用链可能断裂。

可以检查解析顺序:

1
print(Camera.mro())

多重继承适合小型、正交的 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 协作;复用业务能力时通常优先组合与窄接口。

下一篇将讨论异常边界、资源释放、模块与包的组织,以及日志和配置如何进入工程结构。

参考资料

Licensed under CC BY-NC-SA 4.0