业务中不用dataclass直接当值对象,因其默认可变、无值语义、不校验输入;应手写__init__+__eq__+__hash__,确保不可变、构造即校验、相等性仅依赖可哈希字段内容,并将校验逻辑抽离为独立方法。

为什么不用 dataclass 直接当业务实体?
因为 dataclass 默认可变、无值语义、不校验输入——业务里一个订单对象被意外修改字段,或两个相同订单判断不等,都会引发隐蔽逻辑错误。
值对象的核心诉求是:相等性只看字段内容,不可变,构造即校验。Python 原生没内置值对象类型,得自己控住边界。
- 用
@dataclass(frozen=True)是最简起点,但冻结后连__post_init__里的字段修正都报错,不适合需要规范化输入的场景(比如把"2024-01-01"自动转成date) - 真正可控的做法是手写
__init__+__eq__+__hash__,显式声明哪些字段参与比较,且只在初始化时做转换和校验 - 别依赖
__dict__或vars()做序列化,它们会暴露内部实现细节;统一走asdict()或自定义to_dict()
__eq__ 和 __hash__ 必须同步定义
只重写 __eq__ 不加 __hash__,对象会自动变成不可哈希(TypeError: unhashable type),导致无法放进 set 或当 dict 的 key——这在去重订单项、缓存聚合根状态时很常见。
更麻烦的是,如果字段里含可变类型(如 list、dict),即使写了 __hash__,运行时也可能抛 TypeError: unhashable type。
立即学习“Python免费学习笔记(深入)”;
客客出品专业威客系统KPPW(简称KPPW)是武汉客客团队自主研发的开源系统项目,主要应用于威客模式的在线服务交易平台搭建。KPPW客客出品的专业威客系统,是keke produced professional witkey的缩写。产品业务核心功能是基于任务悬赏交易和用户服务商品交易为主构建一个C2C的电子商务交易平台,其主要交易对象是以用户为主的技能、经验、时间和智慧型商品。经过多年发展,KPP
- 确保所有参与
__eq__判断的字段本身可哈希(优先用tuple、str、int、date等) - 若必须含列表(如地址行),先转成
tuple再参与比较:tuple(self.lines) - 别在
__hash__里调用耗时操作(如数据库查询),它可能被频繁调用
业务字段校验不能只靠 __post_init__
很多人以为加个 @dataclass 再写个 __post_init__ 就万事大吉,但这里有两个硬伤:一是异常堆栈指向 __init__ 而非具体字段,二是校验逻辑和领域规则混在一起,难测试、难复用。
比如「手机号必须是中国大陆 11 位数字」这种规则,应该独立成方法,而不是塞进初始化流程里。
- 把校验逻辑拆到类方法(如
validate_phone(phone: str) -> str),返回标准化后的值,或抛出带字段名的ValueError - 在
__init__中调用它,但不要捕获异常——让错误冒泡,业务层才能决定是提示用户还是拒绝创建 - 避免在
__init__中做 I/O 或远程调用,值对象应是纯内存结构
和 Pydantic BaseModel 混用时的坑
不少项目用 Pydantic 做 API 入参校验,再把解析后的 BaseModel 实例直接当业务值对象用——这会导致行为不一致:Pydantic 默认可变、支持字段动态赋值、__eq__ 比较的是所有字段(包括未设置的 None),而业务上往往只关心“有意义的字段”。
更危险的是,Pydantic 的 model_copy() 默认浅拷贝,嵌套模型修改会影响原对象。
- 别直接继承
BaseModel当值对象;要么用BaseModel只做 DTO,再手动映射到纯 Python 值对象;要么用BaseModel的__slots__ = True+ 自定义__eq__+__hash__ - 如果用 Pydantic v2,可用
model_config = ConfigDict(frozen=True, extra='forbid'),但仍需重写__eq__控制相等逻辑 - 注意
BaseModel的dict()方法默认包含None字段,而值对象通常要过滤掉未设置项
值对象真正的复杂点不在代码怎么写,而在“哪些字段算本质属性”。比如「金额」要不要带币种?「地址」要不要标准化到四级行政区?这些不是技术问题,是业务语义的落地——写完代码后,得拉着产品一起对字段定义,否则后面改一次等于重构整个值对象链。







