装饰器是 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)。实践里有两个缓解办法:
- 在 wrapper 上保留
functools.wraps,至少__annotations__能跟着回来。 - 对外提供签名明确的接口函数,装饰逻辑藏在内部实现里。
如果装饰器要改变返回类型(比如把返回值包成 Result 对象),靠 ParamSpec、TypeVar 和 Concatenate 这些标注工具能把签名关系表达清楚,不过复杂度会上来,小项目里量力而行。
十二、收个尾
装饰器的全部秘密就是”函数是对象 + 闭包 + 定义时赋值”三件事的组合。@ 只是语法糖,剥掉之后剩下的都是普通函数调用。掌握了等价形式,带参数的三层结构、装饰顺序、元数据丢失这些现象都能自己推出来,不需要背。
写装饰器时保持一个克制的习惯:包装逻辑尽量薄,只做横切的事,业务逻辑留在被装饰函数里。装饰器一旦开始承载流程控制,调用栈就会变成调试器里的一团线。团队协作中,把通用装饰器集中放在独立模块、写清楚裸用与带参两种形态,比散落在各处的半成品更耐用。
评论(1)
关于这块的取舍,我持稍微不同的看法,但思路是通的。
请 登录 后参与评论。