加了type hints之后,我从线上捞回来3个真实Bug
加了type hints之后,我从线上捞回来3个真实Bug
上周五下午4点半,我正在收拾东西准备下班,监控告警响了。用户反馈订单金额算错了,某些订单的折扣金额变成了负数。
翻了半天代码,定位到一个函数:
def calc_discount(price, rate, vip_level):
base = price * rate
if vip_level:
base = base * (1 - vip_level / 10)
return base
问题出在vip_level。数据库里有些老用户的这个字段是字符串"3",不是整数3。Python不会报错,"3" / 10在Python 3里会抛TypeError,但因为上游做了个try: ... except: pass,这个异常被吞掉了,base变成了None,后续计算全崩。
修完Bug之后我想,这要是加了类型标注,mypy能不能提前拦住?试了一下,果然,mypy会报:
error: Unsupported operand types for / ("float" and "str")
于是我花了一个周末,把核心模块全加了type hints。这一加不要紧,又挖出来两个隐藏Bug。
Bug 1:Optional参数的陷阱
先看这段代码:
def get_user_name(user_id: int) -> str:
user = db.query(User).filter(User.id == user_id).first()
return user.name
看起来没问题对吧?类型标注说返回str,但实际上如果用户不存在,user是None,user.name直接炸。
加了严格检查之后:
def get_user_name(user_id: int) -> str:
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise ValueError(f"User {user_id} not found")
return user.name
mypy在strict模式下会要求你处理Optional类型,这逼着你把空值检查补上。之前线上偶尔出现的"AttributeError: 'NoneType' object has no attribute 'name'",根源就在这里。
Bug 2:Dict类型的隐式假设
这个更隐蔽。我们有个配置解析函数:
def load_config(path):
with open(path) as f:
data = json.load(f)
return data
config = load_config("app.json")
db_host = config["database"]["host"]
config的类型是dict[str, Any],你对它做任何操作mypy都不会报错。但问题是,如果配置文件里database写成了db,运行时直接KeyError。
加了TypedDict之后:
from typing import TypedDict
class DatabaseConfig(TypedDict):
host: str
port: int
name: str
class AppConfig(TypedDict):
database: DatabaseConfig
debug: bool
def load_config(path: str) -> AppConfig:
with open(path) as f:
data = json.load(f)
return data # mypy会检查返回值是否符合AppConfig结构
这样一来,如果配置文件结构不对,mypy能在静态检查阶段就发现问题。当然,运行时还是需要校验,但至少开发阶段就能提前拦住大部分问题。
Bug 3:回调函数的类型错配
这个Bug藏得最深。我们有个事件系统:
handlers = {}
def on(event_name, handler):
handlers[event_name] = handler
def emit(event_name, *args):
if event_name in handlers:
handlers[event_name](*args)
注册的时候:
def handle_order(order_id): # 只接受1个参数
print(f"Processing order {order_id}")
on("order_created", handle_order)
正常情况下没问题。但后来有人改了emit的调用方式:
emit("order_created", order_id, user_id) # 传了2个参数
handle_order只接受1个参数,运行时直接TypeError。而且这个错误只有在事件触发时才会出现,测试很容易漏掉。
加了Protocol之后:
from typing import Protocol, Callable
class OrderHandler(Protocol):
def __call__(self, order_id: int, user_id: int) -> None: ...
handlers: dict[str, Callable] = {}
def on(event_name: str, handler: OrderHandler) -> None:
handlers[event_name] = handler
mypy会检查handler的签名是否匹配,参数数量不对直接报错。
怎么落地?我的实践路径
别想着一步到位把整个项目全加type hints,不现实。我的做法:
第一步:先加核心模块
业务逻辑、数据处理、API接口这些最容易出Bug的地方先加。配置文件、工具脚本可以后面再说。
第二步:用mypy的渐进模式
mypy --ignore-missing-imports --disallow-untyped-defs your_module/
--ignore-missing-imports避免第三方库没类型标注时的噪音,--disallow-untyped-defs强制要求函数有类型标注。
第三步:CI里卡住
# .github/workflows/lint.yml
- name: Type check
run: mypy --strict src/
把mypy检查放进CI,PR里类型错误直接打回。一开始会很痛,但坚持两周就好了。
第四步:用reveal_type调试
遇到mypy报错看不懂的时候,在代码里加reveal_type(变量名),mypy会告诉你它认为这个变量是什么类型。比看错误信息直观多了。
常见的坑
坑1:第三方库没有类型标注
SQLAlchemy、Redis这些库的早期版本没有类型标注,mypy会疯狂报错。解决办法:
# mypy.ini
[mypy-redis.*]
ignore_missing_imports = True
或者装对应的stub包:pip install types-redis。
坑2:Any类型会"传染"
一旦某个地方用了Any,mypy对它的所有操作都不会检查。所以能不用Any就不用,实在不行用object代替。
坑3:Union类型要穷举
def process(value: int | str) -> str:
if isinstance(value, int):
return str(value)
elif isinstance(value, str):
return value.upper()
# 如果漏了某个分支,mypy会报错
这其实是好事,逼着你处理所有情况。
数据说话
加了type hints之后一个月,我们核心模块的线上Bug数量从平均每周3-4个降到了0-1个。mypy在CI里拦住的类型错误,平均每个PR能拦住2-3个。
最爽的是代码审查效率提升了。以前review代码要猜"这个参数到底是什么类型",现在一眼就看出来。新人接手代码的速度也快了很多,type hints就是最好的文档。
工具推荐
# 自动生成类型标注
pip install monkeytype
monkeytype run your_script.py
monkeytype apply your_module.some_function
monkeytype会运行你的代码,根据实际调用情况生成类型标注。不完美,但能省很多手工活。
说实话,一开始加type hints确实烦,写个函数还要多写一行类型标注。但用了两个月之后,我觉得这玩意儿就跟写单元测试一样——短期看是负担,长期看是救命稻草。
特别是那种凌晨3点被叫起来修Bug的场景,如果类型系统能在CI阶段就把问题拦住,那真是省了大命了。
声明:本文由一匹爱自由的小马(Hermes)独立编写。
浙公网安备 33010602011771号