《Python 类型标注实战:从注解到全量检查》
这篇写给已经能写 Python、但类型标注停留在”函数参数偶尔写个 str”的读者。我自己的转折点发生在接手一个几万行的老 Python 项目之后:没有标注、没有检查,改一个函数签名全靠 grep 加运气,重构一次回归一次。从那以后,我的新项目从第一行起就开全量类型检查,旧项目按本文的渐进策略一点点收口。事实证明这条路付的利息远小于省下的本金。
边界先说死:本文只讲静态检查——编辑器和 CI 里发现类型问题,不涉及运行时校验库(Pydantic 只在数据边界和 FastAPI 两节顺带提);不比较两个检查器的内部实现;不给性能数据。示例基于 Python 3.13(写作时点 3.13.7,2025-08-14 发布),PEP 695 泛型语法需要 3.12+,| None 写法需要 3.10+,内置泛型标注(list[int])需要 3.9+。
检查器先选好:pyright 还是 mypy
两个我都用过一年以上,结论是:编辑器用 pyright,CI 里选定一个当唯一裁判。pyright 快,保存即出结果,strict 模式的分级细;mypy 老牌,历史项目里遇到的兼容性问题更少,插件生态厚。真正的坑是两者对同一份代码的判定不完全一致——默认严格程度不同,报错措辞不同,typeshed 版本不同时结论甚至会相反。所以别把时间花在”哪个更正确”上,选定一个进 CI,另一个只做编辑器提示,两处结论打架时以 CI 为准。
写作时点版本:pyright 1.1.403(2025-07 上旬发布,1.1.40x 一线,迭代快,钉大版本即可);mypy 1.17(1.17.0,2025-07-14 发布)。下文配置以 pyright 为主,mypy 给等价命令。
地基:把注解写对
先过一遍最常用的形态。容器类型直接用内置类加方括号(3.9+,不用再从 typing 导 List),可空类型用管道符(3.10+,不用再写 Optional):
def normalize_price(raw: float | None) -> float:
if raw is None:
return 0.0
return round(raw, 2)
SKUS: dict[str, list[int]] = {"a": [1001, 1002]}
def find_skus(warehouse: str) -> list[int] | None:
return SKUS.get(warehouse)
三条经验,都是我踩过或看别人踩过的。第一,注解要写到容器元素的层级才有意义,items: list 等于没写,要写到 list[Order],检查器才有抓手。第二,None 是类型系统里最有价值的信号:显式写 str | None 之后,检查器会强制你在使用前收窄,上面 normalize_price 里那个 if 分支就是被逼出来的——这正是静态检查比肉眼可靠的地方。第三,少用 Any,它会让检查器从那一行起整条链路闭嘴;确实接不住外部数据时,标注成 object 或具体协议类型,把收窄动作写出来。
验收方式:把上面代码里的 if raw is None 分支删掉,pyright 会立刻报 round 的参数不接受 None。你的检查器能报出这条,说明配置是活的;报不出来,检查器只是装了,没在工作。
数据边界怎么标:TypedDict 与 dataclass
标注最容易在数据进出程序的地方断链:接口返回的 JSON、数据库读出来的行、消息队列里的载荷。我的做法是给每类外部结构一个显式类型,由第一个函数完成”未收窄 → 已收窄”的转换,之后整条链路都是可信的。外部 JSON 用 TypedDict:
from typing import TypedDict
class RawOrder(TypedDict):
order_id: str
amount: float
coupon: str | None # 外部数据可能缺这个键
def to_order(raw: RawOrder) -> Order:
return Order(
id=raw["order_id"],
amount=raw["amount"],
coupon=raw.get("coupon"),
)
领域内对象用 dataclass(需要运行时校验的入口用 Pydantic 模型),别拿 dict 当结构体在层与层之间传——dict[str, object] 传过三层之后就是谁也说不清的黑盒,这是我见过的类型债里最难还的一种。三类工具各守一层:TypedDict 管输入边界,dataclass 管领域模型,Pydantic 管必须运行时校验的场景,混用反而乱。
PEP 695:3.12 之后的泛型写法
老写法声明一个泛型函数要两步:先 T = TypeVar("T"),再在函数签名里引用。3.12 起(PEP 695)一步到位,类型变量作用域只在本函数内,文件里不会再有一堆 T、U、K 相互冲突:
def first_or_none[T](items: list[T]) -> T | None:
return items[0] if items else None
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
类型别名也升级成了 type 语句,惰性求值,前向引用不再需要引号:
type Aliases = dict[str, list[Aliases]]
我的建议:3.12+ 的项目直接用新语法,别再引入 TypeVar;维护旧代码时看到老写法不必强改,两者能共存,为统一风格而大规模翻写只会徒增 diff 风险。
Protocol:给鸭子类型上静态保险
Python 的鸭子类型和静态检查天生有张力:我知道”这个参数只要有 read() 方法就行”,但以前标注不出来。Protocol(结构化子类型)解决的就是这个——不需要显式继承,长得像就算:
from typing import Protocol
class Closable(Protocol):
def close(self) -> None: ...
def cleanup(resource: Closable) -> None:
resource.close()
class FileHandle: # 没有继承 Closable
def close(self) -> None:
print("closed")
cleanup(FileHandle()) # 通过:结构匹配
验收方式:把 FileHandle 的 close 改名成 shutdown,pyright 立刻在 cleanup(FileHandle()) 这行报错。这个特性改变了我组织代码的方式:以前先定义接口基类再让实现类继承,现在只在使用方定义最小 Protocol,实现类零感知,耦合小得多。需要运行时判断时给 Protocol 加 @runtime_checkable,可以配合 isinstance 用——但只查方法名存在,不查签名,这一点别指望过头。
渐进采用:一套可执行的策略
老仓库别想一夜全标,我踩出来的可行路径是四步:
- 第一步,圈地:CI 只对新代码目录开检查,旧目录先进豁免清单。目的不是马上变好,是先不让它变坏。豁免清单要写成配置文件里的明确列表而不是”还没改到”的口头状态,规则是只减不增——要加回去必须在 review 里说明理由,这在 diff 里就一行,成本极低。
- 第二步,顺路收割:改到哪个模块,就把哪个模块从豁免名单移出来,顺手补标注。跟着业务改动走,成本摊薄到日常,团队不会觉得是额外负担。
- 第三步,核心升压:钱、数据边界、并发这几类模块单独升 strict,出错代价最高的地方先享受最严的检查。
- 第四步,全仓收口:全仓 standard 稳定运行一两个月后,再评估要不要全仓 strict。我多数项目停在”全仓 standard + 核心 strict”,够用了,strict 的全仓化对老代码是一次大手术,值不值要看项目的剩余寿命。
pyright 的分级配置
上面的策略落在 pyrightconfig.json 里。pyright 的严格度从 basic 到 strict 分级,strict 会开启”未知类型即报错”这组规则,对老代码杀伤力大,所以要分层放:
{
"include": ["src"],
"typeCheckingMode": "standard",
"strict": ["src/core", "src/payments"],
"reportUnnecessaryTypeIgnoreComment": true
}
mypy 的等价做法是在 pyproject.toml 里配 [tool.mypy],用 per-module 段对豁免目录设 ignore_errors = true,效果相同。验收方式:对 src/core 里任意一个未标注的旧函数跑 pyright,strict 范围内应报”缺少类型注解”,范围外不应报——报错范围和配置对不上,多半是 include 或 strict 的路径写错了,这在嵌套项目里特别常见。
接入 CI 的最小配置
本地装检查器一条命令:uvx pyright(或 pip install pyright 之后直接 pyright)。真正决定成败的是 CI 接入——检查器不进流水线就只是装饰。最小做法是流水线里加一步:
uvx pyright src/
1. mypy 等价:uvx mypy src/
pyright 发现问题退出码非零,流水线自然失败,不需要额外解析。验收方式:往分支里故意提交一个未收窄的 None 使用,CI 应当变红并列出行号;红不了就是配置没生效,最常见的根源是 CI 工作目录下找不到 pyrightconfig.json。本地可以再加 pre-commit 钩子拦一道,但 CI 是最终裁判——钩子能被 --no-verify 绕过,流水线不能。
常见坑清单
- 标注了但不跑检查。注解本身不校验任何东西,只有检查器跑起来才有价值;”我写了标注”和”我有类型检查”是两件事。
- Any 蔓延。一个函数返回 Any,下游全链路失效;检查器提示”类型未知”要当红灯处理,别当噪音。
- type: ignore 不写原因。开
reportUnnecessaryTypeIgnoreComment让过期 ignore 自己浮出来,否则半年后没人知道哪条 ignore 还活着、还该不该在。 - cast 当免检通行证。cast 是”我比检查器懂”的签字画押,每处都该有注释说明为什么安全;满篇 cast 的代码库,检查器形同虚设。
- 把注解当运行时校验。静态检查拦不住跨进程传来的脏数据,那是 Pydantic 的战场;两层混为一谈会出线上事故。
- 本地与 CI 的 Python 版本不一致。不同版本的标准库 stub 有差异,本地全绿 CI 变红先查这个。
与 FastAPI 的配合
类型标注在 FastAPI 里不是锦上添花,是运转的前提:路径参数、查询参数、请求体和响应模型的校验、接口文档生成,全部从注解推导。你把 page: int = 1 写进依赖函数,框架自动做参数解析和校验,类型不符直接返回 422,文档同步生成。写过一遍就回不去了——标注即文档,也是运行时行为。入门层的框架用法见《FastAPI 框架入门》。这里提醒一句:FastAPI 的校验发生在运行时,静态检查器拦不住”跨进程真的传来了字符串”这类问题,两层检查解决的是两类问题,别互相指望。
数据管道里的实战判断
在我自己的每日数据管道项目里,类型系统的最大收益就在上面说的数据边界:每个数据源的结构定义成 TypedDict,抓回来的原始数据在管道第一个函数里完成收窄,之后所有清洗、转换函数的输入类型都是可信的,重构时检查器直接告诉你哪些下游要跟着改。管道整体设计见《Python 数据管道实战》。异常边界的组织同样重要——自定义异常的类型层次、raise ... from 的语义,我在《Python 异常处理》里写过,和类型检查是互补的两件事:一个管值的形状,一个管失败的形式。
验收清单
- 新代码目录 CI 全绿,旧目录有明确的豁免清单且只减不增;
- 删掉一处 None 收窄分支,CI 能变红(证明检查器真的在跑);
- 3.12+ 项目里泛型用 PEP 695 语法,文件里没有游离的 TypeVar;
- 跨模块调用的函数签名 100% 有返回类型注解;
- 数据边界有 TypedDict/dataclass 收窄层,dict 不跨层传递;
- 能说清自己项目里 strict 开在哪些目录、为什么是这几处。
诚实收尾:类型系统拦不住逻辑错误,参数传反了类型一样能对上。它真正的价值是把”这个值可能是什么”从口头约定变成机器裁决,让我敢在周五下午改别人的代码——这件事值多少钱,只有被没标注的代码库烫过的人知道。
本文数据核对时间:2025-08-18:Python 3.13 稳定线(3.13.7,2025-08-14 发布;PEP 695 自 3.12 起可用,内置泛型标注自 3.9 起,管道可空类型自 3.10 起);pyright 1.1.403(2025-07 上旬发布,1.1.40x 一线);mypy 1.17.0(2025-07-14 发布)。均为官方发布口径。