网上大多数讲 wraps 的文章止步于"不加它 __name__ 会变成 wrapper、文档字符串会丢",听起来像个无伤大雅的小瑕疵。搭一个会在生产代码里出现的场景——用函数名当 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 === __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 只是"面子工程"(保住名字和文档),实际上它连参数签名的自省结果都会改变。
不加 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 在运行时反推函数需要什么参数——签名读错了,这些工具的行为会直接跟着错。
不是靠魔法,就是把几个固定的属性名从原函数搬到新函数上而已。
functools.WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__qualname__',
'__doc__', '__annotate__', '__type_params__')
functools.WRAPPER_UPDATES = ('__dict__',)
WRAPPER_ASSIGNMENTS 里列的这几个属性,原样从 func 拷贝一份盖到 wrapper 上;WRAPPER_UPDATES 里的 __dict__ 是"合并"而不是覆盖(比如原函数自己挂过额外属性,不会被冲掉)。除此之外,wraps 还会单独设置一个 __wrapped__ 属性指回原函数,这个是专门给 inspect 模块自省用的,不在上面两个列表里。
容易想当然地以为 __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() 更稳。