80 万行 Python 怎么逐步补类型?一套不停工的 Gradual Typing 迁移实战方案
很多 Python 类型教程都会从这样的代码开始:
def add(a: int, b: int) –> int:
return a + b
然后告诉你:
给函数参数和返回值加类型注解,再运行 mypy 或 Pyright 就可以了。
对于一个几千行的新项目,这当然没有问题。
但现实中的大型 Python 项目往往是另一幅画面:
def process(data):
...
这样的函数可能存在几千甚至几万个。
项目已经运行了 8 年,有 80 万行代码,里面混合着 Django、Celery、SQLAlchemy、内部 RPC、动态配置、猴子补丁和数不清的历史兼容逻辑。
几十位开发者每天还在继续提交业务代码。
这时候管理者突然说:
我们准备全面引入 Python typing。
真正的问题就来了:
从哪里开始?
你显然不能宣布:
接下来半年停止业务开发,全员补类型。
更不能直接在 CI 中执行:
mypy –strict .
因为结果大概率不是发现几十个错误,而是:
Found 187432 errors in 6931 files
然后整个团队在两天后关闭 mypy。
大型 Python 项目的 typing 迁移,本质上不是一次“补 annotation”的代码清理,而是一场持续数月甚至数年的工程治理。
真正正确的目标不是:
让所有代码立刻 typed
而是:
让项目中的“已类型化区域”持续扩大,
同时禁止类型质量倒退。
这就是 Gradual Typing——渐进式类型迁移真正应该解决的问题。
一、先建立正确目标:不要追求覆盖率,先阻止继续欠债
假设现在项目中有 80 万行代码。
其中:
10% 有比较完整的类型
20% 有零散 annotation
70% 基本没有 typing
很多团队的第一反应是制定 KPI:
Q1 类型覆盖率达到 50%
Q2 达到 80%
Q3 达到 95%
这个指标看起来很漂亮,但它非常容易被“刷”。
例如:
def process(data: Any) –> Any:
...
从 annotation 数量来看:
100% typed
从类型安全来看:
0% improvement
因为 Any 基本等于告诉类型检查器:
从这里开始,不要管我。
所以第一阶段最重要的事情不是提高 typing coverage,而是建立一条规则:
新增代码不能继续制造无类型债务。
例如团队可以先规定:
旧代码可以暂时无类型;
新模块必须有完整函数签名;
修改旧函数时,应至少补齐修改边界附近的类型。
这叫:
Stop the bleeding.
先止血,再治疗历史债务。
二、迁移之前,先画出项目的“类型地图”
80 万行代码不应该被当作一个整体处理。
第一步应该先对项目进行分区。
例如:
project/
├── api/
├── services/
├── domain/
├── repositories/
├── workers/
├── utils/
├── legacy/
└── integrations/
然后评估每个区域:
稳定性 业务重要性 动态程度 typing 难度
domain 高 高 低 低
services 中 高 中 中
api 高 高 中 中
repositories 中 高 高 中
utils 高 中 低 低
workers 中 中 中 中
legacy 低 中 极高 极高
integrations 中 高 高 高
接下来不要问:
哪个目录最大?
而要问:
哪个区域最值得先建立类型边界?
因为 typing 的收益不是均匀分布的。
某个 500 行的订单核心模块,可能比 3 万行 CLI 工具更值得优先处理。
三、从 Public API 开始,还是从 Leaf Function 开始?
这是大型项目迁移中争论最多的问题之一。
我的建议不是二选一,而是:
战略上从边界开始,战术上从 leaf function 推进。
理解这句话非常重要。
1. 为什么 Public API 很重要?
假设:
def process(data):
...
被 120 个模块调用。
如果我们先把它改成:
def process(data: dict[str, object]) –> ProcessResult:
...
那么这一条 annotation 会直接给 120 个调用点提供约束。
这种函数的“类型杠杆率”非常高。
尤其应该优先类型化:
公共 package API
service interface
repository interface
RPC client
HTTP client
核心 domain API
插件接口
跨模块调用边界
例如:
class UserRepository:
def get(self, user_id: UserId) –> User | None:
...
def save(self, user: User) –> None:
...
这类接口一旦明确,大量内部调用就能受益。
2. 为什么不能只从 Public API 开始?
因为内部实现可能非常动态。
例如:
def process(data):
x = normalize(data)
y = transform(x)
z = calculate(y)
return build_result(z)
你直接写:
def process(data: InputData) –> Result:
类型检查器可能立刻告诉你内部有几十处不一致。
所以真正实施时,往往需要从调用链底部逐渐往上推进。
比如:
parse_value()
↑
normalize()
↑
transform()
↑
process()
先 typing:
def parse_value(raw: str) –> int:
...
然后:
def normalize(data: RawData) –> NormalizedData:
...
再继续向上。
因此最有效的方式通常是:
Public API 决定优先级
Leaf Function 决定实施路径
这比简单说“从底层开始”或“从接口开始”都更接近真实大型项目。
四、第一阶段:只要求函数边界,不追求内部变量全 annotation
假设原来是:
def process(data):
result = {}
for item in data:
...
return result
第一步不要写成:
def process(
data: list[dict[str, Union[str, int, float, None]]]
) –> dict[str, list[tuple[int, str | None]]]:
这种类型不仅难维护,还说明领域模型本身没有被提炼。
更合理的是先定义语义类型。
例如:
from dataclasses import dataclass
@dataclass
class InputRecord:
user_id: int
action: str
score: float
@dataclass
class ProcessResult:
processed: int
failed: int
def process(data: list[InputRecord]) –> ProcessResult:
...
第一阶段关注的是:
函数接收什么?
函数返回什么?
而不是强迫所有局部变量都显式 annotation。
Python 类型推导本身已经可以处理:
count = 0
users = []
mapping = {}
真正值得投入精力的是 public boundary。
五、第二阶段:把“万能 dict”赶出核心业务
大型动态 Python 项目里,最大的 typing 敌人通常不是没有 annotation。
而是:
dict
尤其是这种代码:
def create_order(data: dict):
user_id = data["user_id"]
price = data["price"]
products = data["products"]
或者稍微“typed”一点:
def create_order(data: dict[str, Any]) –> dict[str, Any]:
...
看似已经有类型,实际上没有提供多少帮助。
这时候可以根据业务场景逐步引入:
TypedDict
dataclass
NamedTuple
Pydantic Model
Protocol
例如边界 JSON:
from typing import TypedDict
class CreateOrderPayload(TypedDict):
user_id: int
product_ids: list[int]
coupon_code: str | None
于是:
def create_order(data: CreateOrderPayload) –> Order:
...
如果是内部领域对象,则通常更适合:
from dataclasses import dataclass
@dataclass(frozen=True)
class OrderRequest:
user_id: int
product_ids: tuple[int, ...]
coupon_code: str | None
很多 typing 迁移最终会带来一个额外收益:
它会逼着项目把原本隐藏在 dict 中的领域模型显式化。
这往往比 annotation 本身更有价值。
六、第三阶段:控制 Any,而不是幻想消灭 Any
大型项目中完全消灭 Any 几乎是不现实的。
尤其是:
动态 JSON
第三方 SDK
插件系统
ORM
旧反射代码
动态 getattr
Monkey Patch
所以正确策略不是:
禁止 Any
而是:
限制 Any 的传播范围
把 Any 想象成一种“类型污染源”。
例如:
def load_plugin(name: str) –> Any:
...
然后:
plugin = load_plugin(name)
result = plugin.execute(data)
user = result["user"]
从第一行开始,后面整个调用链都失去了类型保护。
更好的做法是建立 quarantine layer:
from typing import Protocol
class Plugin(Protocol):
def execute(self, data: bytes) –> str:
...
def load_plugin(name: str) –> Plugin:
raw_plugin: Any = dynamic_load(name)
return raw_plugin
Any 被限制在:
dynamic_load()
附近。
对外暴露的是:
Plugin
于是污染不会继续扩散。
工程上可以制定这样一条规则:
Any 可以出现在系统边界,但尽量不要穿过业务层。
七、第三方库没有类型信息怎么办?
大型项目几乎一定会遇到:
Library stubs not installed
Skipping analyzing "xxx": module is installed, but missing library stubs
最糟糕的处理方式是:
ignore_missing_imports = True
然后全局开启。
因为这相当于把所有无类型依赖全部变成 Any。
更合理的处理优先级是:
第一选择:安装官方或社区 stub
很多库已经有:
pip install types-requests
pip install types-PyYAML
pip install django-stubs
如果存在成熟 stub,优先使用。
第二选择:寻找库自身的 py.typed
现代 Python 包可以通过 py.typed 表明自己包含类型信息。
如果库已经提供 typing,就不要再维护自己的 stub。
第三选择:自己写最小 stub
假设内部使用一个无类型 SDK:
payment.create(...)
payment.refund(...)
没有必要一次性为整个 SDK 写 .pyi。
只写项目真实使用的部分:
# stubs/payment_sdk/__init__.pyi
class Payment:
id: str
status: str
def create(
amount: int,
currency: str
) –> Payment: ...
def refund(payment_id: str) –> bool: ...
这种“最小充分 stub”非常适合大型遗留项目。
第四选择:Adapter
有些第三方库极度动态,与其和 stub 苦战,不如包一层:
class PaymentGateway:
def charge(
self,
user_id: UserId,
amount: Money
) –> PaymentResult:
...
内部:
raw = legacy_sdk.charge(...)
外部世界不需要知道 legacy SDK 是怎样设计的。
这是我更推荐的大型项目策略:
不要让第三方库的数据结构直接渗透整个业务系统。
typing 迁移经常是推动 Adapter Pattern 的绝佳机会。
八、Gradual Typing 怎样设置质量门槛?
真正成熟的 typing 改造不能只看:
mypy pass / fail
因为 80 万行代码不可能第一天全部 pass。
应该建立分层质量门槛。
例如:
Level 0
——-
旧代码允许 untyped
Level 1
——-
新函数必须有 annotation
Level 2
——-
禁止 implicit Any
Level 3
——-
禁止返回 Any
Level 4
——-
开启 Optional 严格检查
Level 5
——-
启用 strict
不同目录可以处于不同 level。
例如:
[mypy]
python_version = 3.12
[mypy-domain.*]
disallow_untyped_defs = True
disallow_incomplete_defs = True
check_untyped_defs = True
no_implicit_optional = True
warn_return_any = True
[mypy-services.*]
disallow_untyped_defs = True
check_untyped_defs = True
[mypy-legacy.*]
check_untyped_defs = False
于是整个项目不是一个:
strict / non-strict
二元状态。
而是一张逐渐推进的 typing maturity map。
九、CI 最关键的原则:只允许质量向前,不允许倒退
80 万行代码迁移最实用的 CI 思路不是:
mypy .
然后要求零错误。
而是:
Baseline + No New Errors
假设现在项目有:
14,832 typing errors
不要第一天修完。
记录 baseline。
以后每个 PR:
允许:
14832 → 14821
禁止:
14832 → 14840
也就是说:
existing debt is tolerated
new debt is rejected
这种机制对遗留系统极其有效。
因为它把一个无法完成的任务:
修完 14832 个错误
变成:
今天不要增加第 14833 个错误
团队心理负担完全不同。
十、比 Error Baseline 更好的方式:按文件提高严格程度
长期来看,我更喜欢“typed island”策略。
例如:
domain/
strict
new_services/
strict
services/
medium
legacy/
loose
当一个模块完成迁移,就宣布:
这个目录从今天开始 strict。
然后 CI 永远不允许它退回去。
迁移过程可能是:
Month 1
domain/users strict
其他 loose
然后:
Month 3
domain/users strict
domain/orders strict
utils strict
services medium
legacy loose
一年以后可能变成:
60% strict
30% medium
10% legacy
这比整个仓库永远停留在一个非常宽松的全局配置健康得多。
十一、CI Strictness 应该如何逐渐提高?
可以设计五个阶段。
Stage 1:保证 typed 文件能被检查
check_untyped_defs = True
这是很好的起点。
即使函数没有 annotation:
def calculate(a, b):
return a + b
mypy 仍然会检查函数体中的部分错误。
Stage 2:新模块禁止无类型函数
disallow_untyped_defs = True
于是:
def process(data):
...
不再允许出现在新的 strict package 中。
必须写:
def process(data: InputData) –> ProcessResult:
...
Stage 3:禁止 incomplete typing
防止出现:
def process(data: InputData):
...
只有参数类型,没有返回类型。
开启:
disallow_incomplete_defs = True
Stage 4:围剿 Any
逐渐加入:
warn_return_any = True
disallow_any_generics = True
例如:
items: list
应该升级成:
items: list[Item]
Stage 5:进入 strict
最终成熟模块可以:
strict = True
但请注意:
strict 应该是迁移终点,不应该是迁移起点。
在大型遗留代码库中第一天执行 strict,通常不是工程严谨,而是工程冒进。
十二、PR 应该采用“触碰即改善”的规则
历史代码最大的问题是:
谁来补?
如果成立一个 10 人 typing 小组专门修改 80 万行代码,效果通常不会特别理想。
因为真正理解代码的人,还是日常维护这些模块的开发者。
更好的制度是:
Boy Scout Rule
也就是:
离开一块代码时,让它比你进来时更干净一点。
例如开发者修改:
def calculate_price(data):
...
本来只是修一个 bug。
团队可以要求:
如果工作量合理,同时补上这个函数的输入输出类型。
变成:
def calculate_price(
data: PricingInput
) –> Money:
...
不用一次迁移整个文件。
这样 typing 会跟着真实业务开发自然扩散。
十三、不要为了 typing 制造“类型体操”
迁移过程中最容易出现另一个极端。
开发者为了让 mypy 闭嘴,开始写:
result = cast(
dict[str, list[tuple[int, str | None]]],
value,
)
甚至到处:
# type: ignore
如果一个类型已经复杂到无法阅读,通常需要停下来问:
是类型系统太麻烦,还是我们的数据模型本身太混乱?
例如:
dict[
str,
list[
tuple[
int,
str | None,
dict[str, Any],
]
]
]
通常更应该改成:
@dataclass
class UserEvent:
user_id: int
action: str | None
metadata: EventMetadata
typing 最大的隐藏价值之一,就是帮助你识别:
Primitive Obsession
巨大 dict
隐式协议
模糊接口
跨层耦合
如果 annotation 难写,很多时候是在暴露设计债务。
十四、# type: ignore 可以用,但必须有纪律
大型迁移过程中完全禁止:
# type: ignore
并不现实。
真正危险的是:
value = something() # type: ignore
因为半年后没人知道为什么 ignore。
更好的做法:
value = something() # type: ignore[arg-type]
并配合明确原因:
# SDK stub declares bytes, but runtime also supports memoryview.
value = sdk.send(buffer) # type: ignore[arg-type]
还可以在 CI 中启用:
warn_unused_ignores = True
当未来库的 typing 被修复后,无用 ignore 会自动暴露出来。
把:
type: ignore
当作“受控债务”,而不是垃圾桶。
十五、一个可落地的 12 个月迁移路线
假设你真的负责一个 80 万行 Python 项目,可以采用类似下面的节奏。
第 1~2 月:基础设施
完成:
选择 mypy / Pyright
统一 Python version
配置 pre-commit
建立 CI
统计当前 error baseline
制定 typing style guide
同时规定:
新增模块必须 typed。
第 3~4 月:核心边界
优先处理:
domain model
public API
service interface
repository interface
RPC / HTTP adapter
不要急着补所有工具函数。
先让模块之间能够“说清楚自己传的是什么”。
第 5~7 月:核心业务 strict 化
选择最重要的 3~5 个 package:
orders
payments
users
inventory
permissions
逐个达到:
strict = True
不要同时铺开几十个目录。
建立几个高质量 typed island。
第 8~10 月:控制 Any 与第三方边界
重点处理:
无类型 SDK
JSON dict
动态 ORM
插件系统
legacy adapter
通过:
Protocol
TypedDict
Adapter
stub
wrapper
阻止 Any 向核心业务传播。
第 11~12 月:扩大 strict 区域
开始提高 CI 门槛:
新增 package → strict
核心修改文件 → strict
legacy package → 至少 typed defs
此时 typing 不再是一项专项工程。
它已经成为正常开发流程的一部分。
这才意味着迁移真正成功。
十六、团队应该跟踪哪些指标?
不要只看:
annotation coverage
更有价值的指标包括:
strict package 数量
untyped function 数量
Any 数量
type: ignore 数量
CI typing errors 数量
无类型第三方 dependency 数量
例如每月:
Jan Jun Dec
Strict packages 3 18 46
Untyped funcs 8200 5100 2300
Any occurrences 4300 3100 1700
type: ignore 920 730 510
typing errors 14832 7200 2800
这个趋势比一句:
项目 typing coverage 87%
更能够反映真实质量。
十七、最终架构:让类型成为模块之间的“契约”
迁移几年以后,一个成熟 Python 项目的理想状态并不是:
每一个局部变量都有 annotation
而是:
External World
│
▼
Runtime Validation
│
▼
┌────────────────────────────────┐
│ Typed Boundary │
│ HTTP / RPC / Queue / DB │
└────────────────────────────────┘
│
▼
┌────────────────────────────────┐
│ Strict Domain / Service Layer │
│ NewType / dataclass / Protocol │
└────────────────────────────────┘
│
▼
┌────────────────────────────────┐
│ Typed Infrastructure Adapter │
└────────────────────────────────┘
│
▼
Legacy / Dynamic
implementation
真正重要的是:
动态性应该被关在明确的边界里,而不是在整个代码库中自由传播。
这就是大型 Python 系统使用 typing 时最核心的工程思想。
十八、四个追问,最终答案是什么?
回到最开始的几个问题。
从 Public API 还是内部 leaf function 开始?
答案是:
优先级从 Public API 和高价值边界确定,实现时沿调用链从 leaf function 向上推进。
不要只选其中一个。
Gradual Typing 怎样设置质量门槛?
不要要求整个项目一次 strict。
采用:
旧债允许存在
新增债务禁止
成熟模块升级 strict
Any 控制传播
形成不同 maturity level。
第三方库没有类型怎么办?
依次考虑:
官方 typing
社区 stub
自建最小 stub
Protocol
Adapter
最后才是局部 Any
千万不要简单全局:
ignore_missing_imports = True
然后当问题不存在。
CI 如何逐渐提高 strictness?
采用:
Baseline
↓
No New Errors
↓
New Code Typed
↓
Package-Level Strict
↓
Expand Strict Islands
↓
Repository Strict
CI 的使命不是第一天消灭历史,而是:
确保代码库永远不会比昨天更差。
结语:大型项目的 Typing 迁移,本质是治理,而不是补注解
我见过不少团队第一次引入 Python typing 时,把问题理解成:
给函数补上 : int 和 -> str
做了一段时间之后才发现,真正困难的并不是语法。
而是你不得不面对过去很多年一直被动态语言掩盖的问题:
这个 dict 到底包含什么?
这个 service 实际返回几种结构?
这个函数为什么有时返回 None?
这两个模块之间真正的契约是什么?
为什么一个第三方 SDK 的对象会穿过六层业务代码?
为什么 UserId 和 OrderId 到处都只是 int?
typing 像一盏非常亮的灯。
打开以后,代码原本存在的问题都会显现出来。
所以一个 80 万行 Python 项目的 typing 迁移,绝对不应该被定义为:
“半年内把 annotation 补完。”
真正合理的目标是建立一种长期机制:
今天类型安全的代码
比昨天更多一点;
今天新增的代码
不再复制昨天的技术债;
今天已经 strict 的模块
以后永远保持 strict。
半年之后,也许项目只有 35% 真正进入严格 typing。
这并不可怕。
只要:
35% → 40% → 48% → 60% → 75%
并且这个过程不会倒退,你就在建立一个越来越可靠的 Python 系统。
对于大型 Python 编程项目来说,这才是 Gradual Typing 最有价值的地方。
它允许团队在继续交付业务的同时,一点一点重新建立代码之间的契约。
不需要停工。
不需要“大爆炸式重构”。
也不需要幻想明天所有动态代码都会消失。
我们真正需要做的,只是让类型安全的边界一天比一天宽一点。
而当这种习惯最终进入代码评审、CI、架构设计和团队文化之后,typing 就不再是某个季度的专项任务。
它会成为这个项目开发方式本身的一部分。
这才是一次真正成功的 Python 类型迁移。


