Python 装饰器把函数"搞丢"了?5 个必踩的坑与完整避坑对照表
先给结论:装饰器最大的坑不是"不会写",而是"写完之后原函数被悄悄换掉了"——名字、文档、签名、甚至它的身份都可能不是原来那个函数。functools.wraps 能救回大部分,但它救不了全部(坑 4 和坑 5 它就无能为力)。
下面 5 个坑,按"踩中频率 × 排查难度"排序,每个都给复现代码和修法。
坑 1:函数名和文档字符串凭空消失
症状:用了装饰器之后,help() 看不到原函数的文档,logging 打出来的函数名全是 wrapper。
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"调用 {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_calls
def add(a, b):
"""返回两数之和"""
return a + b
print(add.__name__) # wrapper ← 不是 add!
print(add.__doc__) # None ← 文档没了
原因:@log_calls 等价于 add = log_calls(add)。变量 add 现在指向的是 wrapper 函数对象,原函数的元数据自然全丢了。
修法:加 @functools.wraps。
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"调用 {func.__name__}")
return func(*args, **kwargs)
return wrapper
print(add.__name__) # add
print(add.__doc__) # 返回两数之和
@wraps 会把原函数的 __name__、__doc__、__module__、__qualname__、__dict__ 复制到 wrapper 上,并设置 __wrapped__ 指向原函数。这是每个装饰器都该有的默认动作。
坑 2:以为 wraps 能让一切恢复原样(其实 signature 另说)
症状:加了 wraps 后,inspect.signature 拿到的还是 (*args, **kwargs),参数校验/依赖注入框架认不出你的参数。
import inspect, functools
def check(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@check
def mul(x: int, y: int = 2) -> int:
return x * y
print(inspect.signature(mul))
这里 wraps 设置了 __wrapped__,而 inspect.signature 默认 follow_wrapped=True,会顺着 __wrapped__ 找到原函数,所以正常情况会打印 (x: int, y: int = 2) -> int。但如果你显式传了 follow_wrapped=False,或者装饰器是手写赋值而没设 __wrapped__,就会退化成 `(*args, kwargs)`。**
修法:要么依赖 wraps 的 __wrapped__(推荐),要么在需要精确签名的地方显式 inspect.signature(func, follow_wrapped=False) 并自己处理。
坑 3:装饰器在导入时就执行了,不是调用时
症状:你以为注册/计时逻辑要等函数被调用才跑,结果 import 完就已经跑了一遍;Flask 路由注册顺序诡异;进程启动时就打了一堆日志。
REGISTRY = {
}
def register(func):
REGISTRY[func.__name__] = func # import 时就执行
return func
@register
def task(): pass
print(REGISTRY) # 模块刚 import 完,这里已经有内容了
原因:装饰器是模块加载阶段执行的——@register 在 def 定义完成的那一刻就被调用了,和函数体是否被执行无关。
修法:如果副作用必须延迟,把注册动作挪到 wrapper 内部(首次调用时执行),或者显式提供一个 init() 由调用方触发。记住这条判据:装饰器函数体的代码在 import 时跑,wrapper 函数体的代码在调用时跑。
坑 4:带参数的装饰器,少写一层
症状:想写 @retry(times=3),结果报错或者行为完全不对。
# 错误:只写了两层
def retry(times):
def wrapper(*args, **kwargs):
...
return wrapper # 返回的是 wrapper,但它接收的是原函数的位置参数
原因:带参数的装饰器是三层结构:最外层接收装饰器参数,中间层接收被装饰函数,最内层才是真正的包装。
import functools
def retry(times=3, exceptions=(Exception,)):
def decorator(func): # ← 第二层:收函数
@functools.wraps(func)
def wrapper(*args, **kwargs): # ← 第三层:收参数
for i in range(times):
try:
return func(*args, **kwargs)
except exceptions:
if i == times - 1:
raise
return wrapper
return decorator # ← 第一层返回 decorator
@retry(times=3)
def fetch(url): ...
记忆口诀:@retry 不带括号是两层,@retry(...) 带括号是三层。
坑 5:装饰器顺序,以及 lru_cache 的内存泄漏
症状 A:@staticmethod 和自定义装饰器叠在一起后,方法调用报参数个数不对。
class A:
@staticmethod
@log_calls
def f(x): return x
A.f(1) # 正常
class B:
@log_calls
@staticmethod
def f(x): return x
B.f(1) # 可能抛 TypeError:包装器吃掉/改变了绑定行为
修法:@staticmethod / @classmethod 永远放在最外层(最上面),让描述符协议最后生效。
症状 B:functools.lru_cache 装饰实例方法后,实例永远不被回收。
class Service:
@functools.lru_cache(maxsize=None)
def load(self, key): ...
原因:缓存的 key 里包含 self,缓存表强引用着所有调用过的实例,等于给每个实例钉了根钉子。另外 lru_cache 要求参数可哈希,传 list/dict 会直接 TypeError: unhashable type。
修法:实例方法别用 lru_cache(改用 functools.cached_property,或把缓存挂在实例上并手动管理生命周期);参数不可哈希时先转成 tuple/frozenset。
一张对照表收尾
| 现象 | 根因 | 修法 |
|---|---|---|
__name__ 变成 wrapper、文档丢失 |
装饰器返回了新函数对象 | @functools.wraps(func) |
| 框架读不到原参数签名 | 未设 __wrapped__,或 follow_wrapped=False |
依赖 wraps;或显式传 follow_wrapped |
| import 完副作用就跑了 | 装饰器体在加载时执行 | 副作用挪进 wrapper,或显式 init() |
@retry(times=3) 行为错乱 |
少写一层(应为三层) | 工厂 → decorator → wrapper |
| 方法调用参数个数错 | staticmethod 被包在里面 |
staticmethod 放最外层 |
| 实例不释放 | lru_cache key 持有 self |
实例方法不用 lru_cache |
unhashable type |
参数不可哈希 | 转 tuple/frozenset |
最后
"wraps 解决元数据,解决不了语义"——它能把名字和文档补回来,但补不回执行时机、参数绑定和内存生命周期。写装饰器时按这三问自查:① 加了 wraps 吗?② 副作用该在 import 时还是调用时?③ 有没有和描述符/缓存叠用?
你踩过最离谱的装饰器坑是哪个?评论区聊聊。