Python 装饰器进阶:带参数的装饰器与 functools.wraps 到底怎么写
写装饰器的人多,但真正把「带参数的装饰器」和「元信息丢失」这两个坑填平的人少。你大概率遇到过这两件事:一是想给装饰器传个配置(比如重试几次、限流多少),结果三层嵌套的函数绕晕了;二是被装饰后的函数__name__变成了wrapper,日志和文档全乱套,Flask 路由甚至直接报重名。
这篇把这两个问题一次讲透,给出能直接抄进项目的写法。
先从最朴素的装饰器说起
一个装饰器本质就是「接收一个函数、返回一个函数」:
deflog_call(func):defwrapper(*args,**kwargs):print(f"调用{func.__name__}")returnfunc(*args,**kwargs)returnwrapper@log_calldefadd(a,b):returna+bprint(add(1,2))# 先打印 "调用 add",再返回 3@log_call等价于add = log_call(add)。到这里没问题,但两个隐患已经埋下了。
坑一:被装饰后,函数的身份丢了
接着上面的代码看一眼函数的元信息:
print(add.__name__)# wrapper —— 不是 add 了!print(add.__doc__)# None —— 原来的文档字符串没了add现在指向的是wrapper,所以名字、文档、签名全变成了wrapper的。这在小脚本里无所谓,但一旦上规模就会出事:
- 日志里全打印成
wrapper,根本分不清是哪个函数在跑; - 用
functools.lru_cache、pytest参数化这类依赖函数名的工具会错乱; - Web 框架(Flask、FastAPI)常用函数名做路由端点名,多个函数都叫
wrapper会直接抛「端点重复」异常。
修复只要一行 —— 用functools.wraps把原函数的元信息复制到wrapper上:
importfunctoolsdeflog_call(func):@functools.wraps(func)# 关键:把 func 的 __name__/__doc__/__wrapped__ 拷给 wrapperdefwrapper(*args,**kwargs):print(f"调用{func.__name__}")returnfunc(*args,**kwargs)returnwrapper@log_calldefadd(a,b):"""两数相加"""returna+bprint(add.__name__)# addprint(add.__doc__)# 两数相加functools.wraps本身也是个装饰器,它会把func的__name__、__doc__、__module__、__dict__拷过来,还会设置__wrapped__指回原函数,方便你在需要时拿到未包装的版本。写装饰器时无脑加上它,这是行业惯例。
坑二:我想给装饰器传参数怎么办
真实需求几乎都带配置:重试 3 次、限流每秒 5 次、日志级别设成 WARNING。但装饰器只接收一个「函数」参数,配置往哪塞?
答案是再套一层 ——写一个「返回装饰器的函数」。也就是说,@retry(times=3)里的retry(times=3)先执行,返回一个真正的装饰器,再去装饰函数:
importfunctoolsimporttimedefretry(times=3,delay=0.5,exceptions=(Exception,)):"""失败自动重试。times 次都失败才抛出最后一次异常。"""defdecorator(func):@functools.wraps(func)defwrapper(*args,**kwargs):last_exc=Noneforattemptinrange(1,times+1):try:returnfunc(*args,**kwargs)exceptexceptionsase:last_exc=e# 最后一次就不用再睡了,直接跳出去抛异常ifattempt<times:print(f"[{func.__name__}] 第{attempt}次失败:{e},{delay}s 后重试")time.sleep(delay)raiselast_excreturnwrapperreturndecorator@retry(times=3,delay=1,exceptions=(ConnectionError,))deffetch(url):raiseConnectionError("连接超时")fetch("http://example.com")理清这三层的执行顺序,整件事就通了:
retry(times=3, delay=1, ...)先被调用,返回内层的decorator;decorator(fetch)再被调用,返回wrapper;- 最终
fetch指向wrapper。
记忆点:带参数的装饰器 = 三层函数,最外层收「装饰器参数」,中间层收「被装饰的函数」,最里层收「调用时的实参」。
进阶:让装饰器「带不带参数都能用」
上面的retry必须写成@retry(),忘了括号写成@retry就会报错,因为retry会把func当成times收走。想做到@retry和@retry(times=5)都能用,可以判断第一个位置参数是不是可调用对象:
importfunctoolsdefretry(_func=None,*,times=3,delay=0.5):defdecorator(func):@functools.wraps(func)defwrapper(*args,**kwargs):forattemptinrange(1,times+1):try:returnfunc(*args,**kwargs)exceptException:ifattempt==times:raisereturnwrapper# @retry 直接用:_func 是被装饰的函数,立刻装饰返回if_funcisnotNone:returndecorator(_func)# @retry(times=5) 用法:_func 是 None,返回装饰器等下一步returndecorator@retry# 没括号,走 _func 分支defa():...@retry(times=5)# 有括号,走 return decorator 分支defb():...*强制times、delay只能用关键字传,避免有人写@retry(5)时把 5 误当成_func。这个模式在标准库里很常见,dataclasses.dataclass就是这么实现的。
用 class 写装饰器(需要保存状态时更清晰)
如果装饰器要累积状态(比如统计调用次数),用类比闭包更直观。此时用functools.update_wrapper代替@wraps:
importfunctoolsclassCountCalls:def__init__(self,func):functools.update_wrapper(self,func)# 等价于 @wraps,拷贝元信息到实例self.func=func self.count=0def__call__(self,*args,**kwargs):self.count+=1print(f"{self.func.__name__}第{self.count}次调用")returnself.func(*args,**kwargs)@CountCallsdefhello():print("hi")hello();hello()print(hello.count)# 2 —— 状态存在实例上,读取很自然@wraps是给函数用的语法糖,update_wrapper是它底层调用的函数,类装饰器里手动调一下即可。
小结
- 写任何装饰器都加
@functools.wraps(func),否则被装饰函数的__name__/__doc__会丢,日志、缓存、Web 路由都可能出问题。 - 带参数的装饰器 = 三层嵌套:最外层收装饰器参数,中间层收函数,最里层收调用实参;记住
@retry(times=3)是「先调用retry拿到装饰器」。 - 想做到「带不带括号都能用」,判断第一个参数是否可调用,并用
*把配置项强制成关键字参数。 - 需要保存状态时用类装饰器 +
functools.update_wrapper,比闭包更好读。
一句话记忆:装饰器传参就多包一层函数,元信息丢失就functools.wraps补回来。