类型注解在 Python 里有个容易被误解的定位:它是写给工具看的,运行时不会拦你。def f(x: int) 传一个字符串进去照样跑,直到某处 .bit_length() 报 AttributeError 才炸。理解这一点,才能分清"注解负责静态契约"和"Pydantic 负责运行时校验"这两条不该混淆的职责线。
1. 注解为什么不等于运行时约束
def add(a: int, b: int) -> int:
return a + b
print(add("1", "2")) # 典型输出:'12'
解释器会保存注解(add.__annotations__),但从不检查。注解的消费方是 mypy / pyright 这类静态检查器、IDE 补全、typing.get_type_hints() 反射,以及 Pydantic 这样的运行库。
| 层 | 谁在执行 | 何时生效 | 能拦住什么 |
|---|---|---|---|
| 注解本身 | 无人 | —— | 什么都拦不住 |
| 静态检查(mypy/pyright) | 开发机或 CI | 提交/构建时 | 类型不匹配、None 访问 |
| 运行时校验(Pydantic) | 解释器 | 每次调用 | 脏数据、字段缺失、格式错误 |
assert |
解释器 | 每次执行 | 只在你写的地方;-O 下被移除 |
结论:注解让代码可被分析,Pydantic 让数据在边界上被拒收,两者互补。
2. 基础注解速查
from typing import Optional, Callable, Literal
def find(uid: int) -> Optional[User]: ... # 旧写法
def find2(uid: int) -> User | None: ... # 3.10+ 推荐
def call(fn: Callable[[int, str], bool]) -> None: ...
def mode() -> Literal["dev", "prod"]: ...
| 写法 | 含义 | 备注 |
|---|---|---|
list[str]、dict[str, int] |
内置泛型 | 3.9+,不需要 List/Dict |
X | None |
可选 | 3.10+;Optional[X] 等价 |
Union[int, str] / int | str |
多选一 | 两种写法等价 |
Literal["a","b"] |
枚举字面量 | 静态检查器能查拼写 |
Callable[[int], str] |
可调用对象 | 返回类型在最后 |
Iterable[T] / Sequence[T] |
只读视角 | 参数宽、返回严 |
一条经验:参数类型尽量宽(Iterable、Mapping),返回类型尽量具体,宽进严出。
3. TypedDict、dataclass、BaseModel 三者对照
from typing import TypedDict
from dataclasses import dataclass
class UserDict(TypedDict):
uid: int
name: str
| 维度 | TypedDict |
dataclass |
Pydantic BaseModel |
|---|---|---|---|
| 运行时类型 | 普通 dict |
类实例 | 类实例 |
| 运行时校验 | 无 | 仅 __post_init__ 手写 |
有,字段级 |
类型转换("1"→1) |
无 | 无 | 有 |
| 序列化 | json.dumps 直接可用 |
dataclasses.asdict |
model_dump() |
| 嵌套校验 | 无 | 无 | 递归校验 |
| 典型用途 | 给检查器描述 dict 结构 | 内部领域对象、纯计算 | 外部数据入口/出口 |
选型规则:数据来自外部(HTTP 请求体、配置、第三方 API)用 BaseModel;数据在内部流通、字段自己保证用 dataclass;只给 dict 加标注、不想建类就用 TypedDict。
4. Pydantic v2 的 BaseModel 基础
from pydantic import BaseModel, Field, ConfigDict
from datetime import datetime
class Order(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
oid: int
amount: float = Field(gt=0, le=1_000_000)
currency: str = "CNY"
tags: list[str] = Field(default_factory=list)
created: datetime = Field(default_factory=datetime.utcnow)
o = Order.model_validate({"oid": 1, "amount": "12.5"})
print(o.amount) # 典型输出:12.5(float,已转换)
print(o.model_dump()) # 典型输出:{'oid': 1, 'amount': 12.5, ...}
| API | 方向 | 说明 |
|---|---|---|
model_validate(obj) |
入 | 校验 dict/对象;失败抛 ValidationError |
model_validate_json(s) |
入 | 从 JSON 字符串校验 |
model_dump() / model_dump_json() |
出 | 转 dict / 转 JSON 字符串 |
model_dump(exclude_unset=True) |
出 | 只输出显式赋值字段,PATCH 接口常用 |
model_json_schema() |
出 | 生成 JSON Schema,喂 OpenAPI |
可变默认值用 default_factory:Pydantic 会自己复制默认值,但 dataclass 里 tags: list = [] 是明确的 ValueError,统一习惯最省心。
ValidationError 的结构化输出是它最值钱的部分:
from pydantic import ValidationError
try:
Order.model_validate({"oid": "x", "amount": -1})
except ValidationError as e:
for err in e.errors():
print(err["loc"], err["type"]) # ('oid',) int_parsing / ('amount',) greater_than
5. model_config 常用项
| 配置 | 作用 | 何时开 |
|---|---|---|
extra="forbid" |
出现未声明字段就报错 | 对外 API 入口,防拼写错误 |
extra="ignore" |
静默丢弃未知字段 | 消费第三方 API,对方加字段不该炸 |
from_attributes=True |
允许从对象属性构造(原 orm_mode) |
从 ORM 行对象构造 |
str_strip_whitespace=True |
自动去首尾空白 | 表单输入 |
frozen=True |
实例不可变、可哈希 | 做 dict key 或缓存键 |
validate_assignment=True |
赋值时也校验 | 会拖慢写入 |
from_attributes 是三层架构里最省胶水的一行配置:没有它,从数据库行对象转 DTO 就得手写 model_validate(record.__dict__),而 __dict__ 常带多余字段。
6. 校验器:field_validator 与 model_validator
from pydantic import BaseModel, field_validator, model_validator
class Range(BaseModel):
start: int
end: int
@field_validator("start", "end")
@classmethod
def non_negative(cls, v: int) -> int:
if v < 0:
raise ValueError("不能为负")
return v
@model_validator(mode="after")
def check_order(self) -> "Range":
if self.end < self.start:
raise ValueError("end 必须 >= start")
return self
| 装饰器 | 时机 | 拿到什么 | 典型用途 |
|---|---|---|---|
field_validator(mode="after") |
类型转换之后 | 已转换的值 | 业务规则、范围检查 |
field_validator(mode="before") |
类型转换之前 | 原始输入 | 清洗(""→None)、兼容旧格式 |
model_validator(mode="after") |
全部字段校验完 | self |
跨字段一致性 |
model_validator(mode="before") |
构造前 | 原始 dict | 整体结构改写、版本迁移 |
两个必记细节:field_validator 必须加 @classmethod(漏了会在导入时报错);mode="after" 的 model_validator 必须返回 self,否则模型实例变成 None。
7. 类型检查器在 CI 里的位置
pip install mypy pyright
mypy --strict src/ # 新项目
mypy src/legacy/ --ignore-missing-imports # 老代码过渡
pyright src/
| 工具 | 实现 | 强项 | 注意 |
|---|---|---|---|
| mypy | Python | 生态老、插件多(Django/SQLAlchemy) | 默认不检查未注解函数体 |
| pyright | Node/TS | 推断更强、速度快 | 默认更严格,老项目噪音大 |
CI 里落地的顺序:先只对新增文件开 --strict;把 disallow_untyped_defs、check_untyped_defs 逐项打开,一次只开一个;配置写进 pyproject.toml 的 [tool.mypy] 而非命令行;# type: ignore[code] 必须带具体错误码。对一个五万行老仓库一次开到 --strict,结果通常是团队集体加 ignore,最后留个假绿灯。
8. 排错:六个高频注解错误
| 现象 | 根因 | 处理 |
|---|---|---|
Optional[X] 字段没给默认值就报必填 |
Optional 只表示"可为 None",不表示"可省略" |
写成 x: X | None = None |
from __future__ import annotations 后 Pydantic 解析失败 |
注解变字符串,运行时反射要解析 | 确保类型在模块命名空间可解析 |
| 循环引用模型报 schema 生成失败 | 两个模型互相 import | 用字符串前向引用 + model_rebuild() |
field_validator 没生效 |
漏了 @classmethod,或字段名写错 |
补装饰器;核对拼写 |
| 别名生效导致构造失败 | 默认只接受 alias,不接受字段名 | 开 populate_by_name=True |
| mypy 报第三方库无 stub | 库没带类型信息 | 装 types-xxx 或局部 ignore |
关于 from __future__ import annotations:它把注解变成字符串(PEP 563),静态检查器反而更好用,但依赖运行时反射的库会多一步解析。Pydantic v2 能处理,前提是类型名在模块作用域可解析——if TYPE_CHECKING: 里 import 的类型运行时不存在就会翻车。
9. 常见反模式
| 反模式 | 问题 | 替代 |
|---|---|---|
到处传 dict[str, Any] |
无契约,字段名靠记忆 | 定义模型或 TypedDict |
Any 满天飞 / 用注解当校验 |
检查器失效、传错类型深层才炸 | 具体类型 + 边界 Pydantic |
一个巨型 BaseModel 覆盖所有接口 |
处处必填,PATCH 无法表达 | 按场景分 In/Out/Patch |
用 assert 做输入校验 |
-O 下被整行剥离 |
if ... raise 或模型校验 |
assert user_id is not None 在 python -O 下会整行消失,校验随之蒸发;assert 只该表达"我确信这里不可能发生"的内部不变量。
10. 边界:注解、校验、契约各管一段
| 需求 | 该用什么 | 不该用什么 |
|---|---|---|
| 函数参数是 int | 注解 + mypy | 运行时 isinstance 断言 |
| HTTP 请求体字段校验 | Pydantic BaseModel |
手写 if "field" not in body |
| 第三方 API 响应 | Pydantic(extra="ignore") |
直接 resp.json()["data"]["items"] |
| 数据库行转 DTO | from_attributes=True |
手工逐字段赋值 |
| 内部纯计算对象 | dataclass |
Pydantic(白白付校验开销) |
| 跨服务契约 | model_json_schema() 生成 OpenAPI |
靠文档人工同步 |
第三方 API 必须用 Pydantic 兜住。直接链式取值的问题是:对方少一个字段、把 int 改成 float、把 null 换成 "",你的代码就在半夜抛 KeyError,堆栈深处看不出哪一步坏的。用模型接住后,错误会在边界上以结构化 ValidationError 暴露,loc 直接指到字段。
11. 自测
def f(x: int)传"1"会怎样?注解在运行时由谁消费?Optional[int]和int | None = None在"是否可省略"这一点上有什么差别?TypedDict、dataclass、BaseModel各自最适合描述哪一类数据?extra="forbid"与extra="ignore"分别该用在对内入口和消费第三方 API 的哪个位置?@field_validator为什么必须配@classmethod?mode="before"与mode="after"拿到的值有什么不同?- 第三方 API 响应直接链式取值,出问题时症状是什么?换成 Pydantic 后错误信息多了什么?