注解默认"只是注释" — 存进 __annotations__ 但解释器不检查; mypy/pyright 在 CI 静态拦错零开销, pydantic 在边界运行时校验, 双通道护住动态语言
__annotations__, 解释器默认一个字都不检查from __future__ import annotations 让注解延迟求值类型注解是写在代码里的"合同条款": Python 解释器只负责把条款存档(__annotations__), 自己从来不执行合同。要让它生效有两条路: 左边把合同交给 mypy/pyright, 在 CI 里静态审合同, 错误合并前就拦下, 运行时零开销; 右边把合同交给 pydantic, 在系统边界(请求体、配置、消息)运行时逐字验货, 非法数据当场抛 ValidationError。双通道合起来, 动态语言的灵活性没丢, 又补上了静态语言的兜底。
typing.get_type_hints()(会解析字符串注解)。
def f(x: int) -> str: ... print(f.__annotations__) # → {'x': <class 'int'>, 'return': <class 'str'>} # 关键: 只存档不检查; 取回真实类型用 get_type_hints(f)
from __future__ import annotations 把所有注解变成字符串, 定义时不求值 — 前向引用不用加引号, 循环导入也解掉大半。
from __future__ import annotations class Node: next: Node | None # 关键: 引用自身不用加引号 # 所有注解变字符串延迟求值, 前向引用/循环导入大半解掉
X | None 是类型并集, None 本身也是类型。参数想"可不传"必须写 = None 默认值; 只写 Optional 不给默认, 参数照样必传。
def f(x: int | None): ... # 只可空, x 仍必传 f() # → TypeError: missing 1 required positional argument def g(x: int | None = None): ... # 对: 可空+可不传两件套
runtime_checkable 后还能 isinstance(只查方法存在)。
class Readable(Protocol): def read(self, n: int) -> bytes: ... class LegacyFTP: def read(self, n: int) -> bytes: ... # 没继承也算实现 def fetch(res: Readable): ... fetch(LegacyFTP()) # mypy: OK 形状匹配即可
class DBConfig(TypedDict): host: str; port: int cfg: DBConfig = {"host": "db1", "port": 3306} cfg["port"] = "3306" # mypy: error 运行时不拦, 仍是 dict
T = TypeVar("T") 让 Repository[T] 进什么类型出什么类型; 3.12 起 class Repo[T] 直接写, 不用再声明 TypeVar。
T = TypeVar("T") def first(xs: list[T]) -> T: return xs[0] n = first([1, 2]) # → 推导为 int, 进出同型 class Repo[T]: # 3.12+: 直接写泛型类, 不用 TypeVar ...
match 写状态机最稳; Literal 管"就是这几个字符串"。
def set_mode(m: Literal["r", "w"]) -> None: ... set_mode("x") # mypy: error: 不在 "r"/"w" 里 # Enum 同理收窄取值, match + assert_never 还能查漏分支
row = conn.execute(sql).fetchone() # 驱动只给 Any oid = cast(int, row[0]) # 关键: 确认 schema 后人工收窄 oid + 1 # 零运行时开销, 检查器从此认 int
# type: ignore[arg-type] 并配 warn_unused_ignores — ignore 一旦失效立刻变新告警, 防止永久遮蔽真错。
legacy() # type: ignore[no-any-return] 错误码必带 # warn_unused_ignores = true: # 上游修好后这条 ignore 失效, 自己反过来变告警
Callable[P, R] 把被装饰函数的参数/返回类型原样透传, 打点/重试/缓存包装后 IDE 补全一点不丢。
P = ParamSpec("P"); R = TypeVar("R") def timed(fn: Callable[P, R]) -> Callable[P, R]: ... # 关键: 被 @timed 的函数参数/返回类型原样透传, 补全不丢
Annotated[Decimal, Field(gt=0)] 类型 + 元数据一层写完; pydantic 约束、FastAPI 依赖注入都从这读元数据, 检查器只看第一个参数。
Amount = Annotated[Decimal, Field(gt=0)] class Order(BaseModel): amount: Amount # pydantic 读 Field: 校验 > 0 # 检查器只看第一个参数 Decimal, 元数据不影响类型
Final 声明"不再重新绑定"(静态可查, 运行时不拦); ClassVar 标记类变量, 防止 pydantic 把它当字段收集。
MAX_RETRIES: Final = 3 MAX_RETRIES = 5 # mypy: error, 但运行时照常改 class C: counter: ClassVar[int] = 0 # pydantic 不当字段收集
一次性全量 strict 等于一次报出五万个错, 没人敢合。正解是配置分层: 新模块最严, 旧模块先豁免再逐步收网:
# pyproject.toml — 渐进式收网: 新代码 strict, 旧代码先放行 [tool.mypy] python_version = "3.11" plugins = ["pydantic.mypy"] # 不装插件: pydantic 字段在 mypy 眼里全是 Any warn_unused_ignores = true # ignore 失效即告警, 防止永久遮蔽真错 [[tool.mypy.overrides]] module = "legacy.*" # 存量代码: 先整体豁免, 按目录逐个摘掉 ignore_errors = true [[tool.mypy.overrides]] module = "app.*" # 新模块一律最严 disallow_untyped_defs = true disallow_any_generics = true
CI 里 mypy 失败即红灯。三个月后 legacy 豁免名单从 47 个目录收到 6 个, 每次摘掉一个都有静态检查兜底。
校验逻辑散在视图函数里既难审又难测。把契约写成模型, 校验、文档、错误格式一次到位:
from decimal import Decimal from pydantic import BaseModel, Field, ConfigDict class TransferRequest(BaseModel): model_config = ConfigDict(extra="forbid") # 未知字段直接拒: 防客户端塞脏参数 src: str = Field(min_length=8, max_length=8) # 约束跟类型写在一起, 一眼可审 amount: Decimal = Field(gt=Decimal("0"), max_digits=12) class TransferResponse(BaseModel): receipt_no: str balance: Decimal @app.post("/transfer", response_model=TransferResponse) def transfer(req: TransferRequest): # 进函数体时已是校验过的强类型对象 ... # amount=-1 或多余字段: 自动 422 + 字段级报错, OpenAPI 文档同步生成
想让函数接受"任何可读可关的资源"。继承抽象基类要改别人代码, Protocol 只认形状:
from typing import Protocol class Readable(Protocol): # 结构化类型: 只看方法形状, 不要求继承 def read(self, n: int) -> bytes: ... def close(self) -> None: ... def fetch_header(res: Readable): # 文件/socket/自研连接/测试替身都能传 try: return res.read(16) finally: res.close() class LegacyFTP: # 十年前老类, 零改动即满足 Readable def read(self, n: int) -> bytes: ... def close(self) -> None: ...
配置是 dict 不是类, 但字段名打错、类型给错这类问题完全可以让静态检查器先抓一遍:
from typing import TypedDict, Literal import json, pathlib class DBConfig(TypedDict): host: str port: int engine: Literal["mysql", "postgres"] # 写 "mongo" 直接红线 class AppConfig(TypedDict, total=False): # total=False: 所有键都可选 db: DBConfig debug: bool cfg: AppConfig = json.loads(pathlib.Path("app.json").read_text()) cfg["port"] = "3306" # mypy 立刻报: str 塞不进 int 槽位 # 注意: TypedDict 运行时不校验 — 配置来源不可信时要换 pydantic 解析
状态值用裸字符串, 新增状态后总有分支被漏。枚举 + assert_never 让"漏写分支"变成静态错误:
import enum from typing import assert_never class OrderStatus(str, enum.Enum): PAID = "paid" SHIPPED = "shipped" DONE = "done" def next_action(s: OrderStatus) -> str: match s: case OrderStatus.PAID: return "notify_warehouse" case OrderStatus.SHIPPED: return "watch_carrier" case OrderStatus.DONE: return "archive" case _: assert_never(s) # 明天加 REFUNDED 忘了写分支: mypy 当场报错
User 仓储、Order 仓储除了实体类型什么都不差。泛型把"进出同型"写进合同:
from typing import Generic, TypeVar T = TypeVar("T") class Repository[T]: # 3.12 语法; 老版本写 Generic[T] def __init__(self, model: type[T]) -> None: self.model = model def get(self, oid: int) -> T | None: ... def save(self, obj: T) -> None: ... user_repo: Repository[User] = Repository(User) u = user_repo.get(42) # u 推导为 User | None, 属性补全全有 user_repo.save(Order(...)) # mypy: Repository[User] 不收 Order
监控装饰器如果签名写 *args: Any, 被包函数的类型信息全灭。ParamSpec 原样透传:
from typing import ParamSpec, TypeVar, Callable import time, functools P = ParamSpec("P") R = TypeVar("R") def timed(fn: Callable[P, R]) -> Callable[P, R]: @functools.wraps(fn) def wrap(*args: P.args, **kwargs: P.kwargs) -> R: # 签名原样透传 t0 = time.perf_counter() try: return fn(*args, **kwargs) finally: metrics.observe(fn.__name__, time.perf_counter() - t0) return wrap # 被 @timed 的函数: 参数/返回值类型一个不丢, IDE 补全照常
数据库驱动只会返回 Any, 放任不管 Any 会一路传染。边界处人工确认后收窄:
from typing import cast from decimal import Decimal from sqlalchemy import text row = conn.execute(text("SELECT id, amount FROM orders WHERE id=:o"), {"o": 42}).fetchone() # 驱动层没有类型信息, 返回 Any — 这是 Any 入侵的第一站 oid = cast(int, row[0]) # 确认过 schema 后手动收窄, 零运行时开销 amount = cast(Decimal, row[1]) total = legacy_sum(raw) # type: ignore[no-any-return] # ignore 必须带错误码 + 限期修; warn_unused_ignores 会盯着它失效
事件管道每秒几十万条, 逐条构造模型开销可观。pydantic-core 的批量入口快一个量级:
from pydantic import TypeAdapter, BaseModel, ConfigDict from decimal import Decimal class Event(BaseModel): model_config = ConfigDict(frozen=True) # 不可变: 可哈希可共享, 校验后零拷贝 user_id: int amount: Decimal tag: str | None = None adapter = TypeAdapter(list[Event]) # 编译一次, 别在循环里建模型 events = adapter.validate_python(raw_batch) # pydantic-core (Rust) 批量校验 # 100 万条: 逐条 Event(**x) 8.9s → 批量 2.1s; pydantic v1 同款 21s
结账接口偶发 500, 栈指向最深处。根因是注解只是注释, 而静态通道没开、边界没校验:
# 事故现场: coupon 可能为 None, 但注解写着 Coupon, 解释器根本不查 # def apply_coupon(order, coupon): return order.total * (1 - coupon.rate) # → AttributeError: 'NoneType' object has no attribute 'rate' (深处才炸) # 复盘后双通道补齐: def apply_coupon(order: Order, coupon: Coupon | None) -> Decimal: if coupon is None: # 类型收窄: 这行之后 coupon 就是 Coupon return order.total return order.total * (1 - coupon.rate) class CouponPayload(BaseModel): # 外部入参先过边界模型再进领域函数 coupon_code: str | None # 再配 CI 里的 mypy --strict: 传 None 给 Coupon 参数在合并前就红
int 传字符串照跑, 直到深处 AttributeError/TypeError 才炸, 而且栈不在出错入口。正解: 外部数据在边界过 pydantic, 内部代码靠 mypy strict 兜底, 别指望解释器。
# 错: def add(a: int, b: int) -> int: ... add("1", "2") # → '12' 解释器根本不查注解 # 对: 外部数据先过 pydantic, 内部靠 mypy --strict
def f(x: int | None) 不带 = None, 调用 f() 依然 TypeError missing argument。正解: 可空可选要两件套 x: int | None = None; 只可空必传就保持无默认。
# 错: def f(x: int | None): ... 没给默认值 f() # → TypeError: missing 1 required positional argument: 'x' # 对: def f(x: int | None = None): ... 可空+可不传
list[int] 要 3.9+, 在 3.8 运行时直接 TypeError('type' object is not subscriptable)。正解: 老版本用 typing.List 或文件头加 from __future__ import annotations(只救注解位置, 不救运行时取值)。
# 错: 3.8 上运行时取值 list[int] # → TypeError: 'type' object is not subscriptable from __future__ import annotations xs: list[int] = [] # 对: 延迟求值后 3.8 也能写注解
User.dict() AttributeError。v2 改成 model_dump()/model_validate(), Config 类变 model_config。正解: 全局替换 + 开 pydantic.mypy 插件让静态检查先揪出旧调用。
# 错(v1 写法): User(**data).dict() → v2 AttributeError u = User.model_validate(data) # 对: v2 入口 u.model_dump() # 对: v2 导出; Config → model_config
# type: ignore[arg-type], 开 warn_unused_ignores, 让修好后残留的 ignore 反过来变告警。
# 错: legacy() # type: ignore — 整行所有错全被压掉 legacy() # type: ignore[no-any-return] 对: 带错误码 # warn_unused_ignores = true → 失效 ignore 反变告警
if TYPE_CHECKING: from .models import User, 配 from __future__ import annotations, 检查器看得见, 运行时不执行。
from typing import TYPE_CHECKING if TYPE_CHECKING: from .models import User # 对: 检查器看得见 def svc(u: "User") -> None: ... # 运行时不执行, 解掉环
"Node" 或文件头统一 from __future__ import annotations 一劳永逸(所有注解延迟求值)。
class Node: def nxt(self) -> "Node | None": ... # 对: 加引号 # 错: -> Node | None — 定义时 Node 还不存在, NameError
"port": "3306" 照样进门, 深处才炸。正解: TypedDict 只管静态; 不可信来源(外部配置/请求体)一律 pydantic BaseModel 解析。
class DBConfig(TypedDict): port: int cfg = json.loads('{"port": "3306"}') # 错: 不校验进门 cfg["port"] + 1 # → TypeError: 深处才炸 # 对: 外部数据用 pydantic BaseModel 解析, 门口就 422
plugins = ["sqlalchemy.ext.mypy.plugin"](或 2.0 typing 扩展), pydantic 同理配 pydantic.mypy。
# 错: 不装插件 — ORM 查询结果全是 Any, 传染整条链 # pyproject.toml: [tool.mypy] plugins = ["pydantic.mypy"] # 对: 官方插件 # SQLAlchemy 同理: sqlalchemy.ext.mypy.plugin
# 错: pydantic 校验一遍 → dataclass 再约束一遍, 字段三处维护 user = User.model_validate(raw) # 对: 门口解析一次 stats = UserStats(uid=user.id) # 对: 内部纯 dataclass 零校验
isinstance(x, Readable) 只验证有 read/close 属性, 不查签名与返回类型, 参数不匹配也过。正解: 签名正确性交给 mypy 静态检查; isinstance 只当"有没有这形状"的粗筛。
@runtime_checkable class Readable(Protocol): ... class Fake: read = 42 # 属性存在但不是方法 isinstance(Fake(), Readable) # → True 签名/类型都不查 # 对: 签名正确性交给 mypy, isinstance 只当粗筛
@overload 桩函数没有裸实现, 调用时 TypeError 或 mypy 报 "overloaded function implementation is missing"。正解: overload 桩后面必须跟一个无装饰器的实现函数, 签名要覆盖所有桩。
@overload def parse(s: str) -> dict: ... @overload def parse(s: bytes) -> bytes: ... def parse(s): return json.loads(s) # 对: 必须有裸实现
x: int = 0 是类变量语义, 实例却当各自独立字段用, 一个对象改了全体可见。正解: 实例属性在 __init__ 里 self.x: int = 0; 真类变量标 ClassVar[int], 让 pydantic 也别把它当字段。
class Opt: timeout: int = 30 # 错: 类变量语义, 全体共享一份 # 对: __init__ 里 self.timeout: int = 30 各自独立 # 真类变量标 timeout: ClassVar[int] = 30
class S(str, Enum) 在 3.11 前 f"{member}" 输出 S.PAID 而不是 paid, 存库/拼 URL 就错。正解: 取值用 .value; 3.11+ 用 enum.StrEnum; 比较时统一先转 str 避免成员与裸串混比。
class S(str, Enum): PAID = "paid" f"{S.PAID}" # 3.11 前 → 'S.PAID', 存库就错 S.PAID.value # 对: → 'paid'; 3.11+ 用 enum.StrEnum
Annotated[X, obj] 指望框架自动注入, 只有显式支持的框架(FastAPI/pydantic)才读这些元数据。正解: 依赖注入走框架自己的 Depends 体系; Annotated 元数据只放该框架文档声明支持的类型。
# 错: Annotated[X, MyDBConn()] 自造元数据指望自动注入 — 没人读它 # 对: FastAPI 体系内才有效: def q(db: Annotated[Session, Depends(get_db)]): ...
MAX: Final = 100 运行时重新赋值照常成功, Final 只是给检查器看的。正解: 真要运行时不可变用 dataclass(frozen=True) 或 pydantic frozen 模型; CI 开 strict 让 rebind 在静态就被拦。
MAX: Final = 100 MAX = 200 # mypy: error 但运行时照常成功 # 对: 运行时不可变用 dataclass(frozen=True) / pydantic frozen
isinstance 的类型判断, 或以为继承 Protocol 才算数。正解: 分清两套体系 — Protocol 按形状匹配(静态), ABC/普通基类按继承链匹配; runtime_checkable 的 Protocol 才能参与 isinstance。
class Proto(Protocol): ... class Impl: ... # 没继承, 形状对即可静态通过 isinstance(Impl(), Proto) # 错: → TypeError (未开 runtime_checkable) # 对: @runtime_checkable 后才可 isinstance; 继承链判断用 ABC
model_dump() 递归拷贝, 单次几十毫秒, 热路径 P99 直接劣化。正解: 只 dump 需要的字段(include=)、大列表用 TypeAdapter 批量, 内部传递直接持模型别反复 dump。
# 错: 热路径每次 order.model_dump() — 深嵌套递归拷贝 resp = order.model_dump(include={"id", "status"}) # 对 TypeAdapter(list[Event]).dump_python(events) # 对: 批量
ValueError/AssertionError; 系统级错误(如 DB 挂了)别放 validator。
# 错: validator 里 raise TypeError → 不聚合, 直接 500 @field_validator("port") def chk(v: int) -> int: if v > 65535: raise ValueError("out of range") # 对 return v
disallow_untyped_defs, 无注解函数返回 Any, 下游推导全变 Any, 检查形同虚设。正解: strict(至少 disallow_untyped_defs + disallow_any_explicit 评审) + warn_return_any, 让 Any 显式冒头逐个消灭。
def legacy(x): return x * 2 # 错: 无注解 → 返回 Any reveal_type(legacy(2)) # mypy → Any, 下游推导全灭 # 对: strict + disallow_untyped_defs + warn_return_any