bigywater.tech
Python · 装饰器

@functools.wraps 不加会怎样:一个插件注册表被覆盖的例子

网上大多数讲 wraps 的文章止步于"不加它 __name__ 会变成 wrapper、文档字符串会丢",听起来像个无伤大雅的小瑕疵。搭一个会在生产代码里出现的场景——用函数名当 key 的插件/路由注册表——看看这个"小瑕疵"具体能捅出多大的篓子。

2 个 → 1 个
注册两个不同的处理函数,不加 wraps 时注册表缩水成 1 个,另一个被悄悄覆盖
调错函数
调用注册表里仅存的那个 key,执行的其实是后注册的那个函数,不报任何错

思路:装饰器为什么会把函数名"吃掉"

这不是 bug,是闭包写法必然的结果——返回的那个对象本来就叫 wrapper。

最常见的装饰器写法是这样:外层函数接收原函数 func,内部定义一个 wrapper 函数包一层逻辑,再把 wrapper 返回出去替代原函数。

最朴素的装饰器写法(还没加 wraps) def logged(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper @logged def greet(name): """打个招呼""" return f"hello {name}"

调用 greet(...) 时,你真正调用到的对象其实是 wrapper,只是它内部又转手调用了原来的 func。Python 里函数是普通对象,它的 __name__/__doc__ 这些属性就是定义时那一刻的身份证——wrapper 定义的时候确实叫 wrapper,也确实没写文档字符串,这些属性如实反映了它自己,而不是它转手调用的那个 func。functools.wraps 要做的事,就是把原函数的身份信息"移植"到这个新对象上。

验证:不加 wraps,到底丢了什么

同一个函数,分别用加了 wraps 和没加 wraps 的装饰器包一遍,对比它暴露出来的元信息。

真实实测
=== 不加 wraps ===
__name__:   wrapper
__doc__:    None
__module__: __main__

=== 加了 wraps ===
__name__:   greet2
__doc__:    打个招呼
__module__: __main__
__wrapped__: <function greet2 at 0x1099c6770>
加了 wraps 之后,greet2 对外暴露的身份信息跟原函数完全一致,而且多了一个指向原函数的 __wrapped__ 引用

不加 wraps 那组,help(greet)、IDE 悬浮提示、调试器里看到的函数名和文档字符串全都是错的——你以为看的是 greet 的信息,实际看到的是 wrapper 这个匿名壳子的信息,业务函数本来的说明完全丢了。

踩坑:插件注册表丢了一个函数

"文档字符串丢了"看起来无伤大雅,但如果有代码依赖 __name__ 做逻辑判断,后果就不是丢文档这么简单了。

很多框架的注册模式长这样:装饰器在包一层逻辑的同时,顺手把函数注册进一个字典,用 __name__ 当 key(路由表、事件处理器、插件系统里很常见的写法)。写两版对比:

registry_demo.py registry_bad = {} registry_good = {} def register_bad(func): def wrapper(*a, **kw): return func(*a, **kw) registry_bad[wrapper.__name__] = wrapper # 拿 __name__ 当 key return wrapper def register_good(func): @functools.wraps(func) def wrapper(*a, **kw): return func(*a, **kw) registry_good[wrapper.__name__] = wrapper return wrapper @register_bad def handle_login(req): return "login ok" @register_bad def handle_logout(req): return "logout ok" @register_good def handle_login2(req): return "login ok" @register_good def handle_logout2(req): return "logout ok"
真实实测
registry_bad keys : ['wrapper']
registry_bad size : 1
调用 registry_bad['wrapper']("x") 的结果: logout ok

registry_good keys: ['handle_login2', 'handle_logout2']
registry_good size: 2
registry_bad 只剩 1 个 key——handle_login 注册进去之后,被同名的 handle_logout 直接顶替,没有任何报错或警告 registry_good 正常保留了 2 个不同的 key,一个不少

两个函数都叫 wrapper,字典按 key 赋值,后注册的 handle_logout 原地覆盖了先注册的 handle_login。整个过程 Python 不会报任何错——字典允许同 key 覆盖是完全合法的操作,程序表面上一切正常,只是"登录"这个功能在注册表里已经悄悄不存在了,调用的时候实际执行的是"登出"的逻辑。这种 bug 最难受的地方在于:它不在装饰器定义的地方暴露,而是在业务代码某个完全不相关的地方,以一种看似正常但结果错误的方式出现。

wraps 不只是保 __name__:inspect.signature 也跟着错

很多人以为 wraps 只是"面子工程"(保住名字和文档),实际上它连参数签名的自省结果都会改变。

真实实测
不加 wraps: inspect.signature(greet)  -> (*args, **kwargs)
加了 wraps: inspect.signature(greet2) -> (name)

不加 wraps 时,inspect.signature 拿到的是 wrapper(*args, **kwargs) 这个壳子的签名——技术上没说错,但完全没用,因为它掩盖了原函数真正需要哪些参数。加了 wraps 之后,inspect.signature 会顺着 __wrapped__ 找到原函数,还原出的 (name)。这个细节不只是给人看着舒服:命令行框架(比如 Click)、Web 框架的参数校验、IDE 的参数提示,不少都是靠 inspect.signature 在运行时反推函数需要什么参数——签名读错了,这些工具的行为会直接跟着错。

wraps 到底复制了哪些东西

不是靠魔法,就是把几个固定的属性名从原函数搬到新函数上而已。

真实实测(本机 Python 3.14.7)
functools.WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__qualname__',
                                  '__doc__', '__annotate__', '__type_params__')
