Python闭包详解:从作用域到装饰器的完整实践指南
我第一次真正被Python的“函数是一等公民”这句话撞了一下腰是在学闭包的时候。看教程时闭包定义背得滚瓜烂熟真到自己写装饰器、画爬虫回调却发现代码总是静悄悄地出错。后来我把闭包拆成三个问题反复想内层函数记住的到底是什么它什么时候记录、什么时候取值为什么这层关系能帮助我写出更简洁的代码想通之后闭包反而成了我日常用的最多的Python特性之一。这篇笔记就是我反复踩坑后的总结写给那些已经学完基础语法、但看到“闭包”两个字就开始头疼的Python学习者。我会用大量能直接跑起来的例子把作用域、函数对象、延迟绑定、nonlocal这些概念串起来。1. 从作用域开始认识闭包1.1 一个让你舒服点的起点计数器学闭包之前我建议你先亲手写一个“会记住自己算到哪”的函数。最直觉的写法是用全局变量count 0 def next_count(): global count count 1 return count print(next_count()) # 1 print(next_count()) # 2这样能工作但全局变量就像家里大门敞开谁都能改。多写几个函数之后你根本说不清count被谁动过。更好的办法是把状态“藏”进一个函数作用域内部def make_counter(): count 0 def next_count(): nonlocal count count 1 return count return next_count counter make_counter() print(counter()) # 1 print(counter()) # 2 print(counter()) # 3这个例子就是闭包。next_count定义在make_counter内部它引用了外层的count而make_counter又把next_count返回到了外面。你调用counter()的时候make_counter早就执行完了可是count并没有消失它被next_count“记住”了。我第一次看到这里觉得有点反直觉局部变量不是函数结束就应该回收吗答案就在作用域链的设计里。1.2 作用域链Python 怎么找变量Python 里每个函数都有自己的命名空间但变量查找不是只在当前函数里找。解释器会按照LGB顺序向内向外逐层找先看局部Local再找闭包Enclosing然后是全局Global最后是内建Built-in。这就是常说的LEGB。回到make_counter的例子当next_count内部执行count 1时先在next_count自己的局部作用域里找count找不到接着向上找到make_counter的局部作用域找到了于是就用这个count。关键在于Python 并不是简单地把值复制一份给内层函数而是建立了一条可以被持有的“引用链”。这也是为什么闭包能存活虽然make_counter帧已经结束但next_count这个函数对象仍然带着它当时所在的作用域引用。生活里拿快递柜打比方外层函数是快递柜的注册系统内层函数是取件码。你取件的时候注册中心可能已经下班但取件码仍然能查到你的包裹。闭包就是把“环境信息”打包进函数本身。1.3 闭包的定义和成立条件一般资料里闭包的定义一个函数加上它捕获的自由变量就构成闭包。所谓“自由变量”就是既不在内层函数局部作用域、也不是全局变量而是来自外层函数作用域的变量。要构成一个Python闭包需要同时满足三个条件必须有嵌套函数即一个函数内部再定义另一个函数。内层函数必须引用外层函数作用域里的变量自由变量。外层函数必须返回内层函数或者以某种方式把内层函数暴露出去。注意第三点很关键。如果只是嵌套定义一个函数但外层不把它返回那内层函数随着外层函数调用结束就被销毁了谈不上“记住环境”。只有当你把内层函数对象拿出来它和自由变量的绑定才会独立存活。你不需要记这个定义的每个字但可以用它来判断一段代码是不是闭包。判断步骤就两步先看有没有嵌套函数再看内层函数有没有引用外层变量。这比背概念好用得多。后面我会反复使用这个判断方法。2. 闭包的本质函数与环境的捆绑2.1 函数也是对象函数也有属性“函数是一等公民”这句话听起来空落到代码里就是函数可以赋值给变量可以作为参数传进另一个函数也可以作为返回值。既然函数是对象它就能挂属性。Python 里每个闭包函数都有一个叫__closure__的属性这个属性专门用来保存它捕获的自由变量。你可以自己动手验证def outer(x): def inner(y): return x y return inner f outer(10) print(f.__closure__) # (cell at 0x...: int object at 0x...,) print(f.__closure__[0].cell_contents) # 10 print(f(5)) # 15__closure__是一个元组里面每个元素对应一个捕获变量。我在学习时习惯把它当成“透视镜”只要看到闭包总觉得玄乎打印一下cell_contents立刻就知道函数记住了什么。比如f outer(10)和g outer(20)是两个不同的函数对象尽管它们源自同一个outer但各自__closure__里存的值分别是10和20。这说明闭包不是模板而是实例化的个体。2.2 闭包捕获的是变量不是当时的值这里是我踩过最坑的地方闭包捕获的是变量本身而不是创建时的快照。变量后续怎么变闭包使用到的也是最新值。看这个例子def outer(): x 10 def inner(): return x x 20 return inner f outer() print(f()) # 20不是10因为inner拿到的x是同一个 cellouter里把x从10改成20cell 里的内容也跟着改成20。这种机制叫“延迟取值”内层函数调用时才去 cell 里取值。所以闭包和“把参数默认值固定下来”完全是两回事。这一点和面向对象里的self属性很像对象的方法读取self.xxx时也是读取最新的实例状态不保存过去的快照。理解了这个你就明白装饰器为什么能读到函数的最新状态。2.3 cell 对象与延迟绑定刚才提到的 cell 对象就是__closure__元组里那一个个小格子。它存在的意义是让内外两层函数共享同一个存储位置。所以在内层函数里给自由变量重新赋值不能直接写x 20那样只会创建一个新的局部变量Python 会直接报错“local variable x referenced before assignment”。这时必须使用nonlocal关键字声明def outer(): x 10 def inner(): nonlocal x x 1 return x return inner f outer() print(f()) # 11 print(f()) # 12nonlocal告诉解释器别在inner局部新建x去找外层作用域的同名变量并修改它。这个关键字可以说是闭包“写操作”的通行证。没有它闭包只能读外层变量不能改。用的时候注意nonlocal声明必须放在变量使用之前否则一样会报错。3. 闭包的应用场景从装饰器到回调3.1 装饰器闭包最常见的工程应用如果你想在工程里看到闭包最常见的地方就是装饰器。装饰器的本质就是“接收一个函数返回一个新函数”。这个新函数通常会把原函数包装一层并在前后增加逻辑。这正是闭包外层函数拿到func内层函数引用func并在调用时传递参数。import time def timer(func): def wrapper(*args, **kwargs): start time.perf_counter() result func(*args, **kwargs) cost time.perf_counter() - start print(f{func.__name__} 耗时 {cost:.6f}s) return result return wrapper def hello(): time.sleep(0.1) return ok hello timer(hello) print(hello())手动把hello timer(hello)写出来闭包结构非常清楚timer是外层函数func是捕获的自由变量wrapper是内层函数timer返回了wrapper。用timer语法糖只是省掉了手动赋值这一步背后逻辑完全相同。我自己写装饰器时最常犯的错误是忘记在wrapper上用*args, **kwargs透传参数。一旦原函数需要参数透传不到位报错会非常莫名其妙。还有一个细节装饰器无法拿到原函数的__name__等元信息因为wrapper.__name__已经是新名字了。这就是为什么标准库要用functools.wraps。使用它可以让原始函数的__name__、__doc__等属性被复制到包装函数上调试和日志输出会友好很多。3.2 记忆化缓存闭包做“备忘录”闭包还有一个非常好用的场景把计算结果缓存下来。斐波那契数列是经典例子纯递归性能差到让人崩溃加上缓存后指数级变线性def make_fib(): cache {0: 0, 1: 1} def fib(n): if n not in cache: cache[n] fib(n - 1) fib(n - 2) return cache[n] return fib fib make_fib() for i in range(10): print(i, fib(i))这里的cache就是闭包捕获的自由变量。每次调用fib它都不会重新计算已经缓存过的子问题。这个模式就是“记忆化”。在实际项目中它不只用于数学计算还常用于爬虫里去重已经请求过的URL直接从缓存里取结果避免重复发起网络请求。量化策略里也常见同一个因子计算结果如果短时间内不会变化就可以用闭包缓存下来减少重复计算带来的开销。当然Python 内置了更高级的functools.lru_cache装饰器可以直接帮你做记忆化from functools import lru_cache lru_cache(maxsize128) def fib(n): if n 2: return n return fib(n - 1) fib(n - 2)但如果你只看官方文档很难理解lru_cache为什么能用。一旦你自己用闭包实现过一遍再看它就只是“一个更聪明的闭包”而已。3.3 爬虫、GUI 回调与量化策略里的闭包闭包并不只活在教科书里。写爬虫时我经常用闭包维护某个请求会话的配置比如同一个站点要带 cookie 和请求头我可以写一个make_request(session, headers)它返回一个只负责发请求的内层函数内层函数引用session和headers调用时只传 URL 和参数。这样一来不同站点用不同配置每个配置都打包成一个独立函数代码清爽很多。GUI 编程里闭包更是神器。比如用 Tkinter 写按钮经常需要在循环里给按钮绑定带参数的回调。如果直接在循环里创建 lambda很容易踩到晚绑定坑。用闭包固定参数就能解决import tkinter as tk root tk.Tk() buttons [] for i in range(3): def make_cmd(idx): def cmd(): print(我被点击了编号, idx) return cmd btn tk.Button(root, textf按钮{i}, commandmake_cmd(i)) btn.pack() buttons.append(btn) root.mainloop()make_cmd(i)每一轮循环都会创建一个新的闭包idx被单独绑定不会共享同一个变量。量化交易策略里也到处是闭包。策略往往需要在多次 tick 之间保持状态比如记录均线已经累计了多少根 K 线。把状态放在闭包里比用全局变量安全得多。这些场景的共同点只有一个某个函数需要“带着状态”被传出去单独使用。只要识别到这个需求闭包就是顺理成章的选择。4. 闭包最常见的坑和排查方法4.1 循环变量延迟绑定经典翻车现场闭包最著名的坑就是循环里创建多个闭包结果它们共享了同一个循环变量。最常见的翻车代码是funcs [] for i in range(3): funcs.append(lambda: i) for f in funcs: print(f()) # 输出2 2 2直觉上你可能期望输出0、1、2实际却全是2。原因就是lambda: i引用的i是循环变量循环结束后i停在2。所有 lambda 共享同一个 cell所以调用时取到的是同一个最终值。这不是 Python bug而是延迟绑定的必然结果。修复方式有两种。第一种是用默认参数把当前值固定下来funcs [] for i in range(3): funcs.append(lambda ii: i)默认参数在函数定义时就被计算ii的意思是把当前循环值作为默认值绑定到局部变量。第二种是使用闭包工厂就像 3.3 里那样def make_func(i): def f(): return i return f funcs [make_func(i) for i in range(3)]我推荐第二种因为它更明确也更容易扩展。遇到这种问题先打印每个函数的__closure__[0].cell_contents你会立刻看到它们存的都是同一个 cell自然就明白了。4.2 nonlocal 使用不当变量未声明导致报错闭包内给外层变量重新赋值最容易遇到UnboundLocalError。我见过很多初学者写出这种代码def outer(): count 0 def inner(): count 1 # 报错 return count return inner原因是count 1这行代码让 Python 把count当作inner的局部变量。因为inner里没有任何声明告诉它这是一层作用域变量解释器一看到赋值就认定它是局部变量。但由于局部变量在使用前没有定义直接报错。加上nonlocal count之后问题消失。有一个细节容易忽略nonlocal只能用于嵌套作用域中存在的变量不能用来声明全局变量全局变量要用global。如果你在外层作用域里都找不到这个变量nonlocal也会抛SyntaxError。写代码之前先确认变量确实定义在外层函数里而不是只在全局。4.3 可变默认参数与闭包的联合翻车闭包和可变默认参数一起使用时也可能出现共享状态的问题。比如def outer(): items [] def inner(item): items.append(item) return items[:] return inner a outer() b outer() a(1) print(b(x)) # [x]独立没问题这个例子因为每次调用outer()都会新建items所以没问题。真正容易翻车的是把可变默认参数放在内层函数里def outer(): def inner(item, cache[]): cache.append(item) return cache return inner这种情况下cache是函数对象创建时绑定的默认值多个闭包共享同一个列表。你可能只想让每个闭包保留自己状态结果嵌套函数外层的每次调用都共享了。排查技巧很简单如果发现内层函数带上[]或{}这种默认参数先停下来想想它是不是有意要共享。通常解决办法是把默认值改成None然后在函数体里新建。4.4 性能与内存闭包不是免费的闭包虽然写起来优雅但不是完全没有成本。内层函数每次调用时都要通过 cell 对象间接取值相比直接访问局部变量会多一点开销。对于大多数业务代码这点开销可以忽略但对于高频调用的小函数比如每秒执行上万次的工具函数就值得你实测一下。我做过简单测试用 100 万次循环对比直接参数传递和闭包读取闭包版本慢大约 5% 到 10%不会到让人无法接受的程度。内存方面更需要注意。闭包会持有外层变量的引用如果这个变量是一个很大的对象闭包函数只要还被外部引用着那个大对象就永远不会被垃圾回收。我记得有一次写爬虫闭包里捕获了一个很大的 HTML 文档对象爬虫任务结束之后因为回调函数还被存在某个列表里内存迟迟降不下来。后来我主动把那个大对象从闭包里移出去或者用del清理引用问题才解决。所以记住闭包让变量活得更久这是特性也是负担。5. 进阶闭包之上函数式编程与设计思路5.1 从闭包到装饰器语法糖闭包是理解装饰器的基础但装饰器本身也有一些进阶用法。装饰器可以带参数也就是在普通装饰器外面再包一层。这实际上形成三层函数最外层接收装饰器参数中间层接收函数内层包装真正的逻辑。def repeat(times): def decorator(func): def wrapper(*args, **kwargs): for _ in range(times): result func(*args, **kwargs) return result return wrapper return decorator repeat(3) def say_hello(): print(hello) say_hello()这个结构初看非常劝退但如果你把每一层都理解成一个普通的闭包就一点也不复杂。repeat(3)返回decoratordecorator(say_hello)返回wrapper。三层函数里的times和func都是自由变量。只要拆开写逻辑一清二楚。我建议遇到装饰器难题时手动去掉用普通函数调用重写一遍相当于“闭包还原法”。5.2 闭包与类/面向对象对比闭包本质上是一种轻量级的封装方式。它可以把“状态”和“行为”绑在一起这个作用看起来和类很像。例如前面那个计数器也可以用类实现class Counter: def __init__(self): self.count 0 def __call__(self): self.count 1 return self.count counter Counter() print(counter()) # 1对比闭包版本类方案更明确有属性、方法、继承等完整能力闭包方案更简练不需要定义类也不需要维护self。我自己的习惯是如果只需要一个私有状态和一两方法用闭包如果需要多个方法互相调用、状态字段不止一个、或者要让别人扩展就用类。闭包还有一个比类隐晦的优点闭包捕获的变量对外部完全不可见除非你专门暴露接口否则外部无法直接修改。而类实例的属性在 Python 里实际上没有真正的私有别人可以通过obj._count强行修改。闭包在“创建真正私有状态”这件事上更彻底。5.3 扩展更现代的替代方案闭包并不是唯一的选择。functools.partial可以固定函数的部分参数本质上相当于一部分闭包功能但它更专注只绑定参数不绑定可变状态。生成器、协程也可以用来保存状态比如写成yield的形式状态保存在生成器栈帧里。协程尤其适合处理异步流程比闭包状态管理更直白。到底选哪种我的建议是别迷信哪一种。闭包的优势是“函数式封装”你持有的仍然是一个可调用的函数对象可以把它传进任何接受回调的地方。生成器的优势是“状态暂停与恢复”适合需要分步、可暂停的任务。类的优势是“复杂状态与行为组织”适合对象字段多、方法多的场景。实际项目里这三种经常混用。比如在爬虫框架中外层用类管理会话内层用闭包组装回调再配合生成器实现请求重试。你在读别人代码时看到return inner或return lambda可以快速判断这里在用闭包看到函数里有nonlocal说明闭包需要写自由变量看到decorator闭上眼睛想一下装饰器内部就是三层嵌套。这样读代码的速度会快很多。我自己的经验是闭包不是靠看懂的是靠一遍遍写出来的。学闭包那阵子我把计数器例子在草稿纸上拆了无数遍从__closure__到cell_contents再到nonlocal每一个都亲手验证过。后来写装饰器、写爬虫回调、写策略状态保持闭包反而变成我最自然的肌肉记忆。现在每次用到闭包我心里都会默默问一遍内层函数到底引用了哪些外层变量这些变量会不会在循环里被改如果哪一天闭包又出现了奇怪行为我一定先打印__closure__看看它到底记住了什么。