046、dataclass 好用但有坑:field、__post_init__、可变默认值的教训 046、dataclass 好用但有坑field、post_init、可变默认值的教训上周五晚上十一点我盯着屏幕上一段不到三十行的dataclass代码头皮发麻。业务逻辑跑出来的结果总是莫名其妙地“串数据”——同一个订单号下不同用户的备注信息居然混在一起。排查了半小时最后发现罪魁祸首是一个list默认值。那一刻我恨不得穿越回去把写这段代码的自己拎起来摇醒。一个list引发的血案先看这个“经典错误”fromdataclassesimportdataclassdataclassclassOrder:order_id:stritems:list[]# 别这样写这里踩过坑这段代码看起来人畜无害但如果你创建两个Order实例order1Order(A001)order2Order(A002)order1.items.append(苹果)order2.items.append(香蕉)print(order1.items)# 输出 [苹果, 香蕉] —— 见鬼了print(order2.items)# 输出 [苹果, 香蕉]两个订单的items居然共享了同一个列表。原因很简单Python的默认参数在函数定义时就被求值所有没有显式传入items的Order实例都指向同一个list对象。dataclass只是把这种“经典陷阱”包装得更优雅了但坑还是那个坑。field函数救星还是新坑解决可变默认值问题的标准做法是用field函数配合default_factoryfromdataclassesimportdataclass,fieldfromtypingimportListdataclassclassOrder:order_id:stritems:List[str]field(default_factorylist)# 每次调用都新建一个list这样每个实例都有自己的items列表问题解决。但field能做的事情远不止于此。field的参数里有个init控制这个字段是否出现在__init__的参数中。我见过有人这样用dataclassclassUser:name:strcreated_at:datetimefield(initFalse)# 不暴露给外部然后手动在__post_init__里赋值。这个模式本身没问题但如果你忘了在__post_init__里赋值访问created_at就会报AttributeError——因为dataclass不会自动帮你初始化initFalse的字段。还有repr参数控制字段是否出现在__repr__输出里。敏感信息比如密码哈希值设成reprFalse是个好习惯。但要注意这不会影响__str__方法如果你自己重写了__str__该暴露的还是暴露。post_init初始化后的钩子但别滥用__post_init__是dataclass提供的一个钩子方法在__init__执行完毕后自动调用。这个设计很巧妙适合做字段校验、派生字段计算等。我常用的场景是字段校验dataclassclassTemperature:celsius:floatdef__post_init__(self):ifself.celsius-273.15:raiseValueError(f温度不能低于绝对零度:{self.celsius})另一个常见用法是计算派生字段dataclassclassRectangle:width:floatheight:floatarea:floatfield(initFalse)def__post_init__(self):self.areaself.width*self.height但这里有个坑如果你在__post_init__里修改了某个字段的值而这个字段又参与了其他字段的计算顺序就很重要。dataclass的字段定义顺序决定了__init__的参数顺序但__post_init__里没有“顺序保护”全靠你自己控制。更隐蔽的问题是继承。如果父类和子类都有__post_init__子类的__post_init__会覆盖父类的。正确的做法是在子类的__post_init__里显式调用父类的版本dataclassclassBase:x:intdef__post_init__(self):self.x*2dataclassclassDerived(Base):y:intdef__post_init__(self):super().__post_init__()# 别忘了这行self.yself.x我见过有人忘了调用super().__post_init__()结果父类的初始化逻辑完全没执行排查了半天才发现。可变默认值的更多教训除了listdict、set、自定义类实例都是可变对象都可能踩坑。但有一种情况容易被忽略嵌套的dataclass。dataclassclassAddress:city:strstreet:strdataclassclassPerson:name:straddress:AddressAddress(北京,长安街)# 这也是可变默认值这个Address实例在类定义时只创建一次所有使用默认address的Person实例都共享同一个Address对象。修改一个人的地址其他人的也跟着变。正确的做法还是用default_factorydataclassclassPerson:name:straddress:Addressfield(default_factorylambda:Address(北京,长安街))或者更简洁地如果Address本身是不可变的比如用frozenTrue那共享就没问题。但大多数情况下dataclass默认是可变的。实战中的经验教训所有可变类型默认值一律用default_factory。这是铁律没有例外。哪怕你现在觉得“这个列表肯定不会改”三个月后的你或者接手你代码的同事未必这么想。__post_init__里做校验但别做太重的逻辑。我见过有人在__post_init__里发HTTP请求、查数据库结果实例化一个对象慢得像在加载页面。__post_init__应该轻量、快速、无副作用。继承时小心__post_init__的覆盖问题。如果父类和子类都有初始化后逻辑记得用super()串联起来。更好的做法是尽量少用继承用组合代替。frozenTrue不是银弹。把dataclass设为不可变可以避免很多坑但代价是灵活性降低。如果你需要修改字段就得用dataclasses.replace()创建新实例性能开销和代码复杂度都会增加。slotsTruePython 3.10能省内存。如果你的dataclass实例数量很大比如百万级加上slotsTrue可以显著减少内存占用。但要注意slots会限制一些动态特性比如不能随意添加新属性。调试时善用__repr__的自定义。默认的__repr__输出所有字段对于包含大量数据的字段比如日志内容、长文本可以设成reprFalse避免调试输出刷屏。别在field的default参数里放可变对象。这个错误太常见了以至于我建议团队在代码审查时看到field(default[])直接打回必须改成field(default_factorylist)。最后说一句dataclass是好东西但它不是万能的。如果你的类逻辑复杂、有大量继承关系、或者需要精细控制初始化过程老老实实用普通的__init__可能更清晰。工具是为人服务的别为了用而用。