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 把三个漏改的调用点拍在你脸上,你就会觉得这玩意儿还是有点用的。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。