类、对象协议与 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:先确认对象是否真的具有值语义和固定字段。