一个 Python 项目接了 mypy,配成严格模式,开启后有八百多个错误。团队花了两周修,其中四百多个是第三方库缺类型注解——修不了,只能加 # type: ignore。修完之后,类型检查没有再拦住过任何一个真实缺陷,因为大部分代码都被 ignore 掉了。
类型检查的收益不在"有没有开",而在检查覆盖的是谁的代码、信不信得过。而这些决定要在开启之前想清楚,否则得到的是一个看起来严格、实际被豁免淹没的配置。
1. 类型检查能拦住什么
| 能拦 | 不能拦 |
|---|---|
| 属性拼错、方法不存在 | 类型正确但语义错误 |
| 参数数量与类型不匹配 | 运行时才成立的业务约束 |
None 未处理 |
空集合与非空集合的差别 |
| 返回值被误用 | 数据格式与实际情况不符 |
还有一个更隐蔽的限制:类型检查是静态的,它不执行代码。条件分支里才可能出现的类型不匹配,它只能按声明推断;动态构造的对象(getattr、字典转对象)它完全看不见。
右侧四项里最典型的是最后一项:函数签名写 -> dict,实际返回的字典缺一个键,类型检查完全满意,运行时崩在取值处。类型系统管的是形状,不是内容。
2. 严格程度的四个档位
| 档位 | 配置 | 适用 |
|---|---|---|
| 只检查新代码 | 目录白名单 | 存量项目起步 |
| 基本模式 | disallow_untyped_defs=false |
混合团队 |
| 严格模式 | 全面 + disallow_any 系列 |
新项目、库 |
| 严格 + 运行时校验 | 类型 + Pydantic/dataclass 校验 | 边界层 |
第一档是存量项目唯一可行的路径:先只对新模块严格,让严格成为"新代码的习惯",而不是一场全库改造。
档位之间不是技术选择,而是组织选择:严格程度越高,写代码的人需要付出的注解成本越高,而收益由后续维护者分享。存量项目要推进这件事,通常得先让收益在边界层显现出来。
第四档解决"形状 vs 内容"的缺口:类型保证形状,运行时校验保证内容。两者放在一起,才覆盖边界处的真实风险。
// 类型注解给编辑器与检查器;运行时校验给真实数据
from pydantic import BaseModel, Field
class OrderIn(BaseModel):
order_id: str = Field(min_length=4)
amount: Decimal = Field(gt=0, max_digits=12, decimal_places=2)
// 注解说 amount 是 Decimal;Field 说它必须为正且两位小数
// 两者都在,才既能在编辑器里补全、又能在运行时拒绝脏数据
3. 哪些地方值得严格
| 位置 | 为什么 |
|---|---|
| 对外接口边界(请求/响应模型) | 错误代价最高 |
| 核心业务计算 | 改动频繁、易错 |
| 数据管道入口 | 外部数据不可信 |
| 公共库/被多模块使用 | 影响面大 |
反过来,不值得严格的:一次性脚本、胶水代码、对第三方库的包装(很难标清)、实验性模块。
分目录配置的落地形态通常是一份集中配置,避免每个目录各自为政:
// pyproject.toml:按模块设不同严格度
[tool.mypy]
strict = false
warn_unused_ignores = true // 让多余的 ignore 自动清理
[[tool.mypy.overrides]]
module = ["app.api.*", "app.domain.*"]
strict = true
[[tool.mypy.overrides]]
module = ["app.infra.*"]
disallow_untyped_defs = false
[[tool.mypy.overrides]]
module = ["third_party.*"]
ignore_missing_imports = true
warn_unused_ignores 这一项价值很高:它把"多余的豁免"变成可见的错误,从而阻止豁免只增不减。
// 分层的严格度配置:按目录设,不按全局设
src/api/** → strict(对外边界)
src/domain/** → strict(核心计算)
src/infra/** → basic(包装第三方,Any 多)
scripts/** → 不检查(一次性)
tests/** → basic(过度严格会写不动测试)
4. ignore 的成本
| 用法 | 成本 |
|---|---|
整文件 ignore |
该文件永久失守 |
单行 ignore |
需写明原因,否则无法清理 |
Any 泛化 |
传染,下游全部失去检查 |
| 关闭某类检查 | 局部有效,容易扩散 |
清理豁免也有可行办法:统计 ignore 的数量趋势,要求"新增代码不增豁免"。这个指标比"strict 是否开启"更能反映实际覆盖质量。
// CI 里统计豁免数量,与基线比较,只允许下降
grep -rn "type: ignore" src/ | wc -l > .mypy-ignore-count
prev=$(cat .mypy-ignore-baseline 2>/dev/null || echo 9999)
now=$(cat .mypy-ignore-count)
[ "$now" -le "$prev" ] || { echo "豁免数量上升:$prev → $now"; exit 1; }
第三项是最隐蔽的:一个 Any 会让下游所有推断失效。函数返回 Any,调用方拿到的任何属性访问都不会报错,于是这条链路上的检查形同虚设。
// 反例:Any 传染,下游全部失去检查
def load(path: str) -> Any: ...
// 正例:给出具体类型,或用 Protocol 描述结构
from typing import Protocol
class Row(Protocol):
id: str
amount: Decimal
def load(path: str) -> list[Row]: ...
5. 检查器之外的第三种手段
| 手段 | 检查什么 | 成本 |
|---|---|---|
| 静态类型检查 | 形状 | 一次性配置 + 持续修复 |
| 运行时校验 | 内容 | 每次请求开销 |
| 测试 | 行为 | 编写与维护 |
// 三层各拦一类:类型、数据、行为
def transfer(src: Account, dst: Account, amount: Decimal) -> None: // 形状
if amount <= 0: raise ValueError("金额必须为正") // 内容
... // 行为由测试覆盖
// 调用方传错类型 → 类型检查拦
// 传负数金额 → 运行时校验拦
// 重复扣款 → 测试拦
三者互补而不是替代:类型检查拦住"不可能的组合",运行时校验拦住"不合规的数据",测试拦住"不该发生的逻辑"。任何一环缺失,缺陷会从那一环漏到下一环。
6. 排错:五种"开了检查没收益"
| 现象 | 成因 | 处理方向 |
|---|---|---|
大量 ignore |
一次追求全库严格 | 改为按目录渐进 |
| 检查通过但线上报错 | 只覆盖形状不覆盖内容 | 补运行时校验 |
| 团队不愿写注解 | 收益看不见 | 先严格边界层,见效后再扩 |
| CI 慢很多 | 全量检查 | 只检查改动文件 + 定期全量 |
| 编辑器提示噪音多 | 第三方库无类型 | 配置 stub 或忽略特定包 |
第一项是启动方式的问题:存量项目一次开严格,得到的必然是大量豁免,而豁免一旦形成习惯,后续新增的也会被顺手豁免。
7. 边界:什么情况不值得接入
| 场景 | 为什么 | 做法 |
|---|---|---|
| 一次性脚本 | 生命周期短 | 不检查 |
| 纯胶水代码(小于 200 行) | 无逻辑 | 不检查 |
| 与动态特性深度耦合(元编程) | 类型难以表达 | 局部忽略 |
| 已有强运行时校验 | 覆盖已足够 | 类型注解可选 |
判断口径一句话:这个模块的"形状错误"会不会造成线上问题。会,就值得严格;不会,注解的作用主要是编辑器体验。
8. 自测
- 类型检查能拦的四类与不能拦的四类分别是什么?"形状 vs 内容"这个区分意味着什么?
- 存量项目为什么只能从"只检查新代码"起步?一次开严格会得到什么结果?
- 为什么
Any的代价不止是"这一处不检查"?它的传染机制是什么? - 对外边界层为什么值得同时要类型注解与运行时校验?两者各拦什么?
ignore的四种用法中,哪一种最难清理?为什么它会让整文件永久失守?- 判断"是否接入类型检查"的那个问题是什么?