装饰器是 Python 最招人喜欢也最容易"知其然不知其所以然"的特性。会用 @app.route 不难,难的是说清楚:装饰器为什么能记住状态?带参数的装饰器为什么要写三层函数?functools.wraps 到底修了什么?这些问题的答案全都指向一个更底层的概念——闭包。
场景:一个函数怎么"记住"东西
先看一个不带任何装饰器语法的例子:
1 | def make_counter(): |
make_counter 已经返回、它的栈帧理应销毁,但 inc 仍然能读写 count。这就是闭包:内层函数捕获了外层作用域的变量,使其生命周期被延长到与内层函数绑定。
机制:自由变量与 cell
count 对 inc 来说是自由变量(free variable)——既不是 inc 的局部变量也不是全局变量。CPython 不是把 count 的值拷进去,而是把它放进一个叫 cell 的中间对象里,内外两个函数都通过这个 cell 间接访问同一份数据。这就是为什么计数器能跨调用累加:它们共享 cell,而非各拿一份拷贝。
你可以直接把 cell 翻出来看:
1 | print(c.__closure__) # (<cell at 0x...: int object>,) |
__closure__ 是一个 cell 元组,和 __code__.co_freevars 里的变量名一一对应。理解了这个,nonlocal 的作用就清楚了:没有 nonlocal,count += 1 里的赋值会让 Python 把 count 当成 inc 的新局部变量,从而在读取时抛 UnboundLocalError;nonlocal 告诉编译器"这是外层的 cell 变量,去改那个"。
从闭包到装饰器
装饰器只是闭包的一个应用模式:接收一个函数、返回一个包裹它的新函数。@ 是纯语法糖:
1 |
|
一个计时装饰器:
1 | import time, functools |
这里 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 | import functools |
三层对应三件事:retry 接收配置参数,decorator 接收函数,wrapper 接收调用参数。times 和 func 分别是两层 cell 里的自由变量。看懂闭包链,三层结构就不再是需要死记的模板。
工程权衡与边界
- 性能开销。 每层装饰都多一次函数调用和一次
*args/**kwargs打包解包。热路径上叠五六层装饰器,调用开销会累积。对极热的函数,考虑把逻辑内联,或用类装饰器缓存。 - 类装饰器替代闭包。 当装饰器需要维护复杂状态(计数、缓存表),用实现了
__call__的类比多层闭包更清晰,状态放self上,可读性和可测试性更好。 __wrapped__与无限递归。 多层装饰叠加时,inspect.unwrap靠__wrapped__链逐层剥离。如果你手写 wrapper 忘了用wraps,这条链就断了。
常见踩坑
循环里建闭包的延迟绑定。 这是经典陷阱:
1 | funcs = [lambda: i for i in range(3)] |
闭包捕获的是变量 i(的 cell),不是创建时刻 i 的值。循环结束时 i 是 2,所有 lambda 共享同一个 cell,于是全输出 2。修法是用默认参数在定义时立即求值绑定:lambda i=i: i,或用工厂函数显式建独立 cell。这个坑在事件回调、装饰器批量注册里反复出现,根因正是"闭包捕获变量而非值"。
小结
装饰器不是魔法,它是闭包加 @ 语法糖。闭包的核心是 cell 对象:内外函数共享同一份自由变量,状态因此得以保留。带参装饰器的三层结构、nonlocal 的必要性、循环里的延迟绑定坑,全都能用"捕获的是变量不是值"这一句话推导出来。把闭包想透,装饰器就成了顺理成章的结果。