2140 字
约 7 分钟
21
S10-A12 · 类型检查的深度:检查器读到哪一步就够

一个 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. 自测

  1. 类型检查能拦的四类与不能拦的四类分别是什么?"形状 vs 内容"这个区分意味着什么?
  2. 存量项目为什么只能从"只检查新代码"起步?一次开严格会得到什么结果?
  3. 为什么 Any 的代价不止是"这一处不检查"?它的传染机制是什么?
  4. 对外边界层为什么值得同时要类型注解与运行时校验?两者各拦什么?
  5. ignore 的四种用法中,哪一种最难清理?为什么它会让整文件永久失守?
  6. 判断"是否接入类型检查"的那个问题是什么?
S10-A12 · 类型检查的深度:检查器读到哪一步就够
https://www.clxhxhhr.top/posts/6419/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。