欢迎光临
我们一直在努力

80 万行 Python 怎么逐步补类型?一套不停工的 Gradual Typing 迁移实战方案

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 类型迁移。

赞(0)
未经允许不得转载:171主机测评 » 80 万行 Python 怎么逐步补类型?一套不停工的 Gradual Typing 迁移实战方案
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址