上个月排查一个问题,花了整整一个下午。
起因很简单:我写了一个装饰器用来记录函数执行耗时,用起来挺顺手。后来项目里某个接口响应越来越慢,我加了一行日志准备追踪是哪个函数拖了后腿。结果日志打出来一看——
调用的是 wrapper ,耗时 1.2 秒。
哪个 wrapper ?项目里少说几十个装饰器,每个内部都有一个叫 wrapper 的函数。我盯着日志看了半天,完全不知道是哪个函数。
更离谱的是,我用了 inspect.signature() 去看那个函数的参数,返回的是 (*args, **kwargs) ,而不是我原始定义的参数列表。IDE 的自动补全也失效了, help() 出来的文档是一片空白。
那一刻我才真正意识到,问题出在一个我从来懒得加的东西上—— @functools.wraps 。
先说说装饰器到底干了什么
要理解 @wraps 为什么重要,得先看清装饰器的本质。
装饰器做的事情其实很直接:把原函数替换掉。
def log_decorator(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_decorator
def add(a, b):
"""Return the sum of a and b."""
return a + b
当你写 @log_decorator 的时候,Python 实际执行的是 add = log_decorator(add) 。换句话说, add 这个名字不再指向原来的 add 函数了,而是指向装饰器内部定义的 wrapper 函数。
原函数被“藏”在了 wrapper 的闭包里。从外部看, add 就是一个叫 wrapper 的东西。
这时候你去看 add.__name__ ,得到的是 'wrapper' ,不是 'add' 。 add.__doc__ 是 None ,原来那句 """Return the sum of a and b.""" 消失了。
这不是“看起来不好看”的问题。调试器里显示 wrapper ,你根本不知道断点打在了哪个函数上; pytest 跑出来的测试报告写的是 test_foo.py::wrapper ,你找不到对应的源码位置; help(add) 出来的是一段空白文档。
更隐蔽的是类型检查工具。 mypy 看到 add 的签名是 (*args, **kwargs) ,完全不知道它应该接收两个参数。如果你在代码里写了 add("hello") , mypy 不会报错,但运行时会炸。因为类型信息丢了。
FastAPI 用户对这个坑应该特别有体会。如果你在路由函数上套了装饰器但没加 @wraps ,FastAPI 拿到的 __name__ 是 'wrapper' ,Swagger 文档里所有接口的名字都变成了 wrapper ,根本分不清谁是谁。
@wraps 做的事情其实很朴素
加上 @wraps(func) 之后,事情就变了。
from functools import wraps
def log_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
再执行 add(2, 3) , add.__name__ 是 'add' , add.__doc__ 是 """Return the sum of a and b.""" , inspect.signature(add) 返回 (a, b) 。
它不是魔法。 @wraps 做的事情就是把你原函数身上那些“身份信息”复制到 wrapper 身上。
具体复制了哪些东西?Python 的 functools 模块里定义了一个列表叫 WRAPPER_ASSIGNMENTS ,默认包含 __module__ 、 __name__ 、 __qualname__ 、 __doc__ 和 __annotations__ 。 @wraps 会把这些属性的值从原函数搬到包装函数上。
它还会更新 wrapper.__dict__ ,确保你在原函数上自定义的属性也能被保留下来。
还有一个容易被忽略的细节: @wraps 会给包装函数加上一个 __wrapped__ 属性,指向原函数。这个东西在调试时特别好用——你可以在运行时通过 add.__wrapped__ 拿到装饰器里面那个真正的 add 函数。
inspect.signature() 之所以能拿到正确签名,正是因为它在默认情况下会沿着 __wrapped__ 去找原始函数。所以你看到的是 (a, b) ,而不是 (*args, **kwargs) 。
不加 @wraps,代价是什么
我用一个最小例子来展示区别。
from functools import wraps
def without_wraps(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def with_wraps(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@without_wraps
def fetch_user(user_id: int) -> dict:
"""Fetch a user by ID."""
return {"id": user_id}
@with_wraps
def fetch_order(order_id: int) -> dict:
"""Fetch an order by ID."""
return {"id": order_id}
看看两者有什么不同:
fetch_user.__name__ 是 'wrapper' , fetch_order.__name__ 是 'fetch_order' 。fetch_user.__doc__ 是 None , fetch_order.__doc__ 是 'Fetch an order by ID.' 。fetch_user.__annotations__ 是 {} , fetch_order.__annotations__ 是 {'user_id': int, 'return': dict} 。inspect.signature(fetch_user) 返回 (*args, **kwargs) , inspect.signature(fetch_order) 返回 (order_id: int) -> dict 。
这些差异不是“代码风格”层面的。它们在真实项目里会变成具体的故障:日志里全是 wrapper 的名字,你没法按函数名过滤; mypy 对 fetch_user(123, 456) 这种多传了参数的调用不会报错,因为签名是 (*args, **kwargs) ,什么都接受;单元测试里 self.assertEqual(fetch_user.__name__, "fetch_user") 直接失败。
这些问题有一个共同特征:它们不会在运行时报错。代码能跑,但工具链在静默地出错。你往往是在集成测试、CI 流水线或者线上日志排查时才发现不对劲,而那时已经绕了一大圈了。
@wraps 也有它管不了的事
我说了这么多 @wraps 的好话,但得补一句:它不是万能的。
@wraps 复制的是元信息,不改变包装函数的实际行为。最典型的例子是默认参数。假设你的原函数是 def greet(name, greeting="Hello") , @wraps 会让 inspect.signature 显示 (name, greeting='Hello') 。但如果你的包装器内部对 greeting 做了某种处理,实际运行时用的默认值可能和签名显示的不一致。
还有一个更微妙的场景:当你把 @wraps 用在异步包装器上时,某些框架可能会被 __wrapped__ 属性“骗到”。FastAPI 就踩过这个坑——它在判断一个 handler 是不是协程函数时,会沿着 __wrapped__ 追溯,结果把一个异步包装器背后其实是同步函数的 handler 误判成了同步的,直接报错。
PyTorch 也遇到过类似的问题。 @wraps 会把原函数 __dict__ 里的自定义属性一并复制,包括一些框架内部用来做特殊标记的字段。如果外层包装器恰好也用了同样的标记机制,就可能触发意料之外的短路行为。
所以 @wraps 的正确用法是:知道它在复制什么,以及为什么复制。它不是“加上就对了”的万能胶。
那到底什么时候可以不加
有一个判断标准比“要不要加”更实用:这个被装饰的函数,有没有任何外部工具或框架需要“认识”它?
如果你的装饰器只用在一个一次性的脚本里,这个函数不会被 help() 查看,不会被 inspect 分析,不会被类型检查工具扫描,也不会被任何框架注册为路由或任务——那不加确实没问题。
但只要你是在团队协作的项目里,只要你的代码会经过 CI、会生成文档、会被 IDE 分析、会出现在日志里——那就没有任何理由省略 @wraps 。它是一行代码的成本,解决的是整个工具链的兼容问题。
Python 官方文档和 python3-cookbook 对此的表述是一致的:“任何时候你定义装饰器的时候,都应该使用 functools 库中的 @wraps 装饰器来注解底层包装函数。”
这句话不是风格建议,是功能要求。
我现在写装饰器的习惯是: def 敲完包装函数的第一行,先写 @wraps(func) ,再写别的。不是为了好看,是为了下次看日志的时候,能一眼知道是谁在慢。