Python dataclasses 进阶:default_factory、post_init校验与 frozen 不可变的三个坑
用@dataclass定义数据类,大部分人第一次写就顺手了:
fromdataclassesimportdataclass,field@dataclassclassOrder:id:intitems:list=[]然后运行:
ValueError:mutable default<class'list'>forfield itemsisnotallowedPython 直接拦住了。这是个好设计——它替我们挡住了可变默认参数那个经典陷阱。但拦住之后,顺着往下走会撞上一连串更隐蔽的问题:default_factory什么时候会踩到循环引用、__post_init__里为什么frozen=True的字段赋值会报错、field(init=False)和__post_init__的配合、以及frozen=True是不是真的不可变。这篇把这些逐个拆开。
一、default_factory:为什么必须是可调用对象,而不是值
field(default_factory=list)的道理和函数默认参数一样:默认值在类定义时求值一次,所有实例共享同一个对象。传[]就是给所有Order共享一个列表,a.items.append(1)会让b.items也变。所以dataclass干脆禁掉,逼你用工厂函数,让默认值在实例创建时才求值。
工厂函数可以是任何零参可调用对象:
fromdataclassesimportdataclass,field@dataclassclassOrder:id:intitems:list[str]=field(default_factory=list)created_at:datetime=field(default_factory=datetime.now)# 注意:函数本身,不加括号trace_id:str=field(default_factory=lambda:uuid4().hex)default_factory=datetime.now写成datetime.now()是个高频笔误,后果还分两种:写成datetime.now()是把「导入模块那一刻的时间」固化下来,所有订单一辈子共享同一个创建时间;如果是list()这类返回可变对象的,又会退化成共享实例。只要工厂后面的东西带了括号,基本就是错的。
想省掉 lambda,functools.partial也行:
fromfunctoolsimportpartial retry_count:int=field(default_factory=partial(int,"0"))factory 引用其它字段:只能靠__post_init__
default_factory是零参的,拿不到已初始化的其它字段。想要「默认值依赖另一个字段」,常见需求是「过期时间默认等于创建时间 + 30 分钟」:
@dataclassclassSession:created_at:datetime=field(default_factory=datetime.now)expires_at:datetime=field(init=False)max_idle:timedelta=timedelta(minutes=30)def__post_init__(self):self.expires_at=self.created_at+self.max_idleinit=False表示这个字段不进__init__的签名(调用方不能传),但字段本身仍然存在,会在__post_init__里被赋值。这样Session(max_idle=timedelta(hours=2))依然可用,expires_at自动算出来。
二、__post_init__做校验:统一入口,但有两个前提
校验逻辑写在__post_init__里,天然覆盖了「直接构造」和「从字典解包构造」两条路径——只要走__init__,就会过一遍:
@dataclassclassUser:email:strage:inttags:list[str]=field(default_factory=list)def__post_init__(self):if"@"notinself.email:raiseValueError(f"邮箱格式不对:{self.email!r}")ifnot0<=self.age<=150:raiseValueError(f"年龄超出范围:{self.age}")iflen(self.tags)>10:raiseValueError("标签最多 10 个")前提一:必须用raise,而不是只打日志或只修正。见过不少人在这里self.age = max(0, self.age),把非法值悄悄夹逼成合法值。这在配置解析里是灾难:上游传错了参数,你改成个「差不多」的值继续跑,问题被推迟到更难定位的地方才爆发。校验就该失败得干脆。
前提二:注意字段初始化顺序。__post_init__在所有字段赋值之后执行,所以这里能读到全部字段。但如果字段本身有default_factory且工厂会抛错,报错发生在__post_init__之前,栈里看到的是工厂函数的异常,别在那里找校验逻辑。
继承场景:__post_init__不会自动调用父类
子类定义了自己的__post_init__,父类的就不会执行。这是个容易漏的坑:
@dataclassclassBase:name:strdef__post_init__(self):ifnotself.name.strip():raiseValueError("name 不能为空")@dataclassclassEmployee(Base):dept:str="unknown"def__post_init__(self):ifnotself.dept:raiseValueError("dept 不能为空")# 忘了调父类 -> name 的空校验被绕过Employee(name=" ")不会报错。修复方式是显式委托:
def__post_init__(self):super().__post_init__()ifnotself.dept:raiseValueError("dept 不能为空")子类多的时候,super().__post_init__()漏一个就是一个静默的校验缺口。如果继承层级超过两层,建议校验逻辑收敛到一个显式的validate()方法,由根类的__post_init__调用,而不是让每层各自持有__post_init__。
三、frozen=True:不可变声明的三个坑
frozen=True让字段赋值抛FrozenInstanceError,能安全地把实例当字典 key、放进 set,也能防止被下游代码随手改:
@dataclass(frozen=True)classPoint:x:floaty:floatp=Point(1.0,2.0)# p.x = 3.0 -> dataclasses.FrozenInstanceErrorcache={p:"原点附近"}# 可哈希,能当 key,靠的是 frozen坑 1:frozen 和__post_init__天生冲突
frozen=True时,__post_init__里self.expires_at = ...会直接抛FrozenInstanceError。要用object.__setattr__绕过去:
@dataclass(frozen=True)classSession:created_at:datetime=field(default_factory=datetime.now)expires_at:datetime=field(init=False,default=None)def__post_init__(self):object.__setattr__(self,"expires_at",self.created_at+timedelta(minutes=30))object.__setattr__看起来像走后门,但在frozen+ 派生字段这个组合里是官方认可的写法,因为字段确实只在初始化时算一次,之后不再变。
坑 2:frozen 只冻结字段,不冻结字段里的对象
这是最要命的一个。frozen=True管的是「不能重新绑定self.field」,不管「self.field指向的那个对象内部能不能改」:
@dataclass(frozen=True)classConfig:tags:list[str]=field(default_factory=list)c=Config()c.tags.append("prod")# 完全合法,frozen 拦不住print(c.tags)# ['prod']而且列表本身不可哈希,hash(c)会直接抛TypeError: unhashable type: 'list'——frozen 带来的哈希能力,被字段类型抵消了。要真正做到不可变,字段得用不可变类型:
@dataclass(frozen=True)classConfig:tags:tuple[str,...]=()# 或者 frozenset / MappingProxyType这个原则对嵌套也成立:一个 frozen 的类里放一个可变对象(比如另一个非 frozen 的 dataclass),就相当于只有一层壳是硬的。真要深不可变,得逐层选不可变容器,或者用不可变数据结构库。
坑 3:frozen=True和eq=False、继承的组合限制
frozen=True的类不能被非 frozen 的类继承,反过来也不行——依赖方向反了会报:
@dataclass(frozen=True)classBase:...@dataclassclassChild(Base):...# TypeError: cannot inherit non-frozen dataclass from a frozen one想混用,整个继承链都得是 frozen。另外eq=False时__hash__不生成(沿用object的按身份哈希),此时两个内容相同的实例不等也不可互换,但它仍然可以当字典 key;而frozen=False且eq=True(默认)的类,__hash__被置为None,直接不可哈希。这几条组合规则记不住的时候,别猜——写个三行脚本跑一下比查文档快。
四、与slots=True搭配:省内存,但会锁死字段
Python 3.10 起@dataclass(slots=True)会自动生成__slots__,字段多、实例多的时候内存立竿见影。但它有个连带限制:__slots__要求类里不能再有同名的类属性,而default_factory生成的默认值在__init__里赋值,没问题;可一旦你依赖「动态往实例上挂额外属性」,就会AttributeError:
@dataclass(slots=True)classEvent:name:stre=Event("click")e.timestamp=123# AttributeError: 'Event' object has no attribute 'timestamp'另外slots=True会为每个类创建一个新类(因为__slots__必须在类创建时声明),这意味着super()的零参写法在个别场景下要注意,以及各种缓存了类对象的装饰器(比如某些 ORM 的注册机制)可能拿到旧引用。业务代码里通常没问题,但和元编程、框架集成时要留心。
小结
default_factory必须传可调用对象,不能带括号。带括号的后果是「值在类定义时求值一次、所有实例共享」。- factory 是零参的,拿不到其它字段;要「默认值依赖另一个字段」就用
field(init=False)+__post_init__。 - 校验写在
__post_init__,用raise让它失败得干脆,别把非法值静默夹逼成合法值。继承时记得super().__post_init__(),否则父类校验会被静默绕过。 frozen=True的实例里要赋值派生字段,用object.__setattr__。frozen只冻结字段绑定,不冻结字段里的可变对象。要真正不可变,字段类型也要换成tuple、frozenset等不可变容器。- 继承链上
frozen必须一致;eq与__hash__的联动规则拿不准就跑个三行脚本验证。 slots=True省内存,代价是不能再往实例上挂动态属性。
一句话记忆点:dataclass 替你挡住了「共享可变默认值」这一个坑,但default_factory拿不到兄弟字段、__post_init__会被子类覆盖、frozen只冻结一层——这三件事它不会提醒你,得自己记牢。