1. 从一次排查事故说起
先讲个我自己的故事。几年前在某项目组做接口层改造,用的 Python。某天线上报了一个 bug,定位到某个视图函数里抛了异常,但日志里记录的报错堆栈第一行是in wrapper而不是函数名。我下意识用函数名去 grep 代码,结果搜不到,因为代码里写的函数叫get_user_profile,而运行时的名称已经被替换成了wrapper。排查了三四个小时,最终发现是团队里有人写装饰器时没有保留原函数的元信息,所有被装饰过的接口全变成了同名的wrapper,那段时间线上排障效率极低。
这个问题的正解就是functools.wraps。
@wraps()是 Python 标准库functools模块中的一个装饰器工具,核心作用一句话总结:当你在写自定义装饰器时,它能把被装饰函数的名称、文档字符串、注解、模块等元信息“复制”到包装函数上,同时把原始函数挂到__wrapped__属性上。这样,装饰器包装后的函数在使用体验上“看起来像”原函数,调试、序列化、文档生成、IDE 提示都不会出幺蛾子。
这篇博文写给谁?两类人。一类是刚学装饰器、被各种嵌套搞晕的初学者,另一类是已经在用装饰器但没深究过@wraps内部机制、遇到脏元信息问题的进阶开发者。我会从装饰器本身的坑讲起,拆解@wraps的原理、源码级细节、真实项目中的正确姿势,以及我踩过的一些坑。
2. 装饰器为什么需要 @wraps
2.1 装饰器的本质是“替换”
很多人第一次学装饰器时,听到的一句话是“装饰器就是给函数增加功能”,但这是结果描述,不是机制描述。机制上,装饰器做的事情是你写了一个函数(比如my_decorator),它接收一个函数作为参数,返回一个新的函数,然后 Python 在定义阶段完成一次“名称重新绑定”。
def my_decorator(func): def wrapper(*args, **kwargs): print("before") result = func(*args, **kwargs) print("after") return result return wrapper @my_decorator def hello(): """返回问候语""" return "hello" # 上面完全等价于: # def hello(): # return "hello" # hello = my_decorator(hello)关键点就在最后一行:hello这个名字不再指向原来你编写的函数对象,而是指向了my_decorator内部创建的wrapper。原来的函数对象还在内存里,但丢了名字。这会产生一连串连锁反应。
2.2 丢了什么?——五个名字和一堆元数据
被替代后,最直观的变化是函数“自我介绍”时的信息变了:
print(hello.__name__) # wrapper print(hello.__doc__) # None我把封装前后的属性对比拉个表,这样更直观。每种属性对应一个丢失场景:
| 属性/行为 | 包装前(原函数) | 包装后(wrapper) | 实际影响 |
|---|---|---|---|
__name__ | hello | wrapper | 日志、报错堆栈、序列化时函数名错乱 |
__doc__ | 返回问候语 | None | 帮助文档、IDE 悬停提示失效 |
__annotations__ | 参数和返回值注解 | {} | 类型检查工具失效 |
__module__ | 函数所在模块名 | 当前模块名 | 部分场景下定位困难 |
__qualname__ | 带路径的限定名称 | 被覆盖 | 调试器可读性下降 |
__dict__ | 原函数上挂的扩展属性 | 通常是空 | 框架扩展点失效 |
| 签名(signature) | (name, age=18) | (*args, **kwargs) | IDE 提示、接口自省错误 |
前四个属性是硬伤,__name__变成wrapper是最普遍的现象,几乎只要写一个装饰器就会遇到。报错堆栈里满屏的wrapper会让人瞬间失去排查欲望。
2.3 丢失后的三大麻烦场景
丢失元信息不是“丑”那么简单,是实实在在的坑。我挑三个高频场景说。
第一个是日志和监控。线上系统打日志时,通常会把func.__name__记录进去,用于追踪“哪个函数慢”“哪个函数在报错”。如果装饰器没有保留名字,所有被装饰函数在日志里叫同一个名字,性能瓶颈定位无从谈起,异常聚合告警也会把不同函数合并成一条。
第二个是 IDE、文档生成器和自动测试。现代 IDE 靠函数签名做自动补全、参数提示;Sphinx 这类工具靠__doc__生成 API 文档;某些测试框架靠__name__来动态发现用例。一旦元信息被覆盖,程序员的一天就变成“看源码找参数”的一天。
第三个是序列化和缓存。有些框架要用函数对象作为缓存键,比如把func.__module__ + func.__qualname__拼成 key。没有正确元信息时,缓存 key 会碰撞,导致返回了别的函数的缓存结果,这是最难排查的一类 bug。
正是因为这些问题,Python 官方提供了functools.wraps,让你用一行代码、一个装饰器拯救全部信息。
2.4 顺带理解:为什么不是“手动赋值”
在没有@wraps之前,很多人也手动赋值,最常见的写法是在wrapper里逐个改:
def my_decorator(func): def wrapper(*args, **kwargs): ... wrapper.__name__ = func.__name__ wrapper.__doc__ = func.__doc__ wrapper.__module__ = func.__module__ wrapper.__qualname__ = func.__qualname__ wrapper.__annotations__ = func.__annotations__ return wrapper这种写法有两个问题。
第一个问题:容易漏。Python 的属性很多,今天写全了明天改需求又漏一个,而且从func上取属性时一旦属性不存在,还会抛AttributeError,处理起来很啰嗦。
第二个问题:__dict__没同步。假设原函数上挂了func.custom_meta = "v1",那wrapper上并没有这个属性,而 Python 的__dict__是函数挂载扩展属性的地方,不合并__dict__等于“函数本身上的自定义属性全部丢光”。
3. @wraps 的原理和源码细节
3.1 它不是魔法,是 copy 加贴标签
@wraps其实只是一个函数工厂,定义在functools模块里。它的完整定义(不同 Python 版本略有差异,但核心一致)拆解下来就三件事:
WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__qualname__', '__annotations__', '__doc__') WRAPPER_UPDATES = ('__dict__',) def update_wrapper(wrapper, wrapped, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES): for attr in assigned: try: value = getattr(wrapped, attr) except AttributeError: pass else: setattr(wrapper, attr, value) for attr in updated: getattr(wrapper, attr).update(getattr(wrapped, attr, {})) wrapper.__wrapped__ = wrapped return wrapper def wraps(wrapped, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES): def decorator(wrapper): return update_wrapper(wrapper, wrapped, assigned=assigned, updated=updated) return decorator逐行解读。
第一件事:从原函数wrapped上读取五个属性的值,赋给wrapper。读取时用了try/except AttributeError,防御了“原函数没有某个属性”的情况,不会因为缺属性导致崩溃。
第二件事:把原函数__dict__里的所有键值对,合并进wrapper.__dict__。注意这里是update,不是覆盖整个字典,所以wrapper已有的属性不会被冲掉,原函数的自定义属性会加进来。
第三件事:标记wrapper.__wrapped__ = wrapped。这一行极其关键,它相当于在包装上留了一条“通往原始函数”的线索。后面讲inspect.unwrap时会再用到它。
3.2 两个常量为什么这么设计
WRAPPER_ASSIGNMENTS是一个元组,里面是字符串属性名。这个设计意图是“只复制那些描述函数身份的信息”,不复制函数体、不复制默认参数、不复制__class__。也就是说,@wraps做的事是“贴标签”,而不是“克隆函数”。这符合装饰器的本意:你还是一个全新的函数,只是让外部看起来和原来一样。
WRAPPER_UPDATES里只有一个'__dict__'。原函数上挂的额外属性会被合并到包装函数上。这里有个细节:getattr(wrapper, attr).update(...)要求wrapper本身有一个__dict__,而函数对象天然都有,所以不会报错;但如果你包装的是一个特殊对象(比如某些实现了__call__的类实例,但实例没有__dict__),就得注意,这种情况我会在后面的“坑”里专门讲。
3.3 @wraps 和 @update_wrapper 的关系
很多人第一次看到functools.update_wrapper时容易混淆。我拆开说清楚:
update_wrapper是执行复制动作的函数,它接收wrapper、wrapped两个函数对象,直接改属性。wraps是基于update_wrapper的装饰器工厂,它接收原函数,返回一个装饰器。
所以实际场景中,如果你在写一个需要“手动”更新元信息的工具函数,可以直接调update_wrapper;如果你在写装饰器,用@wraps更标准。
我还会在写类装饰器时手动调用update_wrapper,因为类装饰器的包装对象不是函数,直接加@wraps时附着的目标变了,语义没那么直观。后面会有对应示例。
3.4 一个比喻:快递包裹和面单
把@wraps理解成“快递包装”特别好使。原函数是你真正要寄的东西,装饰器是快递员把东西塞进包装盒并封口。问题是,如果包装盒上不写姓名地址(元信息),收件人拿到一个完全认不出内容的盒子,就只能拆开看里面有什么。@wraps做的事就是把原包装(原函数)上的“面单”完整撕下来,贴到新包装上。盒子里装的东西变了,但面单说它是什么,它就是什么。
__wrapped__属性则是“快递单号可溯源”——你可以通过单号查到原始寄件人,拿到函数本体。这就是inspect.unwrap的工作机制。
4. 从入门到实战:@wraps 的正确使用姿势
4.1 朴素装饰器:一行 @wraps 解决
最基础的使用方式是在wrapper上面一行加@wraps(func):
import functools import time def timer(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() try: result = func(*args, **kwargs) finally: elapsed = time.perf_counter() - start print(f"{func.__name__} 耗时 {elapsed:.4f}s") return result return wrapper @timer def compute(a, b): """两数之和""" return a + b print(compute.__name__) # compute print(compute.__doc__) # 两数之和 print(compute(1, 2)) # 3,同时打印耗时注意写法:@wraps(func)里传的是被装饰的那个函数func,不是你自己写的wrapper。这一行写在wrapper定义之上,因为装饰器语法会把wrapper作为参数传给wraps(func)返回的装饰器。理解这一步,后面带参数的装饰器才会顺。
4.2 带参数的装饰器:@wraps 放在最内层
写一个带参数的装饰器时,函数嵌套层数会变成三层:外层接收参数,中间层接收函数,内层才是真正替代原函数的wrapper。@wraps必须放在最内层的wrapper上:
def retry(max_attempts=3, delay=1.0): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, max_attempts + 1): try: return func(*args, **kwargs) except Exception: if attempt == max_attempts: raise time.sleep(delay) return wrapper return decorator @retry(max_attempts=5, delay=2.0) def fetch_data(url): """拉取远程数据""" ...判断标准很简单:@wraps的语法糖只能修饰紧跟其后的那个函数定义。只要你看代码时发现“这个函数是真正执行原函数调用的”,那么它就是wrapper,@wraps(func)就挂在它头上。这个规则百试百灵。
4.3 类装饰器和call对象:注意 update_wrapper 的目标
类装饰器和类实现__call__是两类场景,它们的包装对象不是普通函数。
第一种,类装饰器。你有一个类,想用一个函数包住它的构造调用:
def singleton(cls): @functools.wraps(cls) def wrapper(*args, **kwargs): if not hasattr(cls, "_instance"): cls._instance = cls(*args, **kwargs) return cls._instance return wrapper @singleton class Config: """应用配置"""这种场景下@functools.wraps(cls)把类属性里能复制的__module__、__name__、__qualname__、__doc__复制给了wrapper函数。类对象有__name__和__doc__,所以wrapper.__name__会变成Config,文档字符串也会保留。调用Config()时,实际上调用的是wrapper,但它“自称”叫Config,排障时不会一头雾水。
第二种,类实例实现__call__,做成可调用对象。这是很多框架里“装饰器类”的实现方式:
class cached: def __init__(self, func): self.func = func self.cache = {} functools.update_wrapper(self, func) def __call__(self, *args, **kwargs): key = (args, tuple(sorted(kwargs.items()))) if key not in self.cache: self.cache[key] = self.func(*args, **kwargs) return self.cache[key] @cached def slow_add(a, b): """慢速加法""" import time time.sleep(1) return a + b print(slow_add.__name__) # slow_add这里用了update_wrapper而不是@wraps。原因是@wraps的默认WRAPPER_UPDATES = ('__dict__',)会执行getattr(wrapper, attr).update(...),但实例的属性存储是self.__dict__,不是函数的__dict__。如果对实例直接调用wraps,它可能会尝试访问一个不存在的函数式__dict__,行为不一致。手动用update_wrapper(self, func)并把updated设为空元组更安全:
functools.update_wrapper(self, func, updated=())这样“复制名字、文档、模块等标识信息”,但不同步__dict__,因为实例自身的状态我们也想保留。
4.4 解开包装:inspect.unwrap 和wrapped的回溯
@wraps在wrapper上设置了__wrapped__ = func,这给了我们一个反向操作手段。当需要从包装后的函数拿到原函数时,用inspect.unwrap:
import inspect def a(func): @functools.wraps(func) def inner(*args, **kwargs): return func(*args, **kwargs) return inner def b(func): @functools.wraps(func) def inner(*args, **kwargs): return func(*args, **kwargs) return inner # 多层装饰叠加 @a @b def original(): """原始函数""" ... # 拿到最里面的函数 print(original.__name__) # original(因为每层都用了 wraps) print(original.__wrapped__.__name__) # original unwrapped = inspect.unwrap(original) print(unwrapped is original) # Falseinspect.unwrap会沿着__wrapped__链一层层剥,直到某个函数没有该属性。这个机制在调试和框架底层很实用。比如某些测试框架用inspect.unwrap找到真正被装饰的函数,然后读取其参数注解来推导测试用例。
顺带提一句,多层装饰时如果不加@wraps,inspect.unwrap在第一层就断了,所以“每个装饰器都写 @wraps”是保证整条链路整洁的前提。
4.5 自定义要复制的属性和更新方式
wraps函数签名里还有两个可选参数,assigned和updated。默认值是前面说的两个元组。如果需要额外复制别的属性,可以传自定义元组:
def my_decorator(func): @functools.wraps(func, assigned=functools.WRAPPER_ASSIGNMENTS + ("my_flag",)) def wrapper(*args, **kwargs): ... return wrapper这个特性用到的场景不多,但确实存在。在我参与的一个项目里,团队约定所有接口函数必须挂一个api_version属性,用于接口兼容性识别。装饰器里光靠@wraps默认行为拿不到这个属性,就得显式加进assigned元组。
另一个思路是,不要刻意去复制自定义属性,而是依赖__dict__的update机制。因为WRAPPER_UPDATES里的__dict__更新会把原函数上所有自定义属性同步过来。如果你自定义的属性刚好在__dict__里(通常都在),就不用改assigned。只有原函数的属性是通过__slots__或者描述符实现时才需要特殊处理。
5. 核心实战:应用场景代码逐行分析
5.1 场景一:权限校验装饰器的工程化写法
权限校验是装饰器应用最多的场景之一。大多数项目里会写成“需要登录”“需要角色”“需要权限”三层,每一层都要@wraps。我写一个工程化示例:
import functools from typing import Callable, Any def require_permission(permission: str): def decorator(func: Callable) -> Callable: @functools.wraps(func) def wrapper(*args, **kwargs) -> Any: user = getattr(args[0], "current_user", None) if user is None: raise PermissionError("未登录") if not user.has_perm(permission): raise PermissionError(f"缺少权限: {permission}") return func(*args, **kwargs) return wrapper return decorator几个工程细节值得说。
getattr(args[0], "current_user", None)是为了兼容“绑定方法被装饰”的情形。绑定方法第一个参数是self,从self上取用户信息是常规做法。如果装饰器也用于普通函数,第一个参数没有current_user,会静默拿到None,同样会触发未登录异常,逻辑合理。
@functools.wraps(func)保证了这个装饰器包装后的方法在 IDE 里提示签名时,显示的是函数本身的参数名,而不是*args: Any, **kwargs: Any。我见过一个项目没用@wraps,结果所有接口的自动生成文档里,参数列表全部变成了*args、**kwargs,前端同学对接口调参全靠猜。
5.2 场景二:日志装饰器,别让日志失去函数名
日志装饰器看似简单,但最容易忽略@wraps带来的排障收益:
def trace_logger(logger): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): logger.info(f"调用 {func.__name__}, args={args}, kwargs={kwargs}") try: result = func(*args, **kwargs) except Exception as exc: logger.exception(f"{func.__name__} 执行异常: {exc}") raise else: logger.debug(f"{func.__name__} 返回 {result}") return result return wrapper return decorator注意,wrapper内部的日志行用的是func.__name__,而不是wrapper.__name__。虽然有了@wraps后两者相等,但func.__name__更保险,因为它在@wraps赋值之前就已存在。如果某一天你把@wraps误删了,wrapper.__name__会变成wrapper,日志里全是垃圾信息,而func.__name__依然正确。这是一个值得养成的编码习惯:在装饰器内部读函数信息,优先读参数绑定的func,不要依赖复制后的wrapper。
5.3 场景三:缓存装饰器与签名冲突
缓存装饰器容易和@wraps碰撞出的问题是:缓存的 key 设计。如果你用inspect.signature来生成 key,那@wraps因为不更新函数签名,所以inspect.signature(fib)显示的是(*args, **kwargs),而不是(n),这会让 key 生成逻辑出错——你以为拿到了n,结果拿到一个空args或全空的绑定。这就引出下一节的核心坑:@wraps到底改没改签名?
6. 五个高频坑和排查清单
6.1 坑一:@wraps 不改变函数签名,该不该算坑
这是最多人误解的地方。用inspect.signature去检查使用了@wraps的装饰器函数,会得到:
@timer def compute(a, b=10): ... print(inspect.signature(compute)) # (*args, **kwargs)原因在于@wraps复制的是__wrapped__之前列出的那几个属性,不包括__signature__,而且inspect.signature默认只看最终函数对象的__code__和__annotations__,不会自动跑到__wrapped__去。也就是说@wraps做了“身份伪装”,但没有做“签名伪装”。
解决方案很直接:给wrapper设置__signature__属性。标准写法:
def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) wrapper.__signature__ = inspect.signature(func) return wrapperinspect.signature会优先读取__signature__,这样外部工具就能拿到正确签名。在写供别人调用的装饰器库时,这一步建议加上。我踩过一次坑:写了一个带缓存的装饰器,内部用签名做缓存 key,签名显示成(*args, **kwargs)导致所有调用都命中同一个缓存,调试了很久才发现是签名问题。
6.2 坑二:多个装饰器叠加时 @wraps 的复制顺序
多个装饰器叠加时,装饰器的执行顺序是从下往上。这影响一个细节:如果最里层装饰器没有@wraps,外层装饰器的@wraps(func)拿到的是什么?
@outer @inner def f(): ...inner装饰后返回的是它的inner_wrapper;outer拿到的是inner_wrapper,@functools.wraps(inner_wrapper)复制的属性来自inner_wrapper,而不是原始f。如果inner_wrapper自己没有正确处理元信息,这些信息就可能是一层污染过的东西。所以最佳实践是:每个装饰器都要在各自的wrapper上加@wraps。这等于“接力赛,每一棒都规范交接”,最后一棒拿到的信息才是完整的。
6.3 坑三:类装饰器中 update_wrapper 和dict的兼容问题
前面提过,@wraps默认会同步__dict__。对一个类实例对象执行update_wrapper(self, func)时,如果updated=('__dict__',),会调用self.__dict__.update(func.__dict__)。这通常没问题,但有一个隐患:实例上已有的状态属性会被原函数上的同名扩展属性覆盖。
比如原函数挂了个func.cache_size = 10,而实例自己也用self.cache_size = 20做缓存控制,那么update_wrapper会覆盖它。解决方法是把updated替换成空元组,或者用一个独立属性名保存这些扩展属性。我的判断标准是:实例对象不参与扩展属性的同步,类装饰器的重点只放在复制__name__、__doc__这些标识信息上。
6.4 坑四:装饰器包装后序列化 pickle 失败
被装饰的函数如果直接pickle.dumps,Python 的序列化机制会按模块路径和函数名去查找,找不到wrapper就会报错。加上@wraps后,__module__和__qualname__被复制,pickle 就能按原函数的路径找到代码,但序列化的仍然不是你想象的那个对象。这个问题不存在完整解,因为wrapper本身是一个新的代码对象,在模块里没有与之对应的顶层名字。实际项目里,凡是需要做进程间传递回调函数的场景,我都不建议对一个被装饰函数直接pickle,应该用inspect.unwrap取出原函数再序列化,否则大概率翻车。
6.5 坑五:partial 与 wraps 混用时的属性来源
functools.partial对象也常与包装场景混在一起。partial 包装后的对象也有func属性,但没有__name__、__doc__。如果你写一个装饰器,对 partial 对象执行functools.wraps(partial_obj),赋值时会因缺少属性而跳过,不会报错,但__name__会保持原样,也就是空。要处理这个场景,可以抽取partial_obj.func上的属性,或者避免对 partial 直接做 wraps。一个常用的做法:
def my_decorator(func): if isinstance(func, functools.partial): wrapped = func.func else: wrapped = func @functools.wraps(wrapped) def wrapper(*args, **kwargs): ...6.6 排查清单:装饰器干了什么
写一个装饰器并怀疑元信息有问题时,我会按顺序检查四个点:
func.__name__是不是原函数的名称。inspect.signature(func)是否保留了参数名。inspect.unwrap(func)是否能找到原函数。- 用
help(func)看文档字符串是否正常。
其中任何一项失败,都说明装饰器的元信息处理不合格。实际项目中我把它做成一个单元测试模板:每个装饰器写一条用例,断言__name__、__doc__、__wrapped__三个属性。自动回归,防止有人不小心删掉@wraps。
7. 标准库与主流框架中的 @wraps 身影
@wraps不是小工具的命,它是 Python 标准库很多装饰器的地基。比如functools.lru_cache源码里就用了update_wrapper来保证被缓存函数保留原名和文档。再比如contextlib.contextmanager、asyncio的某些运行控制装饰器、unittest.mock里的 mock 对象包装,底层全部依赖这同一个机制。
框架层面,我实际接触过的有:某个 Web 框架的鉴权装饰器、某个 RPC 框架的注册装饰器、某个配置系统的属性装饰器。几乎每个 Python 框架源码里的装饰器,都能看到functools.wraps或者基于它的封装。这也侧面说明,装饰器如果不搭配wraps,在工程上很难活得久。
举一个非常直观的标准库例子:lru_cache的使用者们一定见过这个现象。缓存函数的__name__、__doc__都保持原样,等于它内部自动做了元信息保留。你不需要给lru_cache再套一层@wraps,因为它自己的实现已经做了这件事。标准库的规范对你的自定义装饰器有示范意义。
8. 关于 @wraps 的几个冷知识
8.1 为什么官方把几个属性做成“元组”而不是“集合”
WRAPPER_ASSIGNMENTS用的是元组,因为属性复制需要顺序和确定性。字符串、可迭代对象的顺序在 Python 里通常有保证,但元组写死了顺序,阅读源码时不会出现“字典遍历顺序导致赋值顺序不同”的疑问。虽然属性赋值顺序日常无感,但这些细节体现了标准库代码的严谨。
8.2 浅拷贝和内存开销
@wraps的所有操作都是引用赋值,不是深拷贝。wrapper.__doc__和原函数共享同一个文档字符串对象;wrapper.__annotations__直接引用同一个字典。因此,@wraps几乎不产生额外内存开销。但要注意,如果你没有用@wraps,而是自己写了个复制函数,误用了copy.deepcopy复制注解字典,反而会引入不可见的内存膨胀。所以我一直推荐优先用标准库方案,不要手搓。
8.3 函数签名自省工具 inspect.signature 的处理差异
经常有人问:既然inspect.signature在包装后函数上看到的是(*args, **kwargs),那如果手动设置了__signature__,inspect.signature会优先读它,这是不是就可以说@wraps能改签名?严格讲,@wraps本身不改签名,签名显示正确是因为你额外设了__signature__。如果再往上一层,inspect.signature还会看__wrapped__吗?不会。它会看__signature__,然后是__annotations__,然后是__code__,不沿__wrapped__链递归。这和inspect.unwrap的逻辑不同,很多人在这里踩坑。
8.4 Python 3.3 之前没有 @wraps 吗
functools.wraps从 Python 2.5 就已经存在,历史非常悠久。后来在 Python 3.2 中加入的__wrapped__属性则是一次重要增强,它让“解包”成为可能。这意味着,很多老代码里可能没有__wrapped__,需要检查运行时行为时,先用hasattr判断。新代码则没有这个顾虑。
9. 我个人的一套装饰器“标准草案”
我在写装饰器时,基本遵循一套默认模板,可以分享出来当参考:
import functools import inspect def decorator_template(func=None, *, option=None): if func is None: return lambda f: decorator_template(f, option=option) @functools.wraps(func) def wrapper(*args, **kwargs): # 在这里写前置逻辑 result = func(*args, **kwargs) # 在这里写后置逻辑 return result wrapper.__signature__ = inspect.signature(func) return wrapper几个设计决策:
- 既支持
@decorator_template也支持@decorator_template(option=...),判断func is None即可区分两种调用法。 - 保留签名,因为现代 IDE 和文档工具对签名提示依赖度很高。
- 用
func变量名直读原函数,避免内部逻辑依赖wrapper.__name__。 - 装饰器内部绝不修改
func本身,所有附加行为都在wrapper里完成。
这套模板在我经手的项目里被直接复制过,它解决的不仅是元信息问题,也统一了团队写装饰器的风格。
10. 最后分享两个实操体会
第一点,装饰器内读取函数元信息时,不要把wrapper当成信息来源。写完@functools.wraps(func)后,wrapper.__name__ == func.__name__是成立的,但代码可读性和鲁棒性上,直接使用闭包绑定的func更干净。我见过有人这么写:
@functools.wraps(func) def wrapper(*args, **kwargs): logger.info(f"调用 {wrapper.__name__}")这个代码能跑,但一旦将来有人把@wraps删掉调试,日志就错了。改成func.__name__后,逻辑和@wraps解耦,更抗折腾。
第二点,写库给别人用的时候,@wraps只是及格线。真正专业级别的装饰器还需要处理好__signature__、自定义属性、类装饰器兼容性,并在文档里明确说明“被装饰函数的行为变化”。对一个面向团队或社区发布的装饰器组件,这部分投入非常值得。
实际项目里,我在“替换装饰器”时有一个土办法:给装饰器写一条专门的测试用例,断言原函数对象和包装后对象的__name__、__doc__、inspect.unwrap行为,后面所有重构都受益于这条用例。装饰器是 Python 里最能体现“小而美”的语法糖,而@wraps就是保证它甜而不腻的关键。