小Cの已经记不起来的博客

Python 类型提示在大型项目中提升重构安全性的实际用法

大型项目里最怕的不是代码多,而是改一处不知道会崩哪里。尤其是那种跑了三四年的 Python 服务,函数参数叫 data、config、options 的满地都是,返回值一会儿是 dict,一会儿是对象,一会儿又变成 None。平时不重构还能凑合跑,一旦要动公共模块,心里就开始没底。

我这两年在一个不算小的后端项目里逐步补类型提示,最大的感受不是“代码变优雅了”,而是重构时终于能提前发现一批低级但致命的错误。下面这些用法不是教科书式的类型大全,而是实际项目中确实帮我减少过事故的做法。

先把类型检查塞进 CI,别指望自觉

类型提示如果没有检查器兜底,很容易变成注释。人一忙,新代码就懒得标,旧代码更没人碰。所以第一步不是全量加类型,而是先把 pyright 或 mypy 跑起来,哪怕一开始只检查新增目录。

我们用的是 pyright,配置大概这样:

[tool.pyright]
include = ["app"]
exclude = ["app/legacy", "app/vendor"]
typeCheckingMode = "basic"
reportMissingImports = "warning"
reportAttributeAccessIssue = "warning"

注意不要一上来就 strict。大型项目直接 strict 的结果通常是几千条报错,然后大家集体关掉检查。比较现实的做法是:新代码目录严格一点,老代码先排除,等某个模块要重构时再把它从 exclude 里捞出来慢慢修。

CI 里加一行:

pyright

这一步本身不提升重构安全性,但它让类型提示从“可写可不写”变成了“提交前至少别新增明显错误”。

从“会变的地方”开始加类型

类型提示最值钱的地方,不是给稳定代码写说明书,而是给经常改动的边界加约束。

比如订单状态这种东西,早期大家到处写字符串:

if order.status == "paid":
    ...

后来状态越来越多,从 paid 到 partially_refunded,再到 refund_pending,靠 grep 找字符串早晚漏。改成 Literal 之后,重构时至少能定位:

from typing import Literal

OrderStatus = Literal[
    "created",
    "paid",
    "partially_refunded",
    "refund_pending",
    "closed",
]

def can_ship(status: OrderStatus) -> bool:
    return status in {"paid", "partially_refunded"}

如果哪天产品说 partially_refunded 不能发货了,改动 can_ship 的时候,检查器会帮你看住调用方传进来的值有没有拼错。它不能替你判断业务,但能挡住 "partial_refunded" 这种少个 ly 的事故。

用 TypedDict 把配置和 JSON 钉住

大型项目里最容易炸的地方之一是配置。YAML、JSON、接口返回值都长得像 dict,写的时候很爽,重构的时候很慌。

以前我们有这种函数:

def build_client(config: dict) -> Client:
    return Client(
        host=config["host"],
        port=config["port"],
        timeout=config.get("timeout", 10),
    )

后来配置结构改了,host 被拆成 endpoint 和 region,这个函数在运行时才报 KeyError。用 TypedDict 之后,问题会提前暴露:

from typing import TypedDict

class ClientConfig(TypedDict):
    host: str
    port: int
    timeout: int

def build_client(config: ClientConfig) -> Client:
    return Client(
        host=config["host"],
        port=config["port"],
        timeout=config["timeout"],
    )

重构时如果调用方还在传旧字段,类型检查会直接报错。比等到测试环境启动失败强得多。

Protocol 让替换实现不心虚

大型项目经常有“先写一个实现,后面再换”的情况。比如通知服务:

def send_alert(sender, message: str) -> None:
    sender.send(message)

这里的 sender 一开始没类型,谁都能传。后来你想把邮件通知换成企业微信通知,就会担心新对象到底有没有 send,参数是不是同一个意思。

用 Protocol 可以只描述你需要的行为,不强迫实现方继承某个基类:

from typing import Protocol

class MessageSender(Protocol):
    def send(self, message: str) -> None: ...

def notify(sender: MessageSender, message: str) -> None:
    sender.send(message)

这样重构时,你把 EmailSender 换成 WeComSender,检查器会确认新类是否满足 send(self, message: str) -> None。它不关心你内部怎么实现,只关心边界契约。对大型项目来说,这种低耦合的约束比强行抽一个抽象基类好用得多。

Optional 和 TypeGuard 把 None 检查变成硬约束

None 是 Python 重构里的老演员。一个函数以前永远返回对象,后来某个分支可能返回 None,调用方没改,线上就 AttributeError。

类型提示不能自动修 None,但能逼你在改签名时正视它:

def find_user(user_id: int) -> User | None:
    ...

一旦返回值标成 User | None,所有调用点都会开始提醒你处理 None。如果某些调用点前面已经写了判断,但又抽成了函数,可以用 TypeGuard 让检查器理解你的校验逻辑:

from typing import TypeGuard

def is_valid_user(user: User | None) -> TypeGuard[User]:
    return user is not None and user.active

后面再用 if is_valid_user(user):,检查器就知道这个分支里 user 是 User。这比到处写 cast(User, user) 干净,也更安全。

Any 要有边界,cast 要留证据

大型项目不可能没有 Any。第三方库没 stub、老代码动态属性、序列化结果不确定,这些都会逼你用 Any。问题不是用 Any,而是让 Any 到处传染。

我们现在的习惯是:Any 尽量只出现在最外层适配代码里,进到业务层之前转成明确类型。比如接口返回:

from typing import Any, cast

def parse_payload(raw: dict[str, Any]) -> OrderPayload:
    return cast(OrderPayload, raw["order"])

cast 不是运行时检查,它只是告诉检查器“这里我负责”。所以用 cast 的地方要尽量少,最好紧挨着校验逻辑。否则重构时它就是个坑,看起来类型对了,运行时该炸还是炸。

最后一点现实感受

类型提示在大型项目里不是银弹,它挡不住业务逻辑错误,也救不了设计烂到根上的模块。但它确实能把“改函数签名后哪些地方会受影响”这件事从靠记忆和 grep,变成靠检查器列清单。

我的建议是别追求一次性 100% 覆盖,那样很容易烂尾。先从公共模块、配置边界、状态枚举、外部接口这些“改了容易出事”的地方开始,配上 CI 做增量检查。等某天你改了一个返回值,提交前 pyright 把三个漏改的调用点拍在你脸上,你就会觉得这玩意儿还是有点用的。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放