Course Builder个人学习工作台 · 本地模式
Modern Python:面向有经验开发者的回归路线

类、对象协议与 dataclass

目标:能够区分普通行为对象与值对象,并用 dataclass 正确表达默认值、不变式、相等性和只读意图。

Python 的类不只是一组字段。__init__ 负责初始化已创建的实例,self 是显式实例参数;__str__、__repr__、__len__ 等特殊方法把对象接入语言协议。与 Go interface 不同,普通 Python 代码可以直接依赖 duck typing;与 Rust struct 不同,对象字段默认可在运行时增加和修改。

先问对象是什么:

  • 如果它主要封装生命周期和行为,例如有缓存、连接或状态转换,普通 class 更自然。
  • 如果它主要表达一组有值语义的字段,dataclass 可以生成 __init__、__repr__ 和 __eq__。
  • 如果输入来自不可信 JSON,dataclass 不会自动验证类型;后面由 Pydantic 处理边界。

完整示例:消息值对象与统计器

保存为 models_lab.py:

from __future__ import annotations

from dataclasses import dataclass, field


@dataclass(frozen=True, slots=True)
class Message:
    text: str
    priority: int = 0
    tags: tuple[str, ...] = field(default_factory=tuple)

    def __post_init__(self) -> None:
        cleaned = self.text.strip()
        if not cleaned:
            raise ValueError("text must not be empty")
        if not 0 <= self.priority <= 10:
            raise ValueError("priority must be between 0 and 10")
        object.__setattr__(self, "text", cleaned)


class MessageStats:
    def __init__(self) -> None:
        self._count = 0
        self._total_chars = 0

    def add(self, message: Message) -> None:
        self._count += 1
        self._total_chars += len(message.text)

    def __len__(self) -> int:
        return self._count

    @property
    def average_chars(self) -> float:
        return self._total_chars / self._count if self._count else 0.0


if __name__ == "__main__":
    first = Message("  hello  ", tags=("demo",))
    second = Message("world", priority=2)
    stats = MessageStats()
    stats.add(first)
    stats.add(second)
    print(first)
    print(f"count={len(stats)}, average={stats.average_chars:.1f}")

运行:

uv run python models_lab.py

预期输出:

Message(text='hello', priority=0, tags=('demo',))
count=2, average=5.0

逐步理解选择

Message 是值对象:字段相同的两个实例相等。frozen=True 阻止普通字段赋值,但不保证深度不可变;因此 tags 使用 tuple 而不是 list。slots=True 固定实例字段集合并通常减少内存,但不是“自动性能优化”按钮。

在 frozen dataclass 的 __post_init__ 里,普通 self.text = cleaned 会失败;object.__setattr__ 是构造阶段规范化的明确逃生口。不要在对象建好后继续这样修改。

MessageStats 是有生命周期的累积器。它按身份持有变化状态,不需要 dataclass 自动相等性。__len__ 让 len(stats) 成为语言协议的一部分,@property 把只读派生值暴露成属性。

引导实验:验证 frozen 不是深冻结

把 tags 临时改成 list[str] 并构造 frozen Message。你不能重新赋值 message.tags = [],但仍可执行 message.tags.append("x")。这证明 frozen 只拦截字段绑定,不递归冻结字段指向的对象。

恢复 tuple,并运行:

assert hash(Message("hello")) == hash(Message("hello"))

字段都可 hash 时,默认 eq=True, frozen=True 会生成合适的 hash。

独立练习

给 Message 增加 with_tag(tag: str) -> Message,返回新实例而不是修改当前实例;重复标签不应增加。

参考实现
def with_tag(self, tag: str) -> "Message":
    normalized = tag.strip().lower()
    if not normalized or normalized in self.tags:
        return self
    return Message(self.text, self.priority, (*self.tags, normalized))

验收:

original = Message("hello")
updated = original.with_tag(" Demo ")
assert original.tags == ()
assert updated.tags == ("demo",)
assert updated.with_tag("demo") is updated

常见失败

  • dataclass 字段写 members: list[str] = []:使用 field(default_factory=list);现代 dataclass 还会拒绝部分明显的可变默认值。
  • 认为标注会验证 Message(text=123):标注不自动执行运行时校验。
  • 给所有类加 frozen=True, slots=True:先确认对象是否真的具有值语义和固定字段。

参考资料