一个服务里参数一路用 dict 传:从 HTTP 层解析出来,经过三层函数,最后拼成 SQL。某天有人把 user_id 的键写成了 userId,代码不报错,只是查询条件变成了 None——返回了全表数据,然后在业务层被当成"没有过滤条件"处理。
判断"该不该定义类型"还有一条经验:当这个结构出现在函数签名里时。参数是 dict 的函数,读代码的人必须先看实现才知道需要哪些键;参数是类时,看一眼签名就够。
字典的问题不是"不够类型安全",而是键名没有单一来源。任何一处字符串都能成为一个"字段",而拼错的键不会报错,只会让 .get() 返回 None。
1. 三种容器的成本
| 形态 | 成本 |
|---|---|
dict |
键名靠约定,无补全、无检查 |
NamedTuple |
轻量、可解包,但不可变且无默认值 |
@dataclass |
有默认值、可校验、可加方法 |
| Pydantic 模型 | 运行时校验、序列化一体 |
第一项的代价是隐性的:拼错的键在运行到那行时才暴露,而那时可能已经做了别的事。第二项适合"纯数据返回值",第三项适合"有构造逻辑的对象"。
四种形态之间不是替代关系,而是分工:边界用 Pydantic,内部用 dataclass,纯返回值用 NamedTuple,动态结构用 dict。一个项目同时用四种是正常的,全用一种是可疑的。
第四项在"输入边界"上最合适:外部数据必须校验,而内部传递不需要每次都校验。
2. dataclass 真正解决的三件事
| 问题 | 解决方式 |
|---|---|
| 字段没有单一来源 | 类定义成为唯一来源 |
| 默认值散落各处 | field(default_factory=...) |
| 缺少派生逻辑 | 可加方法(如 total()) |
第二项(默认值散落)在字典时代的表现是"每个入口各写一套默认值",于是同一次请求在不同路径上得到不同的默认行为——而这类差异只在边界组合时暴露。
第一项最直接:IDE 补全、重构改名、查引用三件事都依赖"字段是一个属性"。用字典时这三件事全靠 grep。
第三项是判断"该不该升级成类"的关键信号:当同一段计算在多个地方重复出现时,它属于数据本身。
// 从字典升级到 dataclass:字段有了单一来源
from dataclasses import dataclass, field
@dataclass(frozen=True)
class OrderQuery:
user_id: int
status: str = "ALL"
limit: int = 20
tags: tuple[str, ...] = ()
def normalized_status(self) -> str | None:
return None if self.status == "ALL" else self.status
// 拼错字段名 → AttributeError(立刻暴露),而不是静默的 None
3. 可变默认值这个坑
| 写法 | 后果 |
|---|---|
tags: list = [] |
所有实例共享同一个列表 |
tags: list = field(default_factory=list) |
每实例独立 |
frozen=True |
防误改,但嵌套对象仍可变 |
还有一个与 frozen 相关的细节:它会让 __hash__ 自动生成,于是不可变对象可以直接当字典键或放进 set——这在缓存键、去重场景里很好用,而字典做不到。
@dataclass(frozen=True)
class CacheKey:
user_id: int
region: str
page: int
// frozen=True → 可哈希、可做 dict 键、可去重
seen = {CacheKey(1, "cn", 0), CacheKey(1, "cn", 0)} # 长度 1
第一项是 Python 最经典的陷阱,而 dataclass 会让它更容易发生——因为在普通类里人们会写 __init__,在 dataclass 里就忘了。
第三项要补一句:frozen=True 只禁止重新绑定属性,不禁止修改嵌套的可变对象。要真正不可变,嵌套值也要用不可变类型(tuple 而非 list)。
// 三种写法对照
@dataclass
class A:
tags: list = [] # 错:共享同一个 list
@dataclass
class B:
tags: list = field(default_factory=list) # 对
@dataclass(frozen=True)
class C:
tags: tuple = () # 对且不可变
4. 什么时候字典仍然是对的
| 场景 | 理由 |
|---|---|
| 结构由外部决定(JSON 透传) | 无法预先定义 |
| 键是动态的(配置项) | 无法枚举 |
| 一次性脚本 | 定义类型的成本高于收益 |
| 与第三方库交互 | 对方要求 dict |
第二项很常见:配置本来就是"任意键",强行定义类型会让新增配置都要改代码。
第一项同理:透传的数据不该被建模,否则上游加字段就要跟着改。
第四项(与第三方库交互)值得单独说:很多库接收 **kwargs 或 dict,此时强行传对象需要解包,反而增加一层转换。这类边界上,dict 是接口约定的一部分。
所以判断不是"字典 vs 类",而是"这份数据有没有稳定的结构"。有,就定义类型;没有,字典是对的。
5. 边界处的选择:谁负责校验
| 位置 | 用什么 |
|---|---|
| HTTP 入参 | Pydantic(校验 + 文档) |
| 数据库行 | 轻量 dataclass / NamedTuple |
| 内部函数参数 | dataclass |
| 跨服务消息 | 与序列化绑定(Pydantic / attrs) |
关键在于校验只做一次,在边界上。内部再校验一遍是重复劳动,而边界不校验则会把脏数据带进核心。
第三项用 dataclass 而不是 Pydantic 的理由是性能与依赖:内部传递不需要每次构造都跑校验器。
// 边界校验一次,内部用轻量类型
class CreateOrderReq(BaseModel): # 边界:校验 + 文档
user_id: int = Field(gt=0)
items: list[ItemIn] = Field(min_length=1)
@dataclass(frozen=True)
class OrderCommand: # 内部:无校验,只有结构
user_id: int
items: tuple[ItemIn, ...]
def handle(req: CreateOrderReq) -> None:
cmd = OrderCommand(req.user_id, tuple(req.items)) # 转换一次
service.create(cmd) # 之后一路只有 OrderCommand
迁移路径也值得写清楚,因为它决定了这件事能不能做成:先加类,保留字典入口,逐步把内部改完,最后删掉入口。一次性把所有 dict 换掉,通常会引出一批"字段名不一致"的连带问题。
// 渐进迁移:类与字典并存一段时间
@dataclass(frozen=True)
class OrderQuery: ...
@classmethod
def from_dict(cls, d: dict) -> "OrderQuery": # 兼容旧入口
return cls(user_id=d["user_id"], status=d.get("status", "ALL"), limit=d.get("limit", 20))
// 先用 from_dict 接旧调用方;内部全部改完后删除 from_dict
6. 排错:五种"类型定义用错了"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 拼错键静默为 None | 用 dict 传内部数据 | 定义 dataclass |
| 所有实例共享一个列表 | 可变默认值 | 用 default_factory |
| 加了 frozen 仍被改 | 嵌套可变对象 | 嵌套也用不可变 |
| 新增配置要改代码 | 给动态结构建模 | 用 dict |
| 转换代码到处都有 | 边界类型混入内部 | 在边界转换一次 |
第五项是"建模不彻底"的典型:边界类型一路传到领域内部,于是每层都在做同样的字段搬运。解法是在边界转换一次,之后只用内部类型。
7. 边界:什么情况不必定义类型
| 场景 | 为什么 | 做法 |
|---|---|---|
| 两行代码的临时结构 | 生命周期极短 | 用 dict |
| 纯透传 | 不参与逻辑 | 用 dict |
| 性能极敏感的内循环 | 构造开销 | 用 tuple |
| 已有成熟的领域模型 | 无需新增 | 复用 |
最后一条与"重构成本"有关的边界:从 dict 改成 dataclass 是一次性成本,改反方向(类改回 dict)几乎不会发生。所以犹豫时倾向于定义类型——它错得少,而且纠错容易。
判断口径一句话:这个结构会不会跨过三个以上的函数。会,就值得定义类型;只在一处用,字典更轻。
8. 自测
- 用字典传内部数据,最大的代价是什么?为什么拼错的键不报错?
- dataclass 真正解决的三件事里,哪一件是"该不该升级成类"的判断信号?
- 为什么 dataclass 让"可变默认值"这个坑更容易踩?
frozen=True保护了什么、没保护什么? - 哪两类场景字典仍然是对的?判据是"结构是否稳定"还是"数据量大小"?
- 为什么校验只应在边界做一次?内部为什么用轻量 dataclass 而不是 Pydantic?
- 判断"是否值得定义类型"的那个问题是什么?