2222 字
约 7 分钟
16
S10-A14 · dataclass 还是字典:什么时候值得定义类型

一个服务里参数一路用 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. 自测

  1. 用字典传内部数据,最大的代价是什么?为什么拼错的键不报错?
  2. dataclass 真正解决的三件事里,哪一件是"该不该升级成类"的判断信号?
  3. 为什么 dataclass 让"可变默认值"这个坑更容易踩?frozen=True 保护了什么、没保护什么?
  4. 哪两类场景字典仍然是对的?判据是"结构是否稳定"还是"数据量大小"?
  5. 为什么校验只应在边界做一次?内部为什么用轻量 dataclass 而不是 Pydantic?
  6. 判断"是否值得定义类型"的那个问题是什么?
S10-A14 · dataclass 还是字典:什么时候值得定义类型
https://www.clxhxhhr.top/posts/6466/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。