bigywater.tech
Python · 103

mypy 接入旧项目:渐进式类型化方案

给一个没有类型注解的旧项目接上 mypy,直接 --strict 会淹没在几百个错误里,直接默认配置又什么都查不到。这篇造了一个「旧项目 + 新代码」的小环境,把 mypy 的严格程度一档一档拧紧,看每一档到底多出了什么错误,以及一个实用的「旧宽新严」渐进方案。

0 → 2 → 10 → 14
默认 / check-untyped-defs / disallow-untyped-defs / strict 下的错误数
Any
无注解函数的参数和返回值都是 Any,新代码调用它得不到任何检查
0.55 → 0.10 s
60 个模块的 mypy 冷启动与增量重跑

做的事:一个旧项目加一点新代码

legacy/orders.py(节选,没有任何注解) def order_total(order): total = 0 for item in order["items"]: total = total + item["price"] * item["qty"] return total + "1" # 旧代码里的 bug: int + str class Cache: def __init__(self): self.data = {} self.hits = 0
newcode/service.py(节选,有注解) def discounted(order: dict[str, object], rate: float) -> float: base: float = order_total(order) return base * rate def cache_hits(c: Cache) -> int: return c.hits + "1" # 新代码里自己写错的
实测 · 本机执行输出
mypy 版本: mypy 2.3.1 (compiled: yes)

验证一:默认配置什么都看不到

实测 · 本机执行输出
Success: no issues found in 6 source files

默认配置下 mypy 不检查没有类型注解的函数的函数体(它们被当作「动态代码」)。所以 order_total 里的 int + "1" 完全不报;连新代码里的 c.hits + "1" 也没报——因为 Cache.__init__ 没有注解,里面的 self.hits = 0 没有被推断,c.hits 是 Any。

验证二:逐档收紧

实测 · 本机执行输出
  默认配置                         0 个错误
  --check-untyped-defs         2 个错误
  --disallow-untyped-defs     10 个错误
  --strict                    14 个错误
  --check-untyped-defs 下报告的 2 个错误(默认配置一个都看不到):
    legacy/orders.py:11: error: Unsupported operand types for + ("int" and "str")  [operator]
    newcode/service.py:13: error: Unsupported operand types for + ("int" and "str")  [operator]
    Found 2 errors in 2 files (checked 6 source files)
  --strict 下的 14 个错误按类型统计:
    no-untyped-def     10
    operator           2
    no-untyped-call    2

--check-untyped-defs:开始检查无注解函数的函数体,2 个错误立刻出现——旧代码里那个 int + str,以及新代码里的 c.hits + "1"(现在 hits 被推断成 int 了)。这一档最有价值:不需要改任何代码就能发现真正的 bug。--disallow-untyped-defs 变成 10 个:每个没写签名的函数都算错误,这是「要求补注解」的一档。--strict 是 14 个,里面 10 个 no-untyped-def、2 个 operator、2 个 no-untyped-call(有类型的代码调用了无类型的函数)。

验证三:Any 传染

实测 · 本机执行输出
  probe.py:3: note: Revealed type is "def (x: Any, default: Any =) -> Any"
  probe.py:4: note: Revealed type is "Any"
  probe.py:5: note: Revealed type is "Any"
  probe.py:6: note: Revealed type is "Any"

没有注解的函数,参数和返回值都是 Any。safe_int("3")、merge(...)、Cache().get("k") 得到的全是 Any。所以新代码把 safe_int 的结果标成 str(其实是 int)——不报错,因为 mypy 没有信息可对比。Any 不是「未知类型」,而是「关闭检查」,每多一个无类型的函数,就多一个检查不到的洞。

验证四:旧宽新严

按模块设置不同的严格程度
mypy.ini [mypy] check_untyped_defs = true [mypy-legacy.*] ignore_errors = true [mypy-newcode.*] strict = true
实测 · 本机执行输出
  mypy.ini: 全局 check_untyped_defs; legacy.* ignore_errors; newcode.* strict → 3 个错误
    newcode/service.py:5: error: Call to untyped function "order_total" in typed context  [no-untyped-call]
    newcode/service.py:9: error: Call to untyped function "safe_int" in typed context  [no-untyped-call]
    newcode/service.py:13: error: Unsupported operand types for + ("int" and "str")  [operator]
    Found 3 errors in 1 file (checked 6 source files)

整体打开 check_untyped_defs,旧模块 ignore_errors(暂时压住,不必一次修完),新模块 strict。3 个错误全在新代码里:一个是它自己的 int + str,另两个是「在有类型的上下文里调用了无类型的旧函数」——这是渐进式的关键信号:新代码想严格,就会推着你去给它依赖的旧函数补类型。补完一个,就把对应模块从 ignore_errors 名单里拿掉。

验证五:type: ignore 与 warn_unused_ignores

实测 · 本机执行输出
  默认: Found 1 error in 1 file (checked 1 source file)
  --warn-unused-ignores:
    ign.py:2: error: Unused "type: ignore" comment  [unused-ignore]
    Found 1 error in 1 file (checked 1 source file)

三行里有一行的 # type: ignore 是多余的(y: int = 1 本来就对)。默认配置下多余的 ignore 不会被发现,还悄悄留着;打开 --warn-unused-ignores 立刻报出来。渐进接入时会大量使用 ignore,这个开关能保证它们不会变成永远不清理的死注释;写 [assignment] 这样带错误码的形式,可以避免误吞别的错误。

验证六:增量缓存

实测 · 本机执行输出
  60 个模块: 冷启动 0.55 s | 无改动再跑 0.10 s | 改了 1 个文件再跑 0.11 s | 缓存目录大小 2.6 MB

60 个小模块:冷启动 0.55 s,无改动再跑 0.10 s,只改一个文件再跑 0.11 s;缓存目录只有 2.6 MB。这个规模太小,不能推到大项目,但说明缓存命中后开销很低,把 mypy 放进 pre-commit 或 CI 不会拖垮反馈速度。

分步演示:把严格程度一档一档拧紧

每一档的错误数来自上面的真实运行
渐进式接入 mypy
当前档位

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,mypy 2.3.1(compiled);缓存计时取一次冷启动、一次热重跑、一次改动后重跑
    • 没有验证:真实大型项目上的错误数量级、第三方库缺少类型存根(ignore_missing_imports)、--warn-return-any、mypy daemon(dmypy)、与 pyright 的差异
    京ICP备2026067502号-1京公网安备11010502063487号