2299 字
约 7 分钟
15
S10-X09 · 类型注解与运行时校验的配合

写了 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 快、推断激进、字面量收窄好 同样管不了运行时构造的对象

三个盲区要背下来:

  1. Any 传染:Any 参与运算的结果也是 Any,检查器往下全部放弃。一行 json.loads(text) 就能让后面几十行失去保护。
  2. 动态属性:getattr/setattr/obj.__dict__[...] 一律按 Any 处理。
  3. 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. 自测

  1. 为什么 def f(x: int) 传入字符串不会立即报错?
  2. Any 为什么会"传染"?它对下游检查意味着什么?
  3. isinstance(x, list[int]) 抛什么异常,正确的替代写法是什么?
  4. Optional[int] 与"参数有默认值"之间是什么关系?
  5. from __future__ import annotations 之后,pydantic 为什么可能报 PydanticUndefinedAnnotation?
  6. mypy --strict 为什么不建议在存量项目上一次性打开?
S10-X09 · 类型注解与运行时校验的配合
https://www.clxhxhhr.top/posts/5858/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。