装饰器是 Python 最招人喜欢也最容易"知其然不知其所以然"的特性。会用 @app.route 不难,难的是说清楚:装饰器为什么能记住状态?带参数的装饰器为什么要写三层函数?functools.wraps 到底修了什么?这些问题的答案全都指向一个更底层的概念——闭包。

场景:一个函数怎么"记住"东西

先看一个不带任何装饰器语法的例子:

1
2
3
4
5
6
7
8
9
10
def make_counter():
count = 0
def inc():
nonlocal count
count += 1
return count
return inc

c = make_counter()
print(c(), c(), c()) # 1 2 3

make_counter 已经返回、它的栈帧理应销毁,但 inc 仍然能读写 count。这就是闭包:内层函数捕获了外层作用域的变量,使其生命周期被延长到与内层函数绑定。

机制:自由变量与 cell

countinc 来说是自由变量(free variable)——既不是 inc 的局部变量也不是全局变量。CPython 不是把 count 的值拷进去,而是把它放进一个叫 cell 的中间对象里,内外两个函数都通过这个 cell 间接访问同一份数据。这就是为什么计数器能跨调用累加:它们共享 cell,而非各拿一份拷贝。

你可以直接把 cell 翻出来看:

1
2
3
print(c.__closure__)              # (<cell at 0x...: int object>,)
print(c.__closure__[0].cell_contents) # 当前 count 值
print(c.__code__.co_freevars) # ('count',) —— 自由变量名

__closure__ 是一个 cell 元组,和 __code__.co_freevars 里的变量名一一对应。理解了这个,nonlocal 的作用就清楚了:没有 nonlocalcount += 1 里的赋值会让 Python 把 count 当成 inc 的新局部变量,从而在读取时抛 UnboundLocalErrornonlocal 告诉编译器"这是外层的 cell 变量,去改那个"。

从闭包到装饰器

装饰器只是闭包的一个应用模式:接收一个函数、返回一个包裹它的新函数。@ 是纯语法糖:

1
2
3
4
5
6
@timer
def work(): ...

# 完全等价于:
def work(): ...
work = timer(work)

一个计时装饰器:

1
2
3
4
5
6
7
8
9
10
11
12
import time, functools

def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
cost = time.perf_counter() - start
print(f"{func.__name__} took {cost:.4f}s")
return wrapper

这里 func 就是 wrapper 的自由变量,被 cell 捕获——这正是 wrapper 知道该调谁的原因。*args, **kwargs 保证任意签名都能透传。try/finally 保证即使被装饰函数抛异常,计时和清理逻辑也照常执行,这是写装饰器极易漏掉的健壮性细节。

functools.wraps 修了什么

如果不加 @functools.wraps(func)work.__name__ 会变成 'wrapper'__doc__ 丢失,__module__、类型注解、__wrapped__ 也都不对。这会破坏一切依赖元数据的工具:日志、Sphinx 文档、inspect.signature、pickle。wraps 本质是把原函数的 __name____doc____dict____module____qualname__ 等拷到 wrapper 上,并设置 __wrapped__ 指回原函数,让你还能通过它拿到未包装版本。生产代码里写装饰器不加 wraps 几乎是 bug。

带参数的装饰器:为什么是三层

@retry(times=3) 这种带参的装饰器,需要多一层。原因很简单:retry(times=3) 这一步先执行,它必须返回一个"真正的装饰器",再由这个装饰器去接函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import functools

def retry(times=3):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last = None
for _ in range(times):
try:
return func(*args, **kwargs)
except Exception as e:
last = e
raise last
return wrapper
return decorator

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

三层对应三件事:retry 接收配置参数decorator 接收函数wrapper 接收调用参数timesfunc 分别是两层 cell 里的自由变量。看懂闭包链,三层结构就不再是需要死记的模板。

工程权衡与边界

  • 性能开销。 每层装饰都多一次函数调用和一次 *args/**kwargs 打包解包。热路径上叠五六层装饰器,调用开销会累积。对极热的函数,考虑把逻辑内联,或用类装饰器缓存。
  • 类装饰器替代闭包。 当装饰器需要维护复杂状态(计数、缓存表),用实现了 __call__ 的类比多层闭包更清晰,状态放 self 上,可读性和可测试性更好。
  • __wrapped__ 与无限递归。 多层装饰叠加时,inspect.unwrap__wrapped__ 链逐层剥离。如果你手写 wrapper 忘了用 wraps,这条链就断了。

常见踩坑

循环里建闭包的延迟绑定。 这是经典陷阱:

1
2
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # [2, 2, 2],不是 [0, 1, 2]

闭包捕获的是变量 i(的 cell),不是创建时刻 i 的值。循环结束时 i 是 2,所有 lambda 共享同一个 cell,于是全输出 2。修法是用默认参数在定义时立即求值绑定:lambda i=i: i,或用工厂函数显式建独立 cell。这个坑在事件回调、装饰器批量注册里反复出现,根因正是"闭包捕获变量而非值"。

小结

装饰器不是魔法,它是闭包加 @ 语法糖。闭包的核心是 cell 对象:内外函数共享同一份自由变量,状态因此得以保留。带参装饰器的三层结构、nonlocal 的必要性、循环里的延迟绑定坑,全都能用"捕获的是变量不是值"这一句话推导出来。把闭包想透,装饰器就成了顺理成章的结果。