我第一次真正被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是同一个 cell,outer里把x从10改成20,cell 里的内容也跟着改成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(maxsize=128) 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, text=f"按钮{i}", command=make_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 i=i: i)默认参数在函数定义时就被计算,i=i的意思是把当前循环值作为默认值绑定到局部变量。第二种是使用闭包工厂,就像 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)返回decorator,decorator(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__看看它到底记住了什么。