闭包和装饰器,在Python里属于那种“网上教程看了三遍,感觉懂了,自己一写就废”的知识点。不光是面试高频考点,写业务代码、做框架封装、写工具库的时候,这俩就是绕不开的核心技能。尤其是装饰器,不管你是写Django中间件、Flask路由,还是给爬虫加重试、给接口加缓存,本质上玩得都是这套东西。这篇文章我就打算把闭包和装饰器拆开揉碎,从概念到底层实现,从最简例子到实际项目能直接用的写法,一次性把来龙去脉讲明白。
先说下哪类读者建议重点看:有Python基础,学过函数和作用域,但总觉得闭包抽象、装饰器晦涩的;或者能跑通教程代码,但自己面对“带参数的装饰器”“多层装饰器”会犯迷糊的;再就是准备面试想系统理一遍这块知识体系的。文章会先把概念的“为什么”讲透,再给可以直接抄的完整代码,最后整理一份常见坑位速查表。
1. 先把概念说透:什么是闭包,为什么它值得你花时间
1.1 函数是一等公民,这是所有魔法的前提
要理解闭包,先得接受一个Python的重要设定:函数跟整数、字符串、列表一样,也是一种对象。这意味着函数可以像任何普通值一样被赋值给变量、放进列表和字典、作为参数传给另一个函数,甚至可以作为一个函数的返回值传出来。
def greet(name): return f"hello, {name}" # 函数可以被赋值给变量 f = greet print(f("小明")) # hello, 小明 # 函数可以作为参数传递 def call_twice(func, arg): return func(func(arg)) print(call_twice(greet, "小红")) # hello, hello, 小红 # 函数可以作为返回值返回 def get_func(): return greet g = get_func() print(g("小刚")) # hello, 小刚这个特性在很多语言里叫“函数是一等公民”。在Java里你想传一段逻辑,得搞个匿名内部类、Lambda表达式什么的,绕来绕去;在Python里函数可以直接拿来递来递去,非常方便。但一个直觉问题马上来了:函数能当返回值,那我返回的函数它得在“某个地方”待着才能被调用吧?它是在哪个环境里运行的?它还能不能访问定义时的那些变量?闭包解决的就是这个问题。
1.2 闭包到底是什么:一个“带着记忆”的函数
直接看代码。这个是最经典的闭包例子:
def outer(message): def inner(): print(message) return inner fn = outer("hello world") fn() # hello world按理说,outer函数执行完,里面的局部变量message就应该被销毁了。但如果这是普通局部变量,那inner函数被调用的时候,message从哪里来?事实是它不但能取到值,而且取到的还是定义时的"hello world"。这就是闭包的核心机制:内部函数不仅拿走了“函数本身”,还把定义它时所处环境的变量一起“打包带走了”。
这个“函数体 + 环境变量”的捆绑体,就叫闭包(Closure)。
用生活类比来理解:你出门前把家里的钥匙交给室友,说“下午帮我取个快递”。室友记住了“下午取快递”这个任务,也随身带着你家钥匙。后来你出门了,家里没人,但室友照样能进门把快递取了,因为他带了钥匙。这里“取快递的任务”就是内部函数,“钥匙”就是你家的变量环境,“出门的屋主”就是外层函数。外层函数就算执行完了(你走了),内部函数依然能靠着这套“记忆”访问那些变量。
再看一个稍微进阶的:
def make_multiplier(factor): def multiply(x): return x * factor return multiply double = make_multiplier(2) triple = make_multiplier(3) print(double(10)) # 20 print(triple(10)) # 30同一个外层函数,造出了两个“行为不同”的函数。double和triple内部的乘法逻辑一模一样,唯一的区别是它们捕获的factor分别是 2 和 3。这个写法就像“生产函数的工厂”,在Python里特别适合做那种“需要记住一批配置参数,然后反复使用”的场景。
1.3 从底层机制看闭包:closure和 cell 对象
如果只是写法层面理解,遇到复杂情况你还是会懵。我建议直接扒一层,看看Python底层到底怎么存储“记住的变量”。
def outer(value): def inner(): return value return inner fn = outer(42) # 看内部函数记住了哪些自由变量 print(fn.__code__.co_freevars) # ('value',) # 查看闭包中保存的值 for cell in fn.__closure__: print(cell.cell_contents) # 42__code__.co_freevars返回的是一个元组,里面是所有被捕获的外部变量名。__closure__则是一个由 cell 对象组成的元组,每个 cell 的cell_contents属性就保存着变量的当前值。如果你随便定义一个普通函数,再去取它的__closure__,得到的是None。
理解这一点有什么实际用处?排查问题时你就知道:闭包既然是“函数+环境”的组合,那这个环境是长在函数身上的。所以如果你在循环里创建闭包、在多个地方共享同一个闭包,要小心它们捕获的到底是哪个变量。后面我专门讲这个坑。
2. 闭包的经典陷阱与正确的使用姿势
2.1 经典大坑:循环变量延迟绑定
这个坑在面试里几乎必考,论坛里隔三差五就有人贴出来问。先看这段代码:
funcs = [] for i in range(3): funcs.append(lambda: i) for f in funcs: print(f())直觉上你会觉得输出是0 1 2,对吧?实际输出是2 2 2。
问题就出在闭包的捕获时机上。循环是连续执行的,i的取值依次变成 0、1、2,然后循环结束,i的值最终停在了 2。这个过程中,三个 lambda 函数捕获的是同一个变量i,不是 i 在每个循环瞬间的“快照”。它们是“引用”变量本身,等循环结束再调用这些函数时,i已经是 2 了。这就叫延迟绑定:晚绑定到循环结束后的最终值。
解决方法有两种。第一种是使用默认参数绑定当前值:
funcs = [] for i in range(3): funcs.append(lambda i=i: i) for f in funcs: print(f()) # 0 1 2关键在于lambda i=i: i。这里的i=i是默认参数,默认参数在函数定义时就被计算并固定下来了,所以每个 lambda 都把自己当时的值“冻结”进了默认参数里。
第二种是套一个中间层工厂函数:
def make_func(x): return lambda: x funcs = [make_func(i) for i in range(3)] for f in funcs: print(f()) # 0 1 2这个本质上就是利用闭包的“定义环境隔离”特性。每次调用make_func(i)都创建了一个独立的作用域,x是各自独立的一份变量,互不干扰。
2.2 修改闭包外部变量:凭什么要加 nonlocal
闭包内只读访问外部变量很方便,但如果你想在内部函数里修改那个外部变量,直接写会报错:
def counter(): count = 0 def increment(): count += 1 # UnboundLocalError: local variable 'count' referenced before assignment return count return increment原因在于:Python 规定,如果函数体内出现了对某个变量的“赋值语句”,那这个变量就会被当成局部变量。count += 1里有个赋值操作,所以 Python 认为count是increment的局部变量。可局部变量在赋值之前就被引用,这不就报错了嘛。
这时候需要在内部函数的第一行加nonlocal声明:
def counter(): count = 0 def increment(): nonlocal count count += 1 return count return increment c1 = counter() print(c1()) # 1 print(c1()) # 2 print(c1()) # 3nonlocal的含义就是告诉Python:“这个变量不是本函数的局部变量,去外层函数的作用域里找,并且允许我重新绑定它。” 类比一下:闭包的函数体像是一间房间,房间里的本地变量就是房间里摆着的家具;“房间锁住了,普通变量访问默认只能读取窗外看到的东西”;如果你想让房间外的桌子也能被搬进来重新摆设,就需要一张“出入许可证”,也就是nonlocal。
顺带提一个容易弄混的点:nonlocal和global不一样。global是直接去模块全局作用域找变量,nonlocal则是去“当前函数的外层嵌套函数”里找变量,它不会直接跳到全局。搞清楚搜索顺序(局部 → nonlocal对应的外层 → 全局 → 内置),很多变量作用域的问题都能想明白。
2.3 闭包的典型实战场景:带状态的函数
说到闭包的实际用途,最常见的就是“不用类,也能让函数保持状态”。经典的计数器就是一个例子。再比如一个简单的缓存函数,用闭包实现:
def make_cache(): cache = {} def get(key): return cache.get(key) def set(key, value): cache[key] = value return value def has(key): return key in cache return get, set, has get, set, has = make_cache() set("name", "张三") print(get("name")) # 张三 print(has("age")) # False这个实现虽然粗糙,但已经展示了一个思路:用闭包把一批数据(cache 字典)和操作这批数据的函数捆绑在一起,对外完全隐藏cache这个变量,只能通过返回的函数访问。这其实就是“封装”的一种轻量级实现。你当然可以说用类更方便,没错。但闭包方案的优势在于轻,不用定义类、不用处理self、不用额外维护实例化过程,几个函数一返就完事。写小工具脚本或者做函数式风格代码的时候,非常顺滑。
3. 装饰器:闭包最经典的应用
3.1 装饰器的本质:语法糖背后的函数替换
说清楚闭包,再看装饰器就轻松多了。装饰器本质上就是一个“吃进函数、吐出函数”的闭包函数。它的作用是不修改原函数的源代码,但给这个函数动态加上一些额外能力。
最原始的用法,完全不靠语法糖,是下面这个样子:
def log(func): def wrapper(*args, **kwargs): print(f"调用 {func.__name__}") return func(*args, **kwargs) return wrapper def say_hello(): print("hello") say_hello = log(say_hello) say_hello()log接收一个函数,返回一个新的包装函数wrapper。wrapper在调用原函数之前打印日志,再一层层往里执行。等你执行say_hello()时,实际跑的是wrapper()。
而@语法糖只是把这行say_hello = log(say_hello)换成了更简洁的写法:
@log def say_hello(): print("hello")这两段代码完全等价。理解了等价关系,你就不会再觉得装饰器神秘了。所谓装饰器,就是“拿着你定义的函数,放进另一个函数里加工一遍,再吐出来一个功能增强的函数,还继续用原来的函数名来指代它”。
3.2 动手写第一个实用装饰器:统计函数耗时
我当年第一次真正觉得装饰器有用,是给一批数据处理函数统一加耗时统计的时候。原始版本是拿着start_time = time.time()在每个函数体里复制粘贴,后来函数多了,发现既丑又容易漏,就换成了装饰器写法:
import time import functools def timer(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) cost = time.perf_counter() - start print(f"[{func.__name__}] 执行耗时:{cost:.6f} 秒") return result return wrapper @timer def process_data(n): total = 0 for i in range(n): total += i ** 2 return total process_data(1000000) # [process_data] 执行耗时:0.079123 秒这个装饰器有几点值得记住:
wrapper(*args, **kwargs)是标准写法的标配。理由很简单:你写装饰器的时候根本不知道目标函数会接收什么参数,用*args, **kwargs把所有参数原样转发进去,才能适配任意函数。result = func(...)要把原函数的结果保存下来,最后return result。否则所有被装饰的函数都会“吃掉返回值”,变成返回None。- 用
time.perf_counter()而不是time.time()来测耗时。time.time()受系统时间调整影响,而perf_counter()专门用于测量短时间间隔,精度高、不受系统时钟漂移干扰,性能测试更靠谱。
3.3 被忽略的细节:为什么一定要 functools.wraps
如果按上面第一版的log装饰器直接写,用法没什么问题,但你打印一下装饰后的函数信息,会发现:
def log(func): def wrapper(*args, **kwargs): print(f"调用 {func.__name__}") return func(*args, **kwargs) return wrapper @log def say_hello(): """这是一个打招呼的函数""" print("hello") print(say_hello.__name__) # wrapper,而不是 say_hello print(say_hello.__doc__) # None,而不是 “这是一个打招呼的函数”问题来了:装饰器把原函数“伪装”成了wrapper,函数的名字、注释文档全都丢了。这在调试、生成 API 文档、以及某些依赖函数元信息的框架里都是个大麻烦。比如 Flask 路由擅自改了函数名,可能影响端点名称导致各种诡异错误。
解决办法就是在装饰器的内部函数上使用functools.wraps:
import functools def log(func): @functools.wraps(func) def wrapper(*args, **kwargs): print(f"调用 {func.__name__}") return func(*args, **kwargs) return wrapperfunctools.wraps做的核心事情,就是把原函数的__name__、__doc__、__module__、__qualname__等元数据拷贝到wrapper上,同时还会在wrapper上设置一个__wrapped__属性指向原始函数。这样,就算包装过了,很多工具(比如help()、inspect模块)依然能追踪到被包装的原始函数。
建议从今天起,你写的所有装饰器,只要是自己封装的,统一加@functools.wraps(func)。不加不会报错,但属于“留坑”。等出了问题再排查,成本远高于一开始就写好的那一行。
3.4 装饰器可以叠加:多个装饰器的执行顺序
装饰器是可以一层套一层的。比如先计时、再加日志:
@log @timer def compute(): ...这个写法等价于:
compute = log(timer(compute))顺序从下往上应用,执行时从外往里进入。好比你穿了件外套、又穿了件羽绒服:穿的时候先穿里面的,再穿外面的;脱的时候先脱外面的,再脱里面的。程序也一样,调用函数时先执行log的wrapper,再进入timer的wrapper,最后才到达真正的原函数。
理解这个顺序很重要。假如你的装饰器之间有依赖关系,比如某个装饰器依赖另一个装饰器设置的全局状态,就必须严格设计上下顺序。否则你会看到“一会儿生效、一会儿失效”的诡异现象。
4. 进阶玩法:带参数的装饰器、类装饰器和多层组合
4.1 带参数的装饰器:给装饰器再包一层
有时候装饰器本身需要“参数”,比如你要控制日志级别、指定重试次数。这就得在原本“装饰器 = 外层函数返回内部函数”的结构上,再套一层函数:
这里我一直用两层结构,现在换成三层:最外层接收“装饰器的参数”,中间层接收“函数”,最内层接收“函数的参数”。
import functools def repeat(times): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for _ in range(times): result = func(*args, **kwargs) return result return wrapper return decorator @repeat(times=3) def greet(name): print(f"hello, {name}") greet("小明") # hello, 小明 # hello, 小明 # hello, 小明用法是@repeat(times=3),本质就是greet = repeat(times=3)(greet)。先执行repeat(times=3),得到真正的装饰器decorator,再把greet传进去。写这种装饰器,很多人会在层数上绕晕。我的经验是:数数你要“传几个参数”来决定层数,函数参数(*args, **kwargs)那层不算。如果装饰器本身需要配置参数,那就是三层的结构。顺序固定是:参数层 → 函数层 → 函数参数层。
4.2 类装饰器:用类实现装饰器
函数可以当装饰器,类也可以。只要类实例能被调用,也就是定义了__call__方法,实例就能像函数一样被调用。类装饰器最大的好处是你可以在实例上保存更多状态信息。
import functools class CountCalls: def __init__(self, func): functools.update_wrapper(self, func) self.func = func self.calls = 0 def __call__(self, *args, **kwargs): self.calls += 1 print(f"{self.func.__name__} 已被调用 {self.calls} 次") return self.func(*args, **kwargs) @CountCalls def say_hello(): print("hello") say_hello() say_hello() # say_hello 已被调用 1 次 # hello # say_hello 已被调用 2 次 # hello这里用到了functools.update_wrapper(self, func),在自定义类装饰器里它等价于函数装饰器中的@functools.wraps(func),作用同样是复制函数元数据。类装饰器的场景一般是要维护跨多次调用的状态(比如缓存、统计调用次数),比单纯用闭包内部的nonlocal变量存状态更直观,也更容易扩展。缺点就是代码量稍微大一点。
4.3 多层装饰器实战:路由、权限、日志怎么组合
在实际项目中,装饰器不太会单打独斗。比如一个 Web 接口可能同时涉及“路由注册”“权限校验”“操作日志”三层逻辑。如果你把它们拆成三个装饰器,代码会非常清爽:
def register_route(path): def decorator(func): # 伪代码:把 path 和 func 关联到路由表 print(f"注册路由:{path} -> {func.__name__}") return func return decorator def require_permission(level): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): user_level = kwargs.get("user_level", 0) if user_level < level: raise PermissionError(f"需要权限等级 {level}") return func(*args, **kwargs) return wrapper return decorator def log_operation(func): @functools.wraps(func) def wrapper(*args, **kwargs): print(f"记录操作日志:{func.__name__}") return func(*args, **kwargs) return wrapper @register_route("/api/foo") @require_permission(2) @log_operation def foo_handler(**kwargs): print("业务处理") return "ok"执行顺序是自下而上:先进入log_operation打日志,再经过require_permission校验权限,最后返回给register_route把路由注册依赖提取出来。这种拆分以后,每个装饰器只关心一件事,职责单一,测试起来也容易。
5. 项目中真正用得上的装饰器设计
5.1 重试装饰器:爬虫和网络请求的保命符
写爬虫或者调用第三方API,最讨厌的就是偶发网络超时。一次失败不代表永远失败,重试是常规手段。但如果每个请求都手动try/except包一层,代码会烦死人。所以我一般会把“重试”抽象成装饰器:
import time import functools def retry(max_retries=3, delay=1.0, exceptions=(Exception,)): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): last_exc = None for attempt in range(1, max_retries + 1): try: return func(*args, **kwargs) except exceptions as exc: last_exc = exc print(f"第 {attempt} 次调用 {func.__name__} 失败:{exc}") if attempt < max_retries: time.sleep(delay) raise last_exc return wrapper return decorator @retry(max_retries=5, delay=0.5, exceptions=(TimeoutError, ConnectionError)) def fetch_data(): # 模拟网络请求 raise ConnectionError("网络连接失败") return {"data": 123}这里有几个设计点值得展开:
exceptions参数允许你指定“哪些异常才值得重试”。比如KeyError这种业务逻辑错误,重试一万次也没用,直接抛出来才是正道;而TimeoutError、ConnectionError这种瞬时错误才适合重试。- 重试之间加
time.sleep(delay)是必须的。不加延迟硬重试,遇到服务端压力大时只会加重对方负担,导致“越重试越失败”。实际项目里还可以升级为指数退避:delay * (2 ** attempt),效果更好。 - 多次重试都失败后,要把最后一次异常原样抛出去,不要吞掉。不然调用方就接收不到失败信号,容易把错误隐藏起来。
5.2 缓存装饰器:避免重复计算
某些计算量大、但输入相同结果就相同的“纯函数”,很值得加缓存。比如游戏里频繁计算角色属性面板,数值从一堆配置里算出来,每次都从头算一遍很浪费:
import functools def cache_result(func): cache = {} @functools.wraps(func) def wrapper(*args, **kwargs): # 注意:这个简化版只以位置参数为 key,实际用的时候可以结合 kwargs 做一个规范化的 key key = args if key not in cache: cache[key] = func(*args, **kwargs) print(f"首次计算:{func.__name__}") else: print(f"命中缓存:{func.__name__}") return cache[key] return wrapper @cache_result def compute_heavy(x, y): # 模拟复杂的计算过程 result = 0 for i in range(100000): result += x * y + i return result顺手提一句:如果你搞的是深度学习的推理流程,或者无状态Web接口,这类缓存思路就是“备忘录模式”的雏形。生产环境里建议直接用现成的工具,比如functools.lru_cache,带最大缓存上限、线程安全都有考虑,比自己手搓更强。
5.3 输入校验与参数标准化
装饰器也特别适合做函数的“入口统一收口”。比如多个函数都需要把接收到的参数转成int,但对调用方希望保持宽容,也就是字符串、浮点数都收:
import functools def coerce_args(*coerce_names): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): new_kwargs = dict(kwargs) for name in coerce_names: if name in kwargs: new_kwargs[name] = int(kwargs[name]) return func(*args, **kwargs) return wrapper return decorator @coerce_args("times") def repeat_print(text, times): for _ in range(times): print(text) repeat_print("hello", times="3") # hello # hello # hello这种装饰器项目中很常见。尤其在前后端联调时,前端传来的是字符串数字,后端接口统一收口做类型转换,比在函数内部一个个写int()干净得多。这跟 Web 框架里“请求参数解析器”做的事情有异曲同工之妙,只是用装饰器的形式更轻量,不需要引入框架。
6. 常见问题与排查思路速查表
写了这么多年Python,闭包和装饰器相关的报错和诡异问题,我整理了一份速查表,先给大家列出来。
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 循环里创建的 lambda 闭包全部返回同一个值 | 闭包捕获的是循环变量本身,而非每个循环瞬间的值,循环结束后变量停在最后 | 使用默认参数lambda i=i: i或中间工厂函数 |
| 在闭包内部给外层变量赋值直接报 UnboundLocalError | 函数内有赋值语句,Python 默认把变量当作局部变量 | 在内部函数加nonlocal声明 |
被装饰后的函数__name__、__doc__丢失 | 装饰器返回了包装函数,覆盖了原函数元信息 | 在包装函数上加@functools.wraps(func) |
| 被装饰的函数返回结果永远是 None | 装饰器的 wrapper 里没有把func(*args)的结果 return 回去 | 保存结果并以return result返回 |
带参数的装饰器报错:takes 1 positional argument but 2 were given | 层数写错了,把配置参数当成函数参数传给装饰器 | 检查结构是否为“参数层→函数层→函数参数层”三层 |
| 多个装饰器组合后行为跟预期的先后顺序不一致 | 没有搞清楚装饰器是自下而上应用、执行时从外往里进入 | 画出等价形式func = deco1(deco2(func))再分析 |
| 使用类装饰器后原函数信息丢失 | 类装饰器没有复制函数元数据 | 在__init__里调用functools.update_wrapper(self, func) |
以上问题里,第一个和第三个是我见过最多的。尤其第一个,面试官十分钟里能问出三道变体。你只要记住“闭包捕获的是变量本身,不是变量的值”这个底层原则,绝大多数变形题都能迎刃而解。
写装饰器还有一个调试小技巧:如果总觉得装饰器调用过程不透明,可以临时在返回的wrapper里加打印,看看调用链是怎么走的。调试完再删。另外,Python 3 之后有inspect.unwrap()和__wrapped__属性,在需要拿到“最原始函数”做操作时非常方便。
这个知识点学完之后,我给你的建议是别停留在“看懂了”的层面。找一天时间,把计时器、重试、缓存这三个装饰器各写一遍,跑到报错、再修好,这个过程比刷一百道题都管用。等你写出第一个好用的装饰器,你会明显感觉Python代码的组织方式上升一个台阶。