闭包和装饰器这两个概念,在Python里属于那种“绕不过去”的坎。很多朋友学到函数之后就卡在这儿了,尤其是装饰器,看教程的时候觉得懂了,自己一写就报错,或者写出来的东西总感觉差点意思。其实问题往往出在闭包没有真正吃透上——装饰器本质上就是闭包的一种应用场景,闭包搞明白了,装饰器就是一层窗户纸。
这篇文章我会从闭包的底层逻辑开始讲,把手写装饰器、带参数的装饰器、类装饰器、以及实际项目里最常用的缓存、重试这类场景全部过一遍。每个环节都会解释为什么这么做,同时把我踩过的坑一并列出来。适合已经掌握Python基础语法、想进阶函数式编程的读者,也适合面试前突击这块知识点的朋友。
1. 闭包:先搞清楚函数是怎么记住外部变量的
1.1 从作用域规则说起
很多人觉得Python的作用域很简单,无非就是局部变量、全局变量。但闭包出现之后,事情就变得微妙了。看下面这段代码:
def outer(x): def inner(): return x + 1 return inner f = outer(10) print(f()) # 11inner定义在outer内部,按理说outer执行结束后,局部变量x就应该被回收了。但当你调用f()的时候,它居然还能准确拿到x = 10,这就是闭包在起作用。
这里的关键在于,inner引用了outer作用域里的变量x,Python会在函数定义的时候就记录下这个引用关系。当outer(10)执行完毕,x并没有真正消失,而是被inner通过__closure__属性挂在了自己身上。
你可以验证一下:
print(f.__closure__[0].cell_contents) # 10这个cell_contents就是被捕获的变量值。所以闭包的本质可以理解为:内层函数 + 它捕获的外部变量的组合体。内层函数离开外层函数之后,依然能访问那些变量,因为这些变量的“状态”被单独保留了下来。
1.2 闭包的三个必要条件
不是随便嵌套一个函数就能叫闭包。判断标准有三个:
- 必须存在嵌套函数,也就是函数内部定义了另一个函数。
- 内层函数必须引用外层函数作用域中的变量,或者引用外层函数参数。
- 外层函数必须将内层函数作为返回值返回。
三条缺一不可。比如下面这个例子就不是闭包:
def outer(): def inner(): return "hello" return inner()这里inner没有引用outer作用域里的任何东西,所以虽然它嵌套了、也返回了,但没有任何变量需要被“记住”,也就不构成闭包。
再比如:
def outer(): def inner(): return "hello" return inner同样不是闭包,因为内层函数没有对外层变量形成引用。
这里我补充一个容易混淆的知识点:很多人把“函数返回函数”就当作闭包,这是不严谨的。闭包的核心特征是变量的捕获,函数返回函数只是它的表现形式之一。
1.3 闭包里的变量生命周期问题
闭包捕获的是变量本身,还是变量的值?答案是变量本身。这一点非常关键。
def outer(): x = 10 def inner(): return x x = 20 return inner f = outer() print(f()) # 20,而不是10inner里面引用的x,直到真正调用f()的时候才会去取值。所以哪怕outer执行到一半把x改了,闭包拿到的也是最终值。这个特性在后面的装饰器实战里会反复用到,尤其是当你需要给装饰器传参数时,本质就是在利用这一层变量绑定机制。
再进一步,如果你想让内层函数能修改外层变量,需要用到nonlocal:
def counter(): count = 0 def inc(): nonlocal count count += 1 return count return inc c = counter() print(c()) # 1 print(c()) # 2如果没有nonlocal,count += 1会被Python解释成“创建一个新的局部变量count”,这样每次调用inc()都会报UnboundLocalError。这是我早期经常踩的坑,现在写闭包的时候,凡是涉及到对外层变量赋值的,第一反应就是检查有没有nonlocal。
2. 装饰器:闭包最典型的落地场景
2.1 手写一个不带糖的装饰器
很多人第一次接触装饰器,代码已经会抄了,但不知道这玩意到底是怎么回事。我建议先不要用@语法,把最原始的写法走一遍。
假设我们要给一个函数增加日志输出功能,但又不想改动原函数的内部代码:
def my_func(): return "hello" def log_wrapper(func): def inner(*args, **kwargs): print(f"调用前:{func.__name__}") result = func(*args, **kwargs) print(f"调用后:{func.__name__}") return result return inner my_func = log_wrapper(my_func) print(my_func())这个log_wrapper就是一个手动版装饰器。log_wrapper接收一个函数func,返回一个新函数inner。当执行my_func()时,实际执行的是inner,inner里调用了原始的func,同时附加了日志行为。
这整个结构就是闭包——inner捕获了func这个变量。所以我说装饰器是闭包最典型的应用,一点不夸张。
为什么要用*args, **kwargs?因为装饰器是通用工具,不应该绑定在某一个函数的具体签名上。你写一个不带参数的inner(),那这个装饰器就只能装饰无参函数,通用性大打折扣。用*args, **kwargs是为了把任意位置参数、任意关键字参数都透传给原始函数。
2.2 @语法糖到底发生了什么
搞清楚手写版之后,再来看@语法就非常透彻了。语法糖的本质就是把my_func = log_wrapper(my_func)这一步自动完成:
@log_wrapper def my_func(): return "hello"解释器看到@log_wrapper时,会读取下一行定义的函数对象my_func,把它作为参数传给log_wrapper,再将返回值重新绑定到my_func这个名字上。整个过程和你手动写my_func = log_wrapper(my_func)完全等价。
所以装饰器做的事情不是“修改”原函数,而是“替换”。原函数在定义之后就立即被包装出来的新函数覆盖了。如果你把原函数保存在别处,依然可以绕过装饰器直接调用它:
original = my_func.__wrapped__ # 如果用了wraps才有效这里有个细节要注意:如果不用functools.wraps,my_func.__name__会变成inner,而不是my_func。这也是装饰器最容易被人忽视的副作用之一,后面第3节会专门讲。
2.3 带参数的装饰器要再多包一层
很多时候装饰器本身也需要参数,比如指定日志级别。这时候闭包就要多套一层了:
def log_with(level): def decorator(func): def inner(*args, **kwargs): print(f"[{level}] 调用 {func.__name__}") return func(*args, **kwargs) return inner return decorator @log_with("INFO") def my_func(): return "hello"之所以要三层嵌套,是因为当你写@log_with("INFO")时,程序执行的过程是:
- 先调用
log_with("INFO"),得到返回的decorator。 - 再把
my_func作为参数传给decorator,得到返回的inner。 - 最后把
inner绑定到my_func名字上。
所以log_with在这里并不是严格意义上的装饰器,它只是一个“装饰器工厂”,真正的装饰器是它返回的decorator。这个区分很重要,面试经常考。
我在实际项目里更喜欢用这种带参装饰器来配置不同模块的日志策略。比如有的模块需要输出到文件,有的只需要输出到控制台,通过参数就能区分,而不需要写两个几乎一样的装饰器。
3. 装饰器进阶:functools.wraps 和类装饰器
3.1 为什么必须用 wraps
如果不处理的话,装饰器会破坏原函数的元信息。核心的元信息包括_\_name__、_\_doc__、_\_module__、_\_annotations__等。这会带来很实际的问题:
- 日志输出的函数名变成了
inner,排查问题的时候一头雾水。 - API文档生成工具会丢失原函数的签名信息。
- 某些框架依赖函数名做路由匹配,比如Web框架把函数名当作URL端点时,就会出错。
解决办法是使用functools.wraps:
from functools import wraps def log_wrapper(func): @wraps(func) def inner(*args, **kwargs): print(f"调用 {func.__name__}") return func(*args, **kwargs) return innerwraps本身也是一个装饰器,它把func的元信息复制到inner上。你可能会问,inner.__name__本来不是inner吗,为什么wraps能改?因为wraps实际做的是这样的操作:
inner.__name__ = func.__name__ inner.__doc__ = func.__doc__ inner.__module__ = func.__module__ inner.__wrapped__ = func # 保留原始函数引用它还顺带设置了一个_\_wrapped__属性,这在做多层装饰器调试时非常有用。所以我的习惯是:只要是自定义装饰器,一律先加@wraps,没有任何例外。
3.2 类装饰器怎么写
装饰器不一定非要用函数来写,类也可以。用类来实现的好处是可以利用类的属性保存状态,代码结构上更集中。
from functools import wraps class Timer: def __init__(self, func): self.func = func wraps(func)(self) def __call__(self, *args, **kwargs): import time start = time.time() result = self.func(*args, **kwargs) print(f"{self.func.__name__} 耗时 {time.time() - start:.4f}s") return result @Timer def slow_func(): import time time.sleep(1) return "done"用类实现装饰器时,实例本身要可调用,所以必须实现_\_call__方法。调用slow_func()时,实际上执行的是Timer实例的_\_call__。
这里有个细节需要注意:wraps(func)(self)这一句不是直接加在_\_call__上的,而是加在类的实例上,这样slow_func.__name__才能保持原样。我一开始写类装饰器时差点漏掉这行,结果调试半天发现函数名全变成了Timer。
类装饰器适合状态较多的场景,比如统计函数调用次数、做速率限制等。把计数器、时间戳都挂在实例属性上,比闭包嵌套写起来清晰不少。
4. 实操环节:用闭包和装饰器解决真实问题
4.1 实现一个带缓存功能的装饰器
实际开发里,函数计算结果缓存是非常常见的需求。用一个装饰器实现起来特别优雅:
from functools import wraps def memoize(func): cache = {} @wraps(func) def inner(*args, **kwargs): key = (args, tuple(sorted(kwargs.items()))) if key not in cache: cache[key] = func(*args, **kwargs) return cache[key] return inner @memoize def expensive_func(a, b): print("计算中...") return a * b print(expensive_func(3, 4)) # 计算中... 12 print(expensive_func(3, 4)) # 12,不再输出"计算中"闭包在这里的作用是:inner捕获了cache这个字典,所以每次调用expensive_func时访问的都是同一个字典对象,不会因为函数结束就丢失。
需要注意这个简单缓存的缺陷:如果函数参数不是可哈希的,比如传的是列表或字典,key构建就会报错。另外,缓存无限增长,对长期运行的服务来说可能内存泄漏。你在面试时可以主动提这些局限性,再补充一个带maxsize限制的LRU缓存方案,会显得思考更完整。
4.2 实现一个重试机制装饰器
网络请求、数据库连接这类操作经常会出现临时性失败,一次性不成功马上报错的话,用户体验很差。这时候一个重试装饰器就很实用了:
import time from functools import wraps def retry(max_attempts=3, delay=1): def decorator(func): @wraps(func) def inner(*args, **kwargs): for attempt in range(1, max_attempts + 1): try: return func(*args, **kwargs) except Exception as e: print(f"第 {attempt} 次失败:{e}") if attempt == max_attempts: raise time.sleep(delay) return inner return decorator @retry(max_attempts=3, delay=0.5) def unstable_request(): import random if random.random() < 0.5: raise ConnectionError("模拟网络错误") return "success"这里的delay每次固定不变,更好的做法是加入指数退避,比如delay * 2 ** (attempt - 1),避免重试风暴打垮下游服务。
重试装饰器有一个需要注意的点:只对“可重试”的异常生效。业务逻辑错误比如数据校验失败,重试一万次也没有意义。所以在真实的装饰器里,可以增加一个参数指定捕获哪些异常类型,或者用黑名单方式排除致命异常。这个设计决策直接关系到系统的稳定性。
4.3 装饰器顺序问题
一个函数可以叠加多个装饰器,顺序不同,行为差别很大:
@log_wrapper @retry(max_attempts=3) def request(): pass这时候执行顺序是从下往上执行的:request先被retry包装,再被log_wrapper包装。但调用顺序是从上往下执行的,也就是先写日志、再重试逻辑。
如果你把这个顺序反过来:
@retry(max_attempts=3) @log_wrapper def request(): pass就会出现先重试、后写日志的情况——每次失败尝试都会写一次日志,只有最后一次成功才会写第二次日志。两种顺序都有各自的应用场景,没有绝对的对错,但你必须清楚自己写的是哪一种。
我踩过的坑是:给一个耗时接口加了缓存装饰器,又把该接口注册到了某些框架的路由里,结果因为装饰器顺序不对,缓存一直不生效,检查了很久才发现是被外层装饰器提前处理掉了。所以写装饰器之前先想清楚执行链条,是必修课。
5. 常见坑与排查经验
5.1 闭包变量绑定延迟
看这段经典的坑:
def make_funcs(): funcs = [] for i in range(3): def f(): return i funcs.append(f) return funcs funcs = make_funcs() print([f() for f in funcs]) # [2, 2, 2],不是 [0, 1, 2]问题在于三个f捕获的都是同一个变量i,循环结束后i的最终值是2,所以三个函数返回的都是2。
解决办法是给内层函数传入默认参数:
def make_funcs(): funcs = [] for i in range(3): def f(i=i): return i funcs.append(f) return funcs这样每个f捕获的是各自默认参数i=i的快照值,不再是同一个循环变量。这个小技巧在写回调函数、事件处理器时非常常用。
5.2 装饰器参数被吞掉
前面强调过inner必须用*args, **kwargs接收并透传。如果你写装饰器时只写了inner(),那么被装饰函数带参数时一定会报错。这是一个类型错误提示,但新手经常看不明白。
我在开发中遇到过更隐蔽的问题:装饰器内部调用func(*args, **kwargs)时,如果kwargs里有可变类型对象,比如列表或字典,装饰器直接修改了这个对象,那么原函数里的数据也会被修改,因为传的是引用。这在日志装饰器里最容易翻车——如果你在日志里不小心把kwargs里的对象改了,会引发莫名其妙的业务Bug。
解决办法是:如果需要在装饰器内部读取或转发参数,尽量不修改原对象;如果确有必要,用copy.deepcopy做一份副本再操作。
5.3 忘记 return 的问题
inner如果不写return func(*args, **kwargs),而是直接func(*args, **kwargs),那么被装饰函数的返回值就被吞掉了,永远返回None。
这类错误特别隐蔽,因为函数本身执行了、副作用也产生了,只是返回值变成了None。如果调用方没有断言返回值,可能很久都发现不了。
排查建议:调试装饰器时,先手动调用func(*args, **kwargs)方式写一个临时调用,绕开装饰器验证原函数正常;再检查inner里的返回语句是否齐全。
6. 性能与工程化层面的一些补充
6.1 装饰器的调用开销
装饰器本质上多了一层函数调用,这会带来额外的性能开销,虽然单个调用耗时在微秒级,但如果被装饰的是高频函数,比如每秒调用万次,损耗就会积累起来。
我自己的经验是:对性能敏感的热点路径,要么避免使用装饰器,要么在装饰器内部做好快速短路逻辑。比如缓存命中时直接返回,不再执行原函数,这样性能反而是提升的。
6.2 多层装饰器的可读性
两三个装饰器叠着还能看,叠到五六个就非常痛苦了。工程上的做法是尽量把装饰器合并,或者将装饰器拆分为几个内部函数,通过组合方式复用。还有人会写一个统一的“装饰器注册中心”,用配置驱动方式决定哪些函数应用哪些行为,这样业务代码里保持干净。
我个人的习惯是:装饰器的语义边界要清晰。日志、鉴权、重试、缓存这些横切逻辑适合用装饰器;业务逻辑本身永远不要放进装饰器里,否则别人阅读代码时会非常困惑。
6.3 Python版本差异
Python 3.9之前,wraps的行为在不同版本里基本一致,但带参数的装饰器写法没有太大变化。比较新的版本里,类似datetime等标准库对类型注解支持更好了,装饰器配合类型标注时可以利用ParamSpec,这样装饰器前后的函数签名在类型检查时不会丢失。如果你在用类型检查工具,建议了解一下typing.ParamSpec的用法,它能让装饰器在类型层面也更清晰。
到后面我自己项目里的装饰器基本都遵循了这样的模式:外层工厂函数负责接收配置,中层装饰器负责接管函数,内层inner负责实际执行逻辑,加上@wraps保留元信息,用*args, **kwargs透传参数,把返回值完整交还给调用方。想踩坑都难。
这篇内容里每一个知识点都是我实际敲过代码、出过Bug之后总结出来的。你有空的话,把第4节的几个装饰器自己手写一遍,再试着给它们叠加不同顺序,跑一圈下来会比看十遍教程都有用。