装饰器的常见写法并不复杂:在函数外包一层 wrapper,再用 @decorator 简化表达。但只记住模板,还不足以解释几个更关键的问题:函数装饰后究竟变成了什么?带参数装饰器为什么需要三层结构?类装饰器为什么可能破坏实例方法绑定?异步函数为什么不能直接套用同步装饰器?
比起继续背写法,更有效的方式是沿着对象变化与调用过程追踪:装饰发生在什么时候,函数名被重新绑定到什么对象,状态保存在闭包还是实例中,以及新的对象是否仍然遵守原函数的调用协议。
1. @decorator 的本质不是“修饰”,而是重新绑定
@timer
def predict(x):
return x * 2
等价于:
def predict(x):
return x * 2
predict = timer(predict)
这里最值得注意的不是“语法糖”三个字,而是最后一行:名字 predict 被重新绑定到了 timer(predict) 的返回值。
因此,装饰器并不一定修改原函数。它更像是接收原来的 callable,再返回另一个 callable。装饰后,predict 可能绑定到:
- 一个新的普通函数;
- 一个类实例;
- 一个
functools.partial对象; - 甚至其他可调用对象。
所以理解装饰器的第一步,是始终追问:装饰完成后,原来的名字现在指向什么对象?
2. 函数式装饰器:闭包把原函数保存了下来
from functools import wraps
def trace(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"calling {func.__qualname__}")
return func(*args, **kwargs)
return wrapper
trace() 返回后,它的局部变量按理说应该离开普通函数栈帧,但 wrapper 仍然能够访问 func。原因是 func 成了 wrapper 的自由变量,并被保存在闭包的 cell 中。
可以把关系简化为:
函数名 predict
│
▼
wrapper function
│
▼
closure cell
│
▼
original predict
Python 闭包保存的是变量对应的 cell,而不是简单复制变量当前的值。变量通常在函数真正调用时解析,这也是循环闭包出现 late binding 的原因。
3. functools.wraps 保留的是“身份信息”,不是执行语义
不使用 wraps 时,装饰后看到的往往是:
predict.__name__ # 'wrapper'
predict.__doc__ # wrapper 的文档或 None
因为 predict 确实已经绑定到 wrapper。
functools.wraps(func) 会复制 __name__、__qualname__、__doc__、__annotations__ 等信息,并设置:
wrapper.__wrapped__ = func
但这里有一个容易被忽略的边界:
wraps只能保留元数据,不能把同步函数变成异步函数,也不能自动保留生成器、异步生成器等执行语义。
因此,一个普通 def wrapper 即使装饰了 async def,也仍然是同步函数。
4. 带参数装饰器为什么一定多一层
@retry(times=3, delay=0.5)
def request():
...
它的展开不是:
request = retry(request)
而是:
configured_decorator = retry(times=3, delay=0.5)
request = configured_decorator(request)
因此结构自然变成三层:
def retry(times=3, delay=0.0): # 接收配置
def decorator(func): # 接收原函数
def wrapper(*args, **kwargs): # 接收调用参数
...
return wrapper
return decorator
这三层并不是“Python 装饰器模板比较啰嗦”,而是三类信息进入系统的时间不同:
- 模块加载或函数定义时,传入装饰器配置;
- 原函数定义完成后,传入原函数;
- 每次业务调用时,传入实际参数。
一个更完整的同步重试装饰器可以写成:
from functools import wraps
from time import sleep
def retry(times=3, delay=0.0, exceptions=(Exception,)):
if times < 1:
raise ValueError("times must be >= 1")
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions:
if attempt == times:
raise
if delay > 0:
sleep(delay)
raise RuntimeError("unreachable")
return wrapper
return decorator
工程上还要继续追问:异常是否可重试、操作是否幂等、是否需要退避和 jitter、总超时如何控制。装饰器只是组织代码的形式,不能替代这些语义判断。
5. 类装饰器:用可调用对象保存状态
函数不是 Python 中唯一可以调用的对象。一个类只要实现 __call__,其实例就可以像函数一样被调用:
class CallCounter:
def __call__(self, *args, **kwargs):
...
调用对象:
counter(...)
可以理解为执行:
counter.__call__(...)
一个简单类装饰器:
from functools import update_wrapper
class CallCounter:
def __init__(self, func):
self.func = func
self.count = 0
update_wrapper(self, func)
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
使用:
@CallCounter
def predict(x):
return x * 2
等价于:
predict = CallCounter(predict)
此时 predict 已经不是普通函数,而是一个实现了 __call__ 的对象。类装饰器由此把函数式装饰器闭包里的状态转移到了实例属性中,适合管理计数器、缓存或配置等显式状态。
6. 我真正卡住的地方:__call__ 只解决“能调用”,不解决“怎么绑定实例”
把类装饰器用于普通函数通常没问题,但用于实例方法时就出现了新的问题:
class Counter:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs)
class Model:
@Counter
def predict(self, x):
return x * 2
装饰后,Model.predict 已经不是普通函数,而是一个可调用对象。它知道被调用时应该做什么,却没有定义自己作为类属性被实例访问时应该如何绑定,因此 Python 不会自动把 model 放入参数列表。
这里需要区分两套独立的 Python 协议:
__call__定义对象被调用时做什么;__get__定义对象作为类属性被实例访问时如何绑定;- 两者是两套不同协议。
所以只实现 __call__,并不能恢复普通实例方法原本拥有的 bound method 行为。
7. 用 __get__ 与 MethodType 恢复绑定
from functools import update_wrapper
from types import MethodType
class Counter:
def __init__(self, func):
self.func = func
self.count = 0
update_wrapper(self, func)
def __get__(self, instance, owner):
if instance is None:
return self
return MethodType(self, instance)
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
调用:
model.predict(3)
可以展开为:
model.predict
↓
Counter.__get__(counter, model, Model)
↓
MethodType(counter, model)
↓
counter(model, 3)
↓
Counter.__call__(counter, model, 3)
↓
original_predict(model, 3)
这里曾经有一个很自然的疑问:原始 predict 里的 self 会不会变成 counter?答案不会,因为存在两层不同的 self:
Counter.__call__里的self是装饰器实例;- 原始
predict里的self是业务对象model。
counter 已经作为 Counter.__call__ 自己的 self 被消费,而 model 留在 args 中继续转发给原函数。
8. 异步装饰器:关键在于真正执行发生在 await
async def fetch():
return 42
调用:
coro = fetch()
只会创建 coroutine object,并不立即执行函数体。真正执行发生在:
result = await coro
因此同步计时器:
def timer(func):
def wrapper(*args, **kwargs):
start = perf_counter()
result = func(*args, **kwargs)
print(perf_counter() - start)
return result
return wrapper
装饰异步函数时,只测到了 coroutine 创建时间。
正确写法:
from functools import wraps
from time import perf_counter
def async_timer(func):
@wraps(func)
async def wrapper(*args, **kwargs):
start = perf_counter()
try:
return await func(*args, **kwargs)
finally:
print(perf_counter() - start)
return wrapper
这里必须同时满足:
async def wrapper(...)
和:
await func(...)
否则就无法覆盖真实执行、异常、取消和资源清理过程。
9. 最后的理解:装饰器真正难的是保持调用协议
现在我会这样总结装饰器:
函数式装饰器
= 高阶函数 + 闭包
类装饰器
= 可调用对象
+ 实例状态
实例方法绑定
= Descriptor.__get__
不是 __call__ 的职责
异步装饰器
= 保持 coroutine function 身份
并 await 原协程
所以装饰器真正难的地方并不是会不会写 wrapper,而是要确认:
- 装饰后名字绑定到了什么对象;
- 状态保存在闭包还是实例属性;
- 普通方法的实例绑定是否还存在;
- 同步、异步、生成器的执行模型有没有被保持;
- 装饰器顺序是否改变业务语义。
从这个角度看,装饰器不是一种孤立语法,而是 Python 对象模型、闭包和调用协议共同作用的结果。