
这个系列写到第十篇说句实在话一开始我只是想把typing模块里那些看着眼熟的写法挨个过一遍。写到第五六篇的时候我发现身边不少人的状态其实是这样的注解写得挺漂亮mypy一跑满屏红改了两轮嫌烦干脆把类型检查从 CI 里摘掉注释一删代码回到裸奔状态。问题不在注解本身而在于大部分人只学到了怎么写没学到注解怎么在运行时干活以及怎么让它在工程里真正卡住问题。这一篇我不再讲新语法糖重点聊三件事运行时怎么可靠地拿到注解、怎么用注解写一个能跑的参数校验器、以及怎么把类型检查一步步推进到 CI 卡口上让 Python 类型注解从注释变成基建。1. 第十篇的定位从会写到让注解干活1.1 前九篇留下的三个缺口回顾一下前面讲过的内容基础的Optional、Union、List、Dict、Callable、TypeVar、Generic、Protocol、TypedDict、Literal、overload基本都覆盖了。但这些都是静态视角的东西——你写上去编辑器给你提示mypy给你报错代码跑起来之后这些注解就变成一堆没人读的字符串。第一个缺口是运行时自省。很多人以为__annotations__就是注解的全部随手读一下发现拿到的是字符串int而不是int类型对象Optional[int]读出来是个字符串没法用前向引用直接一片乱码。第二个缺口是把注解变成实际约束。既然已经声明了age: int为什么还要在函数体里手写if not isinstance(age, int): raise TypeError这两处信息是重复的。第三个缺口是工程化落地。注解写了没人检查等于没写检查了但全量开着报错两万条等于给自己找罪受。这三个缺口其实是一条线先能可靠地读出注解再能用注解做校验最后让检查工具自动跑起来。这一篇就按这个顺序往下走。1.2 这一篇真正想解决的问题我不想写成 API 文档的搬运。这篇的目标很具体读完你能自己写一个基于注解的参数校验装饰器能解释清楚get_type_hints和__annotations__的区别到底在哪、什么时候会翻车能在存量项目里用一套配置把mypy从报错两万条收敛到新增代码零报错还能顺手用上TypeGuard、ParamSpec、Self、Annotated这几个把注解价值拉满的工具。适合谁看写过几个月的 Python用过dataclass或者pydantic但没自己拆过注解解析的或者团队里负责推类型检查、被同事吐槽整天加类型加得我写不动业务的那位。新手也能看前三节我会把基础概念交代清楚不会默认你知道get_origin是干什么的。2. 运行时拿到注解annotations和 get_type_hints 的差别2.1 直接读annotations会踩的三个坑先看一段最朴素的代码from typing import Optional def create_user(name: str, age: Optional[int] None) - dict: return {name: name, age: age} print(create_user.__annotations__)输出大概是{name: class str, age: typing.Optional[int], return: class dict}。看起来还行但只要你做下面任何一件事结果立刻变味。第一只要你在文件顶部加了from __future__ import annotations所有注解全部退化成字符串输出会变成{name: str, age: Optional[int], return: dict}。这个 import 现在越来越常见因为它能解决前向引用问题、还能减少启动时的求值开销。第二前向引用。你在函数上标注一个还没定义的类Python 求值时拿不到这个对象只能把它存成字符串。第三typing.List[int]、Optional[int]这类泛型别名在 3.6 到 3.8 之间处理方式各不相同历史包袱很重。所以结论很直接__annotations__是原始数据不是给你直接消费的接口。它是能读到不是能读懂。2.2 get_type_hints 到底替你做了什么typing.get_type_hints就是官方给的正规入口。它的工作可以拆成三步取出__annotations__里的原始值对字符串形式的值在给定命名空间里做eval最后对结果做一次规范化处理。from typing import get_type_hints hints get_type_hints(create_user) print(hints) # {name: class str, age: typing.Optional[int], return: class dict}即使你加了from __future__ import annotations这个结果依然是对的因为get_type_hints默认会用函数所在模块的__globals__去解析字符串。这就是它和__annotations__最本质的区别前者保证你拿到的是真正可用的类型对象后者只保证你拿到的是当初写下的东西。注意get_type_hints默认会把Annotated[X, ...]里的元数据剥掉只留X。如果你在用Annotated挂业务信息必须显式传include_extrasTrue。这个坑我踩过一次排查了半小时才想起来自己忘了加参数。2.3 前向引用、作用域和嵌套类怎么写才不出错get_type_hints的关键字参数只有三个globalns、localns、include_extras。绝大多数翻车都出在localns上。def outer(): class Inner: pass def foo(x: Inner) - None: pass return get_type_hints(foo, localns{Inner: Inner})如果你不传localnsget_type_hints(foo)会抛NameError: name Inner is not defined因为Inner是个局部类模块全局里根本没有它。这也是为什么在类方法内部互相引用、或者注解里用Self时要特别注意解析时机。一个很实用的规避手段是延迟解析。装饰器里不要在装饰的那一刻就调用get_type_hints而是把结果缓存起来等第一次真正调用函数时再算import functools, inspect from typing import get_type_hints def lazy_hints(fn): cache {} def resolve(): if v not in cache: cache[v] get_type_hints(fn) return cache[v] return resolve这样做有两个好处一是模块导入阶段不用做字符串求值启动更快二是等到函数真正被调用的时刻模块里的类基本都已经定义完成了前向引用自然就解析得动。这个模式在各类校验框架里非常常见值得记住。3. 手写一个基于注解的参数校验器3.1 设计目标与边界先说清楚我要做的和不要做的。要做的是读取被装饰函数的签名和类型注解在每次调用前把实参逐个对照注解检查一遍不符合就抛出一个带路径信息的异常同时支持返回值校验。不做的是不做类型转换字符串18转18不属于校验范畴那是pydantic的活、不做嵌套数据类递归那会让代码膨胀到两三百行偏离主题、不走eval执行任意代码安全第一。为什么不直接上pydantic因为它真的很重一个几十行的内部脚本引入pydantic会顺带拖进一整套依赖。而且从工程角度讲你需要知道这类框架底层是怎么跑的。万一线上出一个奇怪的校验失败你能顺着栈自己看懂。另外这个手写版本只依赖标准库可以直接塞进任何一个小工具里不需要改requirements.txt。支持的注解范围我列一下Any、None、普通类、Union和X | Y、Literal、Annotated、list[X]、set[X]、frozenset[X]、tuple[X, ...]、tuple[X, Y]、dict[K, V]。这些覆盖了日常业务代码九成以上的场景。3.2 核心解析逻辑逐行拆解解析一个注解本质是一个递归的分发过程。先用get_origin判断这个注解有没有外层结构如果是裸类或者Any就直接用isinstance如果有结构就把参数拆出来递归处理。get_origin是最容易搞混的函数。get_origin(list[int])返回listget_origin(Union[int, str])返回typing.Unionget_origin(dict[str, int])返回dict。没有外层结构时返回None。而get_args负责把里面的参数拆出来get_args(dict[str, int])是(str, int)。Annotated这里要单独说一下因为它有个反直觉的行为。get_origin(Annotated[int, meta])返回的是int而不是Annotated。所以你不能靠get_origin判断一个类型是不是Annotated。正确的判断方式是检查它有没有__metadata__属性if hasattr(tp, __metadata__): tp get_args(tp)[0]这个细节在很多教程里被漏掉导致解析Annotated时行为诡异越查越乱。再看bool和int的关系。Python 里isinstance(True, int)是True因为bool继承自int。所以如果你声明count: int然后传了True进去isinstance检查会放行。这在业务上通常是不可接受的一个存数量的字段被塞了布尔值后续算出来的结果全是错的排查起来还特别隐蔽。我自己的处理方式是显式拦截注解是int而值是bool时直接报错。Optional[int]的实际类型是Union[int, None]所以只要把Union分支处理好了Optional就自动支持。注意None在注解里被规范化成NoneType也就是type(None)校验时要用这个来比对。3.3 完整可用代码下面这份代码可以直接复制到文件里跑只用标准库。from __future__ import annotations import functools import inspect import types import typing from typing import Any, get_args, get_origin, get_type_hints NoneType type(None) class ValidationError(TypeError): pass def _check(value: Any, tp: Any, path: str) - Any: if tp is Any: return value # None / NoneType if tp is None or tp is NoneType: if value is not None: raise ValidationError(f{path}: 期望 None实际是 {value!r}) return value # Annotated剥掉元数据继续校验 if hasattr(tp, __metadata__): return _check(value, get_args(tp)[0], path) origin get_origin(tp) args get_args(tp) # Union / Optional / X | Y if origin is typing.Union or origin is types.UnionType: reasons [] for sub in args: try: return _check(value, sub, path) except ValidationError as exc: reasons.append(str(exc)) raise ValidationError( f{path}: 不匹配 Union 中任何分支 ({; .join(reasons)}) ) # Literal if origin is typing.Literal: if value not in args: raise ValidationError(f{path}: 期望字面量 {args}实际是 {value!r}) return value # list / set / frozenset if origin in (list, set, frozenset): if not isinstance(value, origin): raise ValidationError( f{path}: 期望 {origin.__name__}实际是 {type(value).__name__} ) if args: for i, item in enumerate(value): _check(item, args[0], f{path}[{i}]) return value # tuple区分定长和变长 if origin is tuple: if not isinstance(value, tuple): raise ValidationError(f{path}: 期望 tuple实际是 {type(value).__name__}) if args: if len(args) 2 and args[1] is Ellipsis: for i, item in enumerate(value): _check(item, args[0], f{path}[{i}]) else: if len(value) ! len(args): raise ValidationError( f{path}: 期望长度 {len(args)} 的 tuple实际长度 {len(value)} ) for i, (item, sub) in enumerate(zip(value, args)): _check(item, sub, f{path}[{i}]) return value # dict if origin is dict: if not isinstance(value, dict): raise ValidationError(f{path}: 期望 dict实际是 {type(value).__name__}) if args: kt, vt args for k, v in value.items(): _check(k, kt, f{path}.keys) _check(v, vt, f{path}[{k!r}]) return value # 裸类 if isinstance(tp, type): if tp is int and isinstance(value, bool): raise ValidationError(f{path}: 期望 int得到了 bool这是常见误用) if not isinstance(value, tp): raise ValidationError( f{path}: 期望 {tp.__name__}实际是 {type(value).__name__} ) return value raise ValidationError(f{path}: 暂不支持的注解 {tp!r}) def validate(fn): sig inspect.signature(fn) cache: dict {} def hints(): if v not in cache: cache[v] get_type_hints(fn, include_extrasTrue) return cache[v] functools.wraps(fn) def wrapper(*args, **kwargs): bound sig.bind(*args, **kwargs) bound.apply_defaults() h hints() for name, value in bound.arguments.items(): if name in h: _check(value, h[name], name) result fn(*args, **kwargs) if return in h: _check(result, h[return], return) return result return wrapper有几个设计细节值得单独说。用sig.bind加apply_defaults是为了把位置参数和关键字参数统一成一个arguments映射省得自己处理*args和**kwargs的排列组合这块手写几乎必错。异常继承自TypeError而不是Exception这样调用方写except TypeError的老代码不会突然失效兼容性更好。3.4 实测一遍再看性能写几个用例跑一下validate def create_user(name: str, age: int, tags: list[str], meta: dict[str, int]) - str: return name create_user(alice, 30, [vip], {level: 3}) # 正常通过 create_user(alice, True, [vip], {level: 3}) # ValidationError: age 期望 int得到了 bool create_user(alice, 30, [vip, 1], {level: 3})# ValidationError: tags[1] 期望 str create_user(alice, 30, [vip], {level: 3}) # ValidationError: meta[level] 期望 int第三个用例报的是tags[1]第四个报的是meta[level]路径信息一路带着走定位问题非常快。这也是我坚持在递归里传path的原因只告诉你参数不对的报错等于没报错线上日志里看不到具体是哪个下标、哪个 key你还得自己加日志重跑一遍。性能方面上面这种规模的嵌套结构单次校验大约在几十微秒量级比一次数据库查询便宜几个数量级。但如果你要在一个每秒调用几万次的热点函数上挂校验就得掂量一下。我的建议是分场景外部输入边界HTTP 入口、命令行参数、配置文件读取、反序列化必须校验内部已经校验过的数据往下传时不要在每一层都挂装饰器那会变成给每个函数加一道无谓的开销。4. 几个把注解价值拉满的进阶工具4.1 TypeGuard让类型收窄摆脱 cast写类型检查的人基本都写过这种代码def first_str(items: list[object]) - str | None: for x in items: if isinstance(x, str): return x return None单个值收窄没问题但一旦涉及容器检查工具就懵了def is_str_list(val: list[object]) - bool: return all(isinstance(x, str) for x in val) def join_all(items: list[object]) - str: if is_str_list(items): return ,.join(items) # 检查工具会报错list[object] 不是 Iterable[str] return mypy看不出is_str_list返回True就意味着items是list[str]。以前只能加cast强行骗过检查工具但cast是我保证它是对的如果判断函数写错了运行时炸得毫不留情。TypeGuard的价值就在这儿它让检查工具理解这个布尔函数同时也是一个类型谓词。from typing import TypeGuard def is_str_list(val: list[object]) - TypeGuard[list[str]]: return all(isinstance(x, str) for x in val)加上返回注解之后上面join_all里的join不再报错而且运行时不需要任何额外操作TypeGuard纯粹是给静态检查看的。这一个改动就能让大量依赖cast的代码清爽起来我个人觉得它是近几年typing里性价比最高的补充之一。4.2 ParamSpec 和 Concatenate装饰器不再丢签名装饰器丢签名是 Python 的老问题。你写一个日志装饰器def log_calls(fn): functools.wraps(fn) def wrapper(*args, **kwargs): print(fcall {fn.__name__}) return fn(*args, **kwargs) return wrapper装饰完之后检查工具对wrapper的签名一无所知参数全丢。以前只能靠functools.wraps应付运行时静态检查完全无解。ParamSpec补上了这块from typing import Callable, ParamSpec, TypeVar P ParamSpec(P) R TypeVar(R) def log_calls(fn: Callable[P, R]) - Callable[P, R]: functools.wraps(fn) def wrapper(*args: P.args, **kwargs: P.kwargs) - R: print(fcall {fn.__name__}) return fn(*args, **kwargs) return wrapperP.args和P.kwargs这两个特殊写法是关键它们让检查工具知道这里的参数跟被装饰函数的参数完全一致。而Concatenate处理的是另一种场景装饰器要在参数列表前面插入自己的参数。from typing import Concatenate def with_db(fn: Callable[Concatenate[Connection, P], R]) - Callable[P, R]: functools.wraps(fn) def wrapper(*args: P.args, **kwargs: P.kwargs) - R: with get_conn() as conn: return fn(conn, *args, **kwargs) return wrapper被装饰的函数第一个参数是Connection调用方却不需要传它。这种注入式装饰器在 Web 框架、任务队列里极常见没有Concatenate的话类型注解基本写不出来只能全用Any糊过去。4.3 Self、Never、Unpack、dataclass_transform 各自解决什么Self是给链式调用和继承准备的。一个基类里的方法返回self如果注解写成类名所有子类继承后注解就错了。写成Self就自动指向实际类型from typing import Self class Query: def where(self, cond: str) - Self: self._conds.append(cond) return self class UserQuery(Query): pass q UserQuery().where(id 1) # 类型推导为 UserQuery不是 QueryNever用来标注永远不会正常返回的函数最常见的就是raise辅助函数from typing import Never def fail(msg: str) - Never: raise RuntimeError(msg)加了Never之后检查工具知道fail(...)之后的代码不可达不会再报变量可能未定义之类的假警报。写一个统一的错误抛出函数能省掉无数assert和cast。Unpack解决的是TypedDict转关键字参数的问题from typing import TypedDict, Unpack class RequestOptions(TypedDict): timeout: float retries: int def request(url: str, **kwargs: Unpack[RequestOptions]) - None: ... request(http://x, timeout3.0, retries2) request(http://x, timout3.0) # 检查工具直接报拼写错误这一点对我这种经常手滑打错参数名的人非常友好以前写错只能等运行时TypeError现在编辑器里就标红了。dataclass_transform是给框架作者的自己实现 ORM、配置加载、类似pydantic的类时会用到它告诉检查工具我这个装饰器会让类表现得像 dataclass。写业务代码通常用不到但你如果维护内部框架这玩意儿能让你不用再写配套的插件。4.4 Annotated把业务元数据挂到类型上Annotated的定位很明确类型是给检查工具看的元数据是给你自己看的两者挂在同一个位置但互不干扰。from typing import Annotated PositiveInt Annotated[int, gt0] UserId Annotated[int, {table: users, pk: True}] def get_user(uid: PositiveInt) - dict: ...检查工具只认int运行时你可以通过__metadata__拿到后面那串东西实现自定义校验、生成 OpenAPI 文档、做数据库映射等等。第 3 节那套校验器稍微改几行就能支持Annotated里的规则在_check里剥掉元数据之前先读一下tp.__metadata__如果里面是gt0就顺手检查大小。这个扩展留给你自己动手改起来不超过十行。要提醒一点get_type_hints默认剥掉元数据取注解时记得加include_extrasTrue。我第 3 节的代码里已经加上了但很多教程里的示例没加直接抄过来会发现元数据全没了。5. 工程化落地从零到 CI 卡口的完整路径5.1 工具选型mypy 还是 pyright这两个是当前最主流的静态检查工具选哪个经常被问到。我自己的判断依据是这样的维度mypypyright实现语言PythonTypeScriptNode速度中等大项目加缓存后尚可明显更快增量体验好生态插件多Django、SQLAlchemy 等都有少多数靠 stub 和内置支持严格程度稳误报少更激进能查出更多潜在问题集成方式CLI、pre-commit、CIPylance编辑器、CLI、CI配置位置pyproject.toml/mypy.inipyrightconfig.json/pyproject.toml如果你用 VS CodePylance底层就是 pyright编辑器里已经实时在跑了CI 上直接用 mypy 会导致两边结论不一致。所以我的常用组合是CI 上跑 mypy 作为唯一权威编辑器里 Pylance 保持basic模式当提示不追求两边结果完全一致但也不让 Pylance 的严格模式干扰写代码。如果团队以命令行工具链为主、插件依赖重那 pure mypy 更省心。5.2 渐进式开启 strict 的实操顺序千万别一上来就在pyproject.toml里写strict true那会导致几千条报错没人有动力改。我的推进顺序是这样的第一步只开最基本的检查保证少报错但不能漏[tool.mypy] python_version 3.11 ignore_missing_imports true warn_unused_configs true第二步先把第三方库的缺失导入问题隔离掉避免噪音污染自己的代码[[tool.mypy.overrides]] module [requests.*, yaml.*, some_internal_sdk.*] ignore_missing_imports true第三步按模块逐个开严格检查从核心模块开始而不是全量开[[tool.mypy.overrides]] module [myapp.core.*, myapp.domain.*] disallow_untyped_defs true disallow_incomplete_defs true check_untyped_defs true warn_return_any true第四步等核心模块清零后再整体提升同时打开no_implicit_optional、strict_equality这类容易发现真实 bug 的开关。no_implicit_optional尤其值得单说它在Optional语义变化之后能一次性揪出一大批默认值是 None 但注解没写 Optional的函数这类函数在调用方传了None之后经常炸在很远的地方。注意warn_unused_ignores建议早点打开。它会告诉你哪些# type: ignore已经没用了。这类注释一旦积累起来就是技术债的温床谁也不知道删了会不会冒错于是一直留着最后整个文件全是 ignore。5.3 存量项目的改造节奏与 CI 卡口几万行甚至几十万行的老项目最怕的就是运动式改造。我今天把注解全补上下周业务需求一来没人有空再补注解很快又过时。真正有效的做法是把类型检查变成增量约束只保证新代码干净。具体操作是加一个 CI 任务跑 mypy 但只针对改动过的文件。多数 CI 平台都能拿到本次改动的文件列表把它过滤出.py文件后传给 mypy 作为参数。这样做的好处很明显全量检查的结果作为基线存在但不再当作失败条件新增和修改的文件必须通过等于给代码加了一道只进不出的单向阀门。name: typecheck on: [push, pull_request] jobs: mypy: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 with: fetch-depth: 0 - uses: actions/setup-pythonv5 with: python-version: 3.12 - name: 安装检查工具 run: pip install mypy - name: 只检查本次改动 run: | git diff --name-only origin/main...HEAD \ | grep \.py$ \ | xargs -r mypy --cache-dir.mypy_cache用fetch-depth: 0是为了拿到完整历史方便做 diff。这段脚本很短但它带来的改变是长期的半年之后再回头看主力模块基本都有注解了。另外提一句CI 上开启缓存非常重要。mypy 的增量缓存目录默认在.mypy_cache把它在 CI 里缓存下来第二次跑通常能快好几倍。老项目全量检查动辄两三分钟不加缓存的话开发体验会非常差最后大家就会想办法绕过检查。6. 常见坑与排查实录6.1 高频报错速查表下面这张表是我自己整理出来的踩过至少一遍的坑基本都在里面了现象根因处理方式get_type_hints拿到字符串顶部有from __future__ import annotations用get_type_hints而不是__annotations__Annotated元数据丢失忘了传include_extrasTrue取注解时显式开启NameError: name X is not defined前向引用在局部作用域传localns或延迟到调用时解析True通过了int校验bool继承自int显式拦截 boolget_origin判断Annotated失败get_origin返回的是底层类型改用hasattr(tp, __metadata__)XY 在 3.9 报语法错误isdataclass判断为假装饰器返回了新对象用dataclasses.is_dataclass(obj)对装饰后的类型判断typing.Union和types.UnionType的区分也值得单独提一句。前者是Union[int, str]的 origin后者是int | str的 origin3.10 引入。解析的时候两个都要判否则用新语法的代码会全部漏过检查而且不报错——这是最危险的情况静默失效。6.2 文档里不写的几条经验第一条别在导入期做重活。我见过有的项目在模块顶层就调用get_type_hints遍历所有类结果启动时间多了几百毫秒。启动慢的问题往往就是这么一点点堆出来的而且很难定位。养成延迟解析的习惯只在真正需要时算算完缓存住。第二条装饰器的顺序会影响结果。validate和functools.lru_cache叠在一起的时候谁在外谁在内行为完全不同。validate在外层意味着每次调用都校验在内层意味着只有缓存未命中时才校验。哪种对取决于你的场景但一定要想清楚别让它随机决定。第三条第三方库的类型存根质量参差不齐。有些库自带py.typed标记类型信息完整有些库的存根是社区维护的可能跟实际版本对不上会出现明明运行时正常、检查工具却报错的情况。遇到这种不要硬改自己的代码去迁就直接在[[tool.mypy.overrides]]里把这个模块的检查降级把精力留在自己的代码上。第四条注解不是越多越好。给每个变量都标类型会让代码很难读尤其是那种一眼就能看出类型的局部变量。我的习惯是函数签名和公开 API 一定要有复杂数据结构一定要有剩下的交给推导。团队里如果对这条尺度没有共识最后会变成互相 code review 争论格式效率损失比收益大。第五条TypedDict和dataclass的选择要看数据流向。从外部接口拿到的字典先用TypedDict描述结构检查通过之后再转成dataclass往后传。反过来也行但别混用混用之后检查工具会频繁在两个类型之间报不兼容你会在cast上花掉大量时间。最后分享一个小技巧。如果你在给一个没有类型信息的库写包装层可以在包装模块的最上面加一行from typing import TYPE_CHECKING把只在类型检查时需要的导入放进if TYPE_CHECKING:块里。这样运行时不会真的导入那些库避免了循环导入和启动开销检查工具又能拿到完整类型。这个模式在我处理老项目时几乎每个模块都会用到比硬扛循环导入问题要舒服得多。