写了 def parse(user: dict[str, str]) -> User:,心里默认"传错会被拦下"。实际 CPython 完全不看这些注解,把它存进 __annotations__ 就结束了。类型注解是给工具看的元数据,不是运行时的守卫。这篇讲清分工:哪些交给 mypy/pyright,哪些必须运行时自己挡。
1. 注解在运行时不生效
Python 3.11 里函数注解默认定义时求值:执行到 def 那一行就把 int 对象算好放进 f.__annotations__。后果有两个:注解里的名字必须存在(否则 NameError),以及注解写错也只是元数据错,函数照跑。
| 写法 | 运行时实际发生什么 | 是否拦截错误调用 |
|---|---|---|
def f(x: int) -> str |
f.__annotations__ = {"x": int, "return": str} |
否 |
from __future__ import annotations |
注解变成字符串 "int" |
否 |
x: int = 3 模块级 |
只进 __annotations__ |
否 |
cast(str, v) |
返回 v,零检查 |
否 |
@dataclass + 注解 |
用于生成 __init__ 参数 |
间接:参数个数管,类型不管 |
from __future__ import annotations
from typing import get_type_hints
def parse(uid: int, tags: list[str]) -> str:
return f"{uid}:{tags}"
print(parse.__annotations__) # {'uid': 'int', 'tags': 'list[str]', ...}
print(get_type_hints(parse)) # {'uid': <class 'int'>, 'tags': list[str], ...}
print(parse("not-an-int", {"a": 1})) # 正常返回,没有任何警告
最后一行是这个话题的起点:不报错不等于类型正确。
2. 静态检查能查什么、查不到什么
| 检查器 | 强项 | 典型查不到的 |
|---|---|---|
mypy |
泛型推导、Protocol 匹配、渐进式严格度 |
无 stub 的包内部;getattr 动态属性 |
pyright |
快、推断激进、字面量收窄好 | 同样管不了运行时构造的对象 |
三个盲区要背下来:
Any传染:Any参与运算的结果也是Any,检查器往下全部放弃。一行json.loads(text)就能让后面几十行失去保护。- 动态属性:
getattr/setattr/obj.__dict__[...]一律按Any处理。 cast是谎言:cast(User, raw)检查器只会信你,运行期raw.get("name")照旧AttributeError。
3. 从注解到运行时校验的三条路
| 路线 | 代表做法 | 成本 | 适合 |
|---|---|---|---|
| 手写校验 | isinstance、显式 if、TypeAdapter |
最低,无依赖 | 少量入口、内部工具 |
| 数据模型 | pydantic v2 BaseModel |
中,引入依赖 | 外部输入、配置、API 边界 |
| 结构化容器 | dataclasses + __post_init__ |
低,标准库 | 内部数据载体 |
from dataclasses import dataclass
@dataclass
class Page:
offset: int
limit: int = 20
def __post_init__(self) -> None:
if not isinstance(self.offset, int):
raise TypeError(f"offset must be int, got {type(self.offset).__name__}")
if not 0 < self.limit <= 200:
raise ValueError("limit out of range")
try:
Page(offset="1") # 抛 TypeError,而不是安静通过
except TypeError as e:
print("rejected:", e)
dataclasses 只保证参数个数,不保证类型:没有 __post_init__ 时 Page(offset="1") 安静通过。要完整校验可改用 pydantic.dataclasses.dataclass。
4. typing 常用构造"在运行时是什么"
| 构造 | 运行时对象 | 能 isinstance 吗 |
说明 |
|---|---|---|---|
Optional[X] |
typing.Union[X, NoneType] |
否 | 表示"可为 None",不是默认值 |
Union[A, B] |
typing.Union[...] |
否 | 自己拆 get_args 逐项判 |
Literal["a","b"] |
typing.Literal[...] |
否 | get_args 取候选集比较 |
TypedDict |
dict 子类的元数据 |
否(只认 dict) |
有 __required_keys__ |
Protocol |
类对象 | 仅 @runtime_checkable 时 |
只查方法名,不查签名 |
TypeVar |
typing.TypeVar 实例 |
否 | 运行时无绑定信息 |
list[int] |
types.GenericAlias |
否,会 TypeError |
见下 |
from typing import Protocol, get_args, get_origin, runtime_checkable
try:
isinstance([], list[int])
except TypeError as e:
print("TypeError:", e) # isinstance() argument 2 cannot be a parameterized generic
print(get_origin(list[int]), get_args(list[int])) # list (<class 'int'>,)
@runtime_checkable
class Closer(Protocol):
def close(self) -> None: ...
class Sock:
def close(self) -> None: ...
print(isinstance(Sock(), Closer), isinstance(3, Closer)) # True False
Union 判定要手写分支,别指望 isinstance(v, Optional[int])。@runtime_checkable 只到"有没有这个名字",签名不符也判 True。
5. pydantic v2 的校验与转换边界
| 能力 | API | 边界 |
|---|---|---|
| 模型校验 | Model.model_validate(data) |
默认宽松转换("20" → 20) |
| 严格模式 | ConfigDict(strict=True) |
只关有损转换,无损转换仍保留(按 v2 文档核对) |
| 非模型类型 | TypeAdapter(list[int]).validate_python(v) |
校验任意注解,含 TypedDict |
| 自定义规则 | @field_validator |
抛 ValueError 即失败 |
| 跳过校验 | model_construct(...) |
只在确知数据干净时用 |
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
class Order(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
oid: str
qty: int = Field(gt=0, le=999)
@field_validator("oid")
@classmethod
def oid_is_digits(cls, v: str) -> str:
if not v.isdigit():
raise ValueError("oid must be digits")
return v
try:
Order.model_validate({"oid": "007", "qty": 0}) # qty 越界
except ValidationError as e:
print(e.errors()[0]["type"]) # greater_than
要点:strict=True 挡有损转换;extra="forbid" 挡多余字段(默认 ignore 会悄悄丢数据);TypeAdapter 让非模型注解也能校验,是把校验左移的关键。
6. get_type_hints():把字符串注解还原成对象
from __future__ import annotations
from typing import Annotated, get_type_hints
class Ctx:
limit: Annotated[int, "max=200"] = 100
print(Ctx.__annotations__) # {'limit': "Annotated[int, 'max=200']"}
print(get_type_hints(Ctx, include_extras=True)) # {'limit': Annotated[int, 'max=200']}
| 场景 | 直接读 __annotations__ |
用 get_type_hints() |
|---|---|---|
| 默认(无 future import) | 已是对象 | 等价,可省略 |
from __future__ import annotations |
字符串 | 必须,顺带解析前向引用 |
注解含前向引用 "User" |
是字符串 | 按 globalns/localns 解析 |
Annotated[...] 取元数据 |
只拿到外层 | 要 include_extras=True |
名字只在 TYPE_CHECKING 里导入 |
运行时不存在 | NameError,需 localns= 兜底 |
最后一行最容易炸:TYPE_CHECKING 里的导入运行时不存在,get_type_hints 解析字符串注解即失败。pydantic v2 同理,名字导不到就报 PydanticUndefinedAnnotation。
7. 泛型与 Protocol 在接口设计里的位置
| 用哪个 | 表达什么 | 好处 |
|---|---|---|
Protocol |
"有这几个方法就行" | 不用继承,测试可传假实现 |
| 抽象基类 | 明确继承 + 可 isinstance |
有运行时约束,但耦合继承树 |
Generic[T] |
容器与元素类型关联 | 类型从 put 流到 get |
Sequence |
满足协议的内置类型 | 比 list 更宽,传 tuple 也行 |
Protocol 让依赖倒置无需继承即可成立,代价是运行时看不见约束;要兜底就加 @runtime_checkable,并接受它只查方法名。
8. 把类型检查接进 CI
# pyproject.toml
[tool.mypy]
python_version = "3.11"
warn_unused_ignores = true
no_implicit_optional = true
check_untyped_defs = true
[[tool.mypy.overrides]]
module = "nostubs_thirdparty.*"
ignore_missing_imports = true
// pyrightconfig.json
{"include": ["src"], "pythonVersion": "3.11", "typeCheckingMode": "basic",
"reportMissingTypeStubs": "warning"}
| 阶段 | 配置 | 目的 |
|---|---|---|
| 起步 | check_untyped_defs = true |
只查已写注解的函数 |
| 收紧 | 按模块加 disallow_untyped_defs |
新模块必须全注解 |
| 接近完成 | mypy --strict |
打开 Any 相关全部告警 |
| 稳定后 | CI 阻断 + warn_unused_ignores |
防止 ignore 腐烂 |
--strict 不是能一上来就开的开关:它一次引入大量 Any 与缺失泛型参数的报错。做法是按目录逐批打开,并用 warn_unused_ignores 清理失效的 ignore。
9. 排错:六种典型现场
| 现象 | 根因 | 处理 |
|---|---|---|
isinstance(x, list[int]) 报 TypeError |
下标泛型不能 isinstance |
判 list 再逐元素判,或用 TypeAdapter |
以为 Optional[int] 自带默认值 |
注解只描述允许 None |
显式 = None,否则参数必填 |
| future import 后拿不到真实类型 | 注解是字符串 | get_type_hints(..., include_extras=True) |
pydantic 报 PydanticUndefinedAnnotation |
名字藏在 TYPE_CHECKING |
把导入移到运行时 |
| mypy 全绿但线上仍崩 | Any 传染、cast、type: ignore |
边界 model_validate + warn_unused_ignores |
| 内部函数仍收到脏数据 | 只在最外层校验 | 模型做唯一入口,禁止裸 dict 穿透 |
"只在最外层校验"是最常见的错觉:入口挡住了 HTTP 数据,内部却有个函数自己拼 dict 塞进去,注解依旧不拦。要么边界校验一次让干净类型贯穿到底,要么每个重建点重做。
10. 边界:注解本就不该承担什么
| 需求 | 该用什么 | 原因 |
|---|---|---|
| 防外部坏输入 | 运行时校验(pydantic / 手写) | 注解不执行 |
| 数据清洗与归一 | 校验层显式转换 | 转换是业务决策 |
| 热点里逐元素判类型 | 向量化 / C 扩展 | isinstance 成本高 |
| 运行时多态分发 | functools.singledispatch |
注解不参与分发 |
| 契约文档 | 注解 + 生成的 schema | 注解正好适合生成文档 |
一句话:注解让人和工具看懂意图,校验在数据进门时把假的挡住。 缺一个都会在半夜出事。
11. 自测
- 为什么
def f(x: int)传入字符串不会立即报错? Any为什么会"传染"?它对下游检查意味着什么?isinstance(x, list[int])抛什么异常,正确的替代写法是什么?Optional[int]与"参数有默认值"之间是什么关系?from __future__ import annotations之后,pydantic 为什么可能报PydanticUndefinedAnnotation?mypy --strict为什么不建议在存量项目上一次性打开?