装饰器的常见写法并不复杂:在函数外包一层 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 装饰器模板比较啰嗦”,而是三类信息进入系统的时间不同:

  1. 模块加载或函数定义时,传入装饰器配置;
  2. 原函数定义完成后,传入原函数;
  3. 每次业务调用时,传入实际参数。

一个更完整的同步重试装饰器可以写成:

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 对象模型、闭包和调用协议共同作用的结果。