在 Python 中构建领域模型或配置对象时,常常需要在不可变性和易用性之间找平衡——既不想让状态被意外修改,又希望字段更新(尤其是嵌套字段)能像函数调用一样自然流畅。你可能试过 dataclasses.replace(),它确实能处理顶层字段替换,但遇到嵌套的冻结数据类(比如一个 Occupation 实例),默认机制就不会帮你做“深层合并式”更新了。直接在 frozen=True 的类里定义 @property 加 @setter 更是踩坑:object.__setattr__ 会绕过冻结检查,逻辑也难以维护。所以,纯构造式方案才是正道。
核心思路非常简单:让每个冻结数据类自己具备“生成新实例”的能力,然后在父类的 __call__ 中智能处理嵌套子对象的重建。下面就是这个方案的实现,代码量不多,但足够优雅:
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Occupation:
title: str
salary: int
def __call__(self, **kwargs):
return replace(self, **kwargs)
@dataclass(frozen=True)
class Person:
name: str
occupation: Occupation
def __call__(self, **kwargs):
# 若传入 occupation 参数,先将其转换为新的 Occupation 实例
if 'occupation' in kwargs:
occ_arg = kwargs['occupation']
if isinstance(occ_arg, dict):
# 深层合并:用当前 occupation 作为基础,覆盖指定字段
new_occ = self.occupation(**occ_arg)
elif isinstance(occ_arg, tuple):
new_occ = self.occupation(*occ_arg)
elif isinstance(occ_arg, Occupation):
new_occ = occ_arg
else:
raise TypeError(f"Unsupported occupation type: {type(occ_arg)}")
kwargs['occupation'] = new_occ
return replace(self, **kwargs)
用起来是什么感觉?直接看例子:
# 初始化
a0 = Person(name='John', occupation=Occupation(title='junior', salary=30))
a1 = a0(name=a0.name + ' Smith') # → Person(name='John Smith', ...)
a2 = a1(occupation={'title': 'senior'}) # → 新 Occupation(title='senior', salary=30)
a3 = a2(occupation={'salary': 50}) # → 新 Occupation(title='senior', salary=50)
可以看到,通过 a(occupation={'salary': X}) 这种写法,语义一目了然,完全不需要手动解构再构造 Occupation。
优势在哪里
- 完全不可变:所有实例都是
frozen=True,没有 setter,也没有隐藏状态——想改只能生成新对象。 - 嵌套即扁平:更新深层字段就像在顶层操作一样直观,少了中间层的样板代码。
- 类型安全:
Occupation.__call__也返回Occupation,IDE 和 mypy 都能推导类型,不会出现“字段拼错”这种低级问题——当然,字段名必须严格一致,比如sallary就不是salary,否则**kwargs会直接报错。 - 可扩展性强:无论嵌套多少层(比如 Company → Department → Person → Occupation),每个类都沿用一个模式,代码量线性增长而非爆炸式增长。
需要留意的细节
- 字典合并的行为:上面例子中
self.occupation(**occ_arg)是以当前实例为模板,仅覆盖传入的键,其他字段自动继承。所以{'title': 'lead'}并不会丢掉原有的salary——它会保留。这正是“合并”而非“替换”的意图。 - 更高级的合并策略:如果希望递归合并嵌套的字典(比如
occupation内部还有更深的字典),可以把Occupation.__call__替换成自定义的merge_with方法,灵活度完全由你掌控。
最后说一句:如果项目中这种模式大量出现,可以考虑用元类或在 __post_init__ 阶段动态注入 __call__,或者直接上 pydantic v2(它的 model_copy(update=...) 更强大)或 immutablecollections。但对于轻量级场景,上面这个纯 dataclass 方案已经足够稳健、透明,而且零依赖——有什么比零依赖更让人安心的呢?