主题
09 类型注解与静态检查
一句话:类型注解是给工具看的元数据,运行时不会自动拦截错误;mypy/pyright 在运行前做静态检查,Pydantic v2 在可信边界上把注解变成运行时校验。
适用:CPython 3.11+;list[int] 需要 3.9+,X | None 需要 3.10+,typing.NotRequired 需要 3.11+。Pydantic 部分只讲 2.x。
为什么需要
动态类型让写代码快,但错误出现得晚:参数传错、返回 None、字段名拼错,往往到线上才炸。注解不改变运行时行为,却能让编辑器补全、让静态检查器在提交前报错、让函数签名变成可读文档。Pydantic 解决另一个问题:外部数据(HTTP 入参、配置、LLM 输出)不可信,注解本身不校验,需要运行时把 JSON 转成带类型的对象。
核心机制
注解是什么,不是什么
python
def add(a: int, b: int) -> int:
return a + b
print(add("x", "y")) # xy:运行时照样执行注解保存在 __annotations__,默认不参与执行。它的价值在三个消费方:编辑器/Pylance 的补全与跳转、mypy/pyright 的静态检查、Pydantic/dataclass 等运行时框架。
基础语法
python
from collections.abc import Callable, Iterable
from typing import Any, Literal, Optional
def find(user_id: int) -> Optional[dict[str, Any]]: # 可能返回 None
return None
def parse(v: int | float | str) -> float: # 3.10+
return float(v)
def set_mode(mode: Literal["train", "eval"]) -> None: ...
def apply(fn: Callable[[int, int], int], a: int, b: int) -> int: return fn(a, b)
def walk(items: Iterable[int]) -> Iterable[str]: return (str(x) for x in items)Optional[X] 等价于 X | None。Any 会关闭该值的检查,能用具体类型或 object 时不要用 Any。容器优先用内建泛型 list[int]/dict[str, int];需要只读接口时用 Sequence/Mapping。
TypedDict、Protocol 与泛型
python
from typing import NotRequired, Protocol, TypeVar, TypedDict
class Message(TypedDict):
role: str
content: str
name: NotRequired[str] # 3.11+;旧版本用 typing_extensions
class SupportsPredict(Protocol):
def predict(self, x: list[float]) -> str: ...
T = TypeVar("T")
def first(items: list[T]) -> T: return items[0]TypedDict 描述 dict 的键和值类型,适合 JSON 形状;Protocol 描述「有哪些方法」,实现结构化子类型,不要求继承;TypeVar 让函数在多个类型之间保持关系。需要运行时检查 Protocol 时用 @runtime_checkable,但它只检查方法是否存在,不检查签名。
静态检查器:mypy 与 pyright
安装 python -m pip install "mypy>=1.11" 后运行 mypy demo.py,add("1", 2) 会报 Argument 1 has incompatible type "str"; expected "int"。pyright 是另一套实现,VS Code 的 Pylance 内置它;两者对边缘规则可能有差异,团队选一个作为门禁即可。
toml
# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_unused_ignores = truepyrightconfig.json 用 {"typeCheckingMode": "strict", "pythonVersion": "3.12"}。strict 会打开 disallow_untyped_defs、warn_return_any 等;存量项目可以先只对新增模块开启。reveal_type(x) 查看推断结果;cast(T, x) 用于你比检查器更了解类型时,但每次使用都要能说明理由。注解求值时机也在变化:from __future__ import annotations 会延迟成字符串,3.14 起 PEP 649 改变默认求值方式,涉及 __annotations__ 的框架要按目标版本验证,以官方文档为准。
Pydantic v2:把注解变成运行时校验
以下 API 全部是 Pydantic 2.x。v1 的 parse_obj / .dict() 在 v2 中分别改为 model_validate / model_dump,两套写法不要混用。
python
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class Answer(BaseModel):
model_config = ConfigDict(extra="forbid") # 多余字段直接报错
answer: str
confidence: float = Field(ge=0, le=1)
a = Answer.model_validate_json('{"answer": "2", "confidence": 0.9}')
print(a.answer, a.confidence, a.model_dump())
try:
Answer.model_validate({"answer": "x", "confidence": 1.5})
except ValidationError as e:
print(e.error_count(), e.errors()[0]["loc"])- 入口:
model_validate(dict)、model_validate_json(str);出口:model_dump()、model_dump_json()。 - 默认是 lax 模式,会把
"2048"转成2048;需要严格时用ConfigDict(strict=True)。 model_json_schema()可生成 JSON Schema,用于让 LLM 按结构输出(11)。- 只在校验边界用:HTTP 入参、配置、LLM 输出、消息队列;内部热路径不要反复校验。
最小上手
保存为 typed.py 后运行 python typed.py,再运行 mypy typed.py,预期输出 Ada:36、1,mypy 输出 Success: no issues found:
python
from typing import TypedDict
from pydantic import BaseModel, Field, ValidationError
class User(TypedDict):
name: str
age: int
def greet(user: User) -> str: return f"{user['name']}:{user['age']}"
print(greet({"name": "Ada", "age": 36}))
class Answer(BaseModel):
answer: str
confidence: float = Field(ge=0, le=1)
try:
Answer.model_validate({"answer": "x", "confidence": 1.5})
except ValidationError as e:
print(e.error_count())生产动作
- 门禁:CI 跑
mypy src或pyright,失败即阻断;新增模块先strict,存量模块逐步加注解。 - 忽略要带码:
# type: ignore[arg-type]比裸忽略可审计;配合warn_unused_ignores = true清理过期忽略。 - 第三方库:优先用自带
py.typed的库;没有类型的库装types-*存根,或局部ignore_missing_imports,不要把整个项目降级成Any。 - Pydantic 边界:外部数据一律
model_validate_json;extra="forbid"防止字段悄悄混入;错误日志记录e.errors()的loc/msg。 - 排障三段:
- 现象:mypy 报找不到包,运行时却正常 → 命令:
python -m mypy、mypy --show-error-codes、检查mypy_path→ 判断:mypy 与运行解释器是否不同环境。 - 现象:Pylance 报错但命令行 mypy 不报 → 命令:检查 VS Code 选择的解释器和
pyrightconfig.json→ 判断:pyright 是否指向另一个 venv。 - 现象:
PydanticUserError或方法不存在 → 命令:python -c "import pydantic; print(pydantic.__version__)"→ 判断:是否把 v1/v2 两套 API 混用。
- 现象:mypy 报找不到包,运行时却正常 → 命令:
边界
- 注解不校验运行时:
add("x", "y")不会报错;安全性来自静态检查和边界校验,不来自注解本身。 - Pydantic 有运行时成本:大列表逐条校验会明显变慢;批量数据先做一次结构校验,内部用 dataclass/普通对象。
Any会传染:一个Any进入调用链后,后续检查会失效;宁可先用object加收窄。- 静态检查不是测试:类型正确不等于逻辑正确;关键路径仍要有单元测试。
- 环境与依赖锁定不在本篇:见 01;类型检查只解决「形状」问题。
讲清楚
类型注解是可被工具读取的元数据,运行时不会自动报错;它的第一价值是让编辑器和 mypy/pyright 在提交前发现「参数类型不对、返回值可能是 None、字段名拼错」。写法上优先内建泛型和 X | None,用 TypedDict 描述 JSON 形状,用 Protocol 描述行为,用 TypeVar 保持类型关系。静态检查要进门禁,忽略要带错误码,存量项目逐步收紧。Pydantic v2 负责运行时边界:model_validate_json 把外部 JSON 变成带类型的对象,extra="forbid" 防止脏字段,但不要把它塞进热路径。注解解决形状,测试解决逻辑。
适用 CPython 3.11+、mypy 1.11+、Pydantic 2.x | 初稿 2026-08,2026-10 重写