Python 13:dataclass 能保证哪些不变性
@dataclass(frozen=True) 拒绝给字段重新赋值,却不阻止修改字段里保存的列表。一个测试先得到 FrozenInstanceError,紧接着又成功把字符串追加进同一个对象的 labels。这两项输出不矛盾:冻结约束的是字段赋值入口,不是递归冻结对象图。
在 12 的任务实例基础上,tasklab 把任务定义与词频结果提炼为两种 dataclass,状态迁移仍由原有 Task 和 run 负责。外部 query 继续返回同一形状的快照,旧实验无需改调用方式。
自动生成方法并不自动定义所有权
1 | |
两个内容相同的 TaskDefinition 值比较相等,但可以是两个不同的对象;自动生成的相等方法按字段比较,不能代替身份判断。任务定义把标签转成字符串元组,是为了在当前受支持的模式下避免外层冻结、内层列表仍被修改。类型标注本身不会检查传进来的文本或标签,submit 仍必须在创建任务前完成运行输入验证。
TaskResult 的默认计数字典必须为每个实例单独创建;dataclasses.field(default_factory=...)就是这条规则的工具。实测两份空结果的 counts 不是同一个字典,向第一份写入 "one": 1 后第二份仍为空。这与 05 可变默认参数跨调用污染是同一种所有权问题在数据类上的表现,但修复位置不同。
frozen=True 的边界在哪里
故意保留一份反例:
1 | |
实验的原始输出记录 "deep_mutation": ["still mutable"] 和 "assignment_error": "FrozenInstanceError"。Python 3.12 frozen 实例说明也明确指出不能通过该机制获得真正不可变的 Python 对象;frozen 模拟禁止常规字段赋值,不会遍历每个可变引用。
业务中的任务定义使用不可变元组保存标签,查询时再产生新列表交给外部调用方,避免影响原有 JSON 形状;计算结果存在 TaskResult 中,查询时复制其计数字典。run 的对外返回仍是普通字典,成功终态仍是 succeeded。本次已在改动内部存储后重放前十二章累计用例;这证明当前支持的字段模式和调用方式没有破坏,不证明“dataclass 对任意嵌套输入都安全”。
从 examples/python-language/ 执行:
1 | |
两条命令的退出码均为 0,累计运行 40 条测试。新增断言覆盖默认字典不共享、冻结字段赋值被拒绝、嵌套可变列表仍可修改,以及生产任务定义采用元组、查询快照不能反向修改内部标签。具体输入、原始 stdout/stderr 和环境记录位于 examples/python-language/evidence/13/20261007-dataclass/。
预测题
FrozenExample 的两个实例都没有显式传 labels。向其中一个实例的 labels 追加词,另一个会跟着变化吗?再给第一个实例执行 labels = [] 呢?
答案:default_factory=list 为每个实例建新列表,另一个不跟着变化;对第一个实例的字段重新赋值仍抛 FrozenInstanceError。前半项由独立默认值测试在 TaskResult 的字典场景执行,FrozenExample 的双实例变式可用同样的对象图推导,未作为额外实测结论。
变更题
把生产 TaskDefinition.labels 从元组改成列表,又保留 frozen=True,还需要怎样证明外部无法修改已经提交的任务定义?
答案:不能只检查 definition.labels = other 会失败;要保留外部列表并在提交后修改最内层,比较任务快照和计算结果,必要时在创建边界复制并禁止可变别名逃逸。本章的反例已证明“frozen 不会深度保护列表”;生产实现没有采用此变更。
下一章检查特殊方法与 property 的协议边界,避免把状态校验藏进不可观察的魔术里。系列导航只列已完成章节。




