7AA7 博客

Python 装饰器详解:从语法糖到面向切面

AsyncLab UID:94
2026-10-15 09:50
73 1 5

装饰器是 Python 里最容易被”用会了却讲不清”的特性之一。很多人写得出 @login_required,但被问到 @deco 到底等价于什么、为什么必须写 functools.wraps、装饰顺序谁先谁后时,答案就开始含糊。这篇文章从函数对象这个起点把整条链路走一遍。

一、前置:函数是对象,名字只是绑定

Python 里函数是普通对象,可以赋值、传参、放进容器、作为返回值:

def greet(name):
    return f"hi, {name}"

hello = greet          # hello 与 greet 指向同一个函数对象
print(hello("world"))  # hi, world

def greet 只是把函数对象绑定到名字 greet 上。既然函数能被赋值,那”把一个函数替换成另一个函数”就完全合法——这正是装饰器的全部机制。

二、闭包:装饰器的载体

装饰器返回的新函数需要”记住”原函数,这就得靠闭包:

def make_logger(func):
    def wrapper(*args, **kwargs):
        print(f"call {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

wrapper 引用了外层的 func,即使 make_logger 已经返回,这个绑定依然存活。闭包捕获的是变量绑定而不是值的快照,所以对可变对象的修改能被观察到,这一点在后面讲坑的时候还会用到。

三、functools:先认识两个工具

写装饰器之前,functools 里有两个函数值得先混个脸熟,它们会反复出现在后面的例子里。

functools.wraps 是装饰器的”元数据修复器”,后面专门有一节讲它。functools.partial 则是把函数的部分参数先固定住,返回一个新的可调用对象,常用来从一个通用函数派生出配置好的版本:

import functools

def log(level, msg):
    print(level, msg)

info = functools.partial(log, "INFO")
info("started")   # 等价于 log("INFO", "started")

partial 本身和装饰器没有必然联系,但它是理解”函数是对象”的最好例子之一,也经常被用在装饰器内部组合逻辑。

四、装饰器语法的等价形式

@deco 写在函数定义上方,和在定义之后做一次函数赋值完全等价:

@make_logger
def add(a, b):
    return a + b

# 等价于
def add(a, b):
    return a + b
add = make_logger(add)

注意等价形式发生在函数定义时,不是调用时。add 这个名字此后指向的是 wrapper,原函数只被闭包里的 func 持有。理解了这一点,下面这些现象就都不奇怪了:

print(add.__name__)  # 没有 wraps 的情况下输出 wrapper,而不是 add

五、functools.wraps 为什么必要

原函数的 __name__、__doc__、类型注解、签名都被 wrapper 顶替了。后果不只是难看:文档工具读不到原始 docstring,基于 inspect.signature 的 Web 框架参数解析会出错,测试里按 __name__ 找函数也会扑空。

functools.wraps 把这些元数据拷贝回来:

import functools

def make_logger(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"call {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

经验法则:只要装饰器返回的是另一个函数,就随手加上 functools.wraps。成本一行,省掉一类很隐蔽的 bug。

六、带参数的装饰器

装饰器本身需要参数(比如缓存超时时间)时,要多包一层函数,因为 @deco(arg) 的语义是 func = deco(arg)(func):

import functools, time

def retry(times=3, delay=0.5):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for i in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception:
                    if i == times - 1:
                        raise
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(times=5, delay=1)
def call_api():
    ...

三层嵌套容易晕,记住固定的三层职责即可:最外层收装饰器参数,中间层收被装饰函数,最内层是实际执行的包装逻辑。

七、类装饰器

装饰器不一定要返回函数,任何可调用对象都行,类也可以被装饰器包装,类本身也可以当装饰器用:

class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"{self.func.__name__} called {self.count} times")
        return self.func(*args, **kwargs)

@CountCalls
def work():
    ...

类装饰器的好处是状态(这里是 count)有明确的存放位置,比往闭包里塞 nonlocal 变量可读。反过来,接收”类”作为参数的装饰器则常用于给类批量注入方法或注册到某个表里。

八、典型用途

装饰器解决的是横切关注点:那些和主逻辑正交、却要散落在很多函数里的事。

用途 核心思路
日志 包装入口出口,打印参数与返回值
缓存 先查缓存命中则短路,miss 时调用原函数并回填
鉴权 校验前置条件不通过则抛异常或返回错误对象
计时 记录前后时间差,time.perf_counter 精度更合适
重试 捕获异常按策略重试,配合退避

标准库里的 functools.lru_cache、property、staticmethod 本质上都是装饰器或装饰器协议的实现,读它们的用法比读教程更快建立直觉。

类上用装饰器还有一个高频组合:把某个方法标成路由、订阅者或者序列化钩子,装饰器干的事就是往类的注册表里添一条记录。Web 框架的路由声明、事件系统的监听注册,多半长这个样子。理解了”定义时执行”,这类框架魔法就都不神秘了。

九、两个常见的坑

装饰顺序。 多个装饰器从下往上先套最近的:

@a
@b
def f(): ...
# 等价于 f = a(b(f))

执行时 a 的包装逻辑先进入,b 的逻辑嵌在内层。日志和鉴权叠在一起时顺序反了,表现就是”没打日志”或”鉴权没生效”,排查时先看这里。

装饰器自己的状态。 闭包里想记录调用次数之类的统计信息,用 nonlocal 可以,但一旦逻辑变复杂,状态散在闭包里很难看。类装饰器把状态放在实例属性上,后续要加开关、要清零、要导出指标都顺手。如果装饰器既要支持并发调用又要维护状态,记得自己加锁,Python 的 GIL 不会替你保证”读-改-写”是原子的。

可变默认参数。 这严格说不是装饰器的问题,但两者经常一起踩:

def deco(func):
    def wrapper(items=[]):   # 坏味道:默认列表在函数定义时创建一次
        items.append(1)
        return items
    return wrapper

items 这个列表是定义时创建的单例,多次调用会互相污染。写成 items=None 再在函数体内 items = items or [] 是标准解法。闭包里若还捕获了可变对象做累加器,同样的问题会以更隐蔽的形式出现。

十、装饰器工厂:可选参数的统一写法

项目里常看到同一个装饰器既能裸用又能带参:

@retry
def f(): ...

@retry(times=5)
def g(): ...

实现上要做一次探测:如果第一个位置参数是可调用对象且没有传关键字参数,说明是裸用,直接返回包装结果;否则返回真正的装饰器。常用的骨架是:

def retry(func=None, *, times=3, delay=0.5):
    def decorator(f):
        @functools.wraps(f)
        def wrapper(*args, **kwargs):
            ...
        return wrapper
    if func is not None:
        return decorator(func)   # 裸用:@retry
    return decorator             # 带参:@retry(times=5)

这个模式在标准库和第三方库里反复出现,读到 func=None 开头的装饰器函数基本都是这套路数。它换来调用侧的简洁,代价是实现侧多一个分支,团队里值得约定清楚,避免一半裸用一半带参。

调试装饰器本身时还有个小技巧:临时把 functools.wraps 注释掉,让 wrapper 的名字暴露出来,堆栈里就能一眼看出卡在哪一层包装;或者直接用调试器在 wrapper 里下断点单步进原函数。装饰层数多了之后,读堆栈比读代码更快定位问题。

十一、装饰器与类型标注

类型检查器对装饰器的推断能力有限,wrapper 的签名往往被抹平成 (*args, **kwargs)。实践里有两个缓解办法:

  1. 在 wrapper 上保留 functools.wraps,至少 __annotations__ 能跟着回来。
  2. 对外提供签名明确的接口函数,装饰逻辑藏在内部实现里。

如果装饰器要改变返回类型(比如把返回值包成 Result 对象),靠 ParamSpec、TypeVar 和 Concatenate 这些标注工具能把签名关系表达清楚,不过复杂度会上来,小项目里量力而行。

十二、收个尾

装饰器的全部秘密就是”函数是对象 + 闭包 + 定义时赋值”三件事的组合。@ 只是语法糖,剥掉之后剩下的都是普通函数调用。掌握了等价形式,带参数的三层结构、装饰顺序、元数据丢失这些现象都能自己推出来,不需要背。

写装饰器时保持一个克制的习惯:包装逻辑尽量薄,只做横切的事,业务逻辑留在被装饰函数里。装饰器一旦开始承载流程控制,调用栈就会变成调试器里的一团线。团队协作中,把通用装饰器集中放在独立模块、写清楚裸用与带参两种形态,比散落在各处的半成品更耐用。

733浏览量
73赞
1评论
1参与用户
13 分钟阅读时间

评论(1)

  • Kael · 2026-10-15 12:50

    关于这块的取舍,我持稍微不同的看法,但思路是通的。

    回复