parse_config(path, timeout) 被传成了 parse_config(timeout, path),程序不报错,跑了两小时后在写日志时炸出 TypeError: unsupported operand type(s),栈里全是框架代码。动态语言的错误不在犯错处发生,而在类型不再兼容的那一行发生。类型注解把这个爆炸点提前到你能看懂的位置——前提是先接受一件事:注解在运行时什么都不校验。
1. 注解到底是什么
def parse_port(raw: str) -> int:
return int(raw)
print(parse_port.__annotations__)
# {'raw': <class 'str'>, 'return': <class 'int'>}
parse_port(8080) 照样能跑。CPython 只把注解存进 __annotations__ 然后继续执行。注解是给读代码的人和静态检查工具看的元数据,不是运行时守卫。
| 你以为注解会做 | 实际发生 |
|---|---|
传错类型时抛 TypeError |
不抛,照常执行到出错为止 |
| 返回值自动转换 | 不转,返回什么就是什么 |
| 在 IDE 里给提示 | 有,这部分是真的 |
让 mypy 报错 |
会,但只在跑 mypy 时 |
所以引入注解必须配套引入一个检查器,否则你只是写了另一种注释。
2. 基础写法与版本差异
def total(prices: list[float], weights: dict[str, int]) -> float: ... # 3.9+(PEP 585)
def find(key: str | None) -> str | None: ... # 3.10+(PEP 604)
from typing import Optional # 3.9 的等价写法
def find(key: Optional[str]) -> Optional[str]: ...
| 写法 | 最低版本 | 备注 |
|---|---|---|
list[int]、dict[str, int] |
3.9 | 3.8 运行时 TypeError: 'type' object is not subscriptable |
str | None |
3.10 | 3.9 运行时 unsupported operand type(s) |
typing.List / typing.Dict |
3.5+ | 老项目兼容写法,新代码不必 |
from __future__ import annotations |
3.7+ | 注解不求值,全部变字符串 |
最后一行是跨版本兼容的关键:加上它,def f(x: list[int]) 在 3.8 上定义时也不会报错,因为注解压根不求值。代价是运行时拿不到真实类型;需要运行时类型时用 typing.get_type_hints() 还原。
3. Optional、None 与可变默认值
Optional[X] 不是"可选参数",它的准确含义是 X | None:值允许是 None。参数有没有默认值是另一件事。
def fetch(url: str, timeout: Optional[float] = None) -> bytes: ...
def fetch(url: str, timeout: float = None) -> bytes: # mypy 报错
第二种在旧版 mypy 里被默许(隐式 Optional),新版默认开启 --no-implicit-optional 会直接报错。只有 Optional[float] = None 是安全的写法。
可变默认参数是另一类问题,注解帮不上,但常与之同时出现:
def append_tag(tag: str, tags: list[str] | None = None) -> list[str]:
tags = [] if tags is None else tags # 默认写 None,体内构造
tags.append(tag)
return tags
注解在此的价值是逼你把"可能是 None"写出来,从而被迫处理它,而不是让 None.append 在运行时炸。
4. Union 与 Literal
from typing import Literal, Union
Mode = Literal["r", "w", "a"] # 只接受这三个字面值
def open_file(path: str, mode: Mode = "r") -> object: ...
open_file("a.txt", "x") # mypy: incompatible type "Literal['x']"
def parse(value: Union[str, bytes]) -> dict: ...
| 场景 | 选择 | 理由 |
|---|---|---|
| 枚举式字符串参数 | Literal["a", "b"] |
拼错立刻报错,胜过文档 |
| 一个参数接受两种类型 | Union[A, B] 或 A | B |
函数内要做类型收窄 |
| 区分业务状态 | Enum 或 Literal |
Enum 更适合跨模块传 |
int 与 float 混用 |
直接标 float |
PEP 484 允许 int 赋给 float |
Literal 是低成本高回报的一类注解:把"只能取某几个值"从注释变成可检查的约束。
5. TypedDict、dataclass、NamedTuple
from dataclasses import dataclass
from typing import NamedTuple, TypedDict
class UserDict(TypedDict):
id: int
name: str
@dataclass
class User:
id: int
name: str = ""
class Point(NamedTuple):
x: float
y: float
| 结构 | 运行时行为 | 适用 |
|---|---|---|
TypedDict |
就是个 dict,无校验 |
解析 JSON、外部 API 的中间结构 |
dataclass |
真类,有 __init__/__eq__ |
业务对象、需要默认值和方法 |
NamedTuple |
不可变元组,可解包 | 小值对象、要当元组用的返回值 |
选错不会立刻出错,只会长期别扭:用 dataclass 接 JSON 要写转换层,用 TypedDict 当业务对象则没有方法与不可变保证。
6. 函数签名与 Callable
from typing import Callable
def register(name: str, on_done: Callable[[int, str], None]) -> None: ...
def query(sql: str, *, limit: int = 100) -> list[tuple]: ...
Callable[[int, str], None] 读法是"接受 int 与 str、返回 None 的可调用对象"。参数列表要按位置写全,Callable[..., None] 是放弃检查的兜底写法。
| 漏标处 | 后果 |
|---|---|
| 返回值 | 检查器默认 Any,错误往下游传 |
| 关键字参数 | 调用方写错参数名也不报 |
*args / **kwargs |
内部取值全是 Any,等于没标 |
7. Protocol:给鸭子类型写注解
from typing import Protocol
class Reader(Protocol):
def read(self, n: int = -1) -> bytes: ...
def consume(src: Reader) -> int:
return len(src.read())
class FakeFile: # 不继承 Reader,也不需要知道它
def read(self, n: int = -1) -> bytes:
return b"abc"
consume(FakeFile()) # mypy 通过:结构匹配
与抽象基类的区别在于结构化匹配:实现方无需知道接口存在,因此不会在你的包和实现方的包之间制造 import 依赖。这也是它适合做测试替身的原因。
8. 泛型的入门:先知道它解决什么
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T: ... # first([1]) -> int;first(["a"]) -> str
TypeVar 的作用是保留入参与出参之间的类型关系。不写泛型,first 只能返回 Any 或某个具体类型,调用方就丢了信息。
但顺序建议是:先把公共接口标准,泛型只在"同一类型从入参流到出参"处引入。过早造出 T、KT、VT 三四个类型变量,可读性掉得比收益快。
9. 检查器:三档严格度
| 档位 | 命令/配置 | 拦得住什么 |
|---|---|---|
| 宽松 | mypy src |
只查已注解部分的矛盾 |
| 中等 | --disallow-untyped-defs --warn-return-any |
未注解函数、返回 Any |
| 严格 | --strict(含 --disallow-any-generics 等) |
裸 Any、裸泛型、隐式 Optional |
# pyproject.toml
[tool.mypy]
python_version = "3.11"
disallow_untyped_defs = true
warn_return_any = true
no_implicit_optional = true
pyright 的对应物是 pyrightconfig.json 里的 "typeCheckingMode": "basic|standard|strict"。两者规则不完全一致,选定一个当门禁、另一个当参考即可——都当门禁会陷入规则对不齐的争论。严格度也不要一步到位:打开 --strict 的第一天通常满屏报错,正确路径是先中等档跑通存量,再对新目录开严格档。
10. 运行时归 pydantic,静态归 mypy
from pydantic import BaseModel, Field
class CreateUser(BaseModel):
name: str = Field(min_length=1)
age: int = Field(ge=0)
CreateUser(name="a", age="x") # 运行时抛 ValidationError
| 关注点 | 静态检查 | 运行时校验 |
|---|---|---|
| 何时生效 | 开发时、CI | 每次调用 |
| 拦得住外部输入吗 | 拦不住,外部数据是 dict/Any |
拦得住 |
| 性能开销 | 无 | 有,每次构造都校验 |
| 保证什么 | 进程内调用一致 | 数据边界合规 |
分工一句话:跨进程边界的数据用 pydantic 校验,进程内的函数调用用注解 + mypy 约束。只做前者,内部调用照样传错;只做后者,外部输入永远是不可信类型。
11. 最小可跑示例
# calc.py
def average(values: list[int]) -> float:
return sum(values) / len(values)
print(average(["1", "2"]))
$ mypy calc.py
calc.py:4: error: List item 0 has incompatible type "str"; expected "int"
修正的关键不是改注解了事,而是判断转换该发生在哪:average 不该接收字符串再自己转,转换属于读入数据的边界(int(raw) 或 pydantic 模型)。改完之后用严格档复核并落进 CI:
$ mypy --strict src/
12. 常见坑
| 坑 | 现象 | 处理 |
|---|---|---|
老版本用 list[str] |
'type' object is not subscriptable |
升 3.9+,或加 from __future__ import annotations |
| 注解写成字符串 | 检查器认,get_type_hints 才还原 |
正常行为,需要运行时类型时显式调用 |
Any 泛滥 |
全绿但无约束 | 禁裸 Any,换具体类型或 object |
| 循环引用 | 两模块互相注解 | 字符串注解 + TYPE_CHECKING 块 |
| 注解与实现不一致 | 检查过、运行崩 | 注解是承诺,改实现先改注解 |
13. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 一个文件报几百条 | 一次开了 --strict |
退回默认档,按目录分批收紧 |
Module has no attribute |
第三方库无存根 | 装 types-* 包或写最小 .pyi |
| CI 与本地结果不同 | Python/mypy 版本不一致 | CI 固定版本,与 python_version 对齐 |
| pydantic 模型静态报错但能跑 | 检查器不知道模型会转换 | 用模型类型作入参注解,别用 dict |
14. 边界
| 场景 | 注解帮不上 | 该用什么 |
|---|---|---|
| 外部 JSON / 表单 | 静态检查看不到运行时数据 | pydantic 或手写校验 |
| 数值范围与业务规则 | 类型不表达 age >= 0 |
Field 约束或校验函数 |
动态属性、setattr 拼装 |
检查器无法追踪 | 测试覆盖 |
| 极小脚本 | 收益低于维护成本 | 不标也行 |
落地顺序:公共 API 与跨模块调用 → 数据模型 → 内部辅助函数 → 测试代码。前两层覆盖了绝大多数跨边界错误,后两层是重构时的护栏。
自测
parse_port(8080)为什么不因注解是str而报错?Optional[float] = None里Optional修饰的是"参数可选"还是"值可为 None"?- 加了
from __future__ import annotations后,为什么 3.8 也能写list[int]? Protocol相比抽象基类,省掉了哪一类依赖?- 什么情况下必须用 pydantic,而不能只靠 mypy?
- 打开
--strict第一天满屏报错,正确的推进顺序是什么?