functools.WRAPPER_UPDATES = ('__dict__',)

WRAPPER_ASSIGNMENTS 里列的这几个属性,原样从 func 拷贝一份盖到 wrapper 上;WRAPPER_UPDATES 里的 __dict__ 是"合并"而不是覆盖(比如原函数自己挂过额外属性,不会被冲掉)。除此之外,wraps 还会单独设置一个 __wrapped__ 属性指回原函数,这个是专门给 inspect 模块自省用的,不在上面两个列表里。

本机是 Python 3.14,列表里的 __annotate__/__type_params__ 是比较新的属性(取代旧版本里的 __annotations__,对应 PEP 649 延迟求值注解和 PEP 695 泛型语法)——wraps 具体搬哪些属性,是跟着 Python 版本走的,不是一份亘古不变的清单。

多层装饰器叠加:__wrapped__ 只指向下一层,不是原函数

容易想当然地以为 __wrapped__ 直接指向最初定义的那个函数,验证发现它只指向紧挨着的下一层。

两层装饰器叠加 @dec_a @dec_b def core(x): """核心函数""" return x * 2
真实实测
layer2 = dec_a(dec_b(original))

layer2 is original?                       False
layer2.__wrapped__ is layer1(dec_b 包过的那层)?   True
layer2.__wrapped__ is original?           False
layer2.__wrapped__.__wrapped__ is original? True

layer2.__wrapped__ 指向的是 dec_b 包出来的那一层,不是最原始的 original——要拿到最底层,得再往下取一次 .__wrapped__,或者不想自己管有几层嵌套的话,直接用标准库的 inspect.unwrap(),它会顺着 __wrapped__ 链条自动走到底(遇到循环引用会主动报错,不会死循环)。装饰器叠得越多层,这条链条就越长,手动一层层 .__wrapped__ 剥洋葱很容易漏,交给 inspect.unwrap() 更稳。

参考与说明

  • 本文所有对比结果均在本机 Python 3.14.7 运行得出,代码可以直接复制到本地复现。
  • functools.WRAPPER_ASSIGNMENTS/WRAPPER_UPDATES 的具体内容会随 Python 版本变化,文中给出的是当前版本的值,不代表所有版本都一样。
  • 没有涉及:functools.partial 相关的元信息处理、类装饰器(装饰的是类而不是函数)、以及 C 扩展实现的可调用对象上 wraps 的行为差异。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号