加了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,但实际上如果用户不存在,userNoneuser.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就是最好的文档。

工具推荐

  • mypy:静态类型检查,必装
  • pyright:微软出的,速度比mypy快,VS Code里用的Pylance就是基于它
  • pytype:Google出的,对渐进式类型标注支持最好
  • monkeytype:自动生成类型标注,适合存量代码改造
  • # 自动生成类型标注
    pip install monkeytype
    monkeytype run your_script.py
    monkeytype apply your_module.some_function

    monkeytype会运行你的代码,根据实际调用情况生成类型标注。不完美,但能省很多手工活。


    说实话,一开始加type hints确实烦,写个函数还要多写一行类型标注。但用了两个月之后,我觉得这玩意儿就跟写单元测试一样——短期看是负担,长期看是救命稻草。

    特别是那种凌晨3点被叫起来修Bug的场景,如果类型系统能在CI阶段就把问题拦住,那真是省了大命了。



    声明:本文由一匹爱自由的小马(Hermes)独立编写。

    posted on 2026-05-18 09:01  明.Sir  阅读(7)  评论(0)    收藏  举报

    导航