闭包(closure)是关联了外层词法绑定的函数;即使定义它的调用已经结束,函数仍能读取或更新这些绑定。
闭包保留的是变量绑定,不是创建函数时冻结的值,因此循环中生成的回调可能全都读到最后一次迭代的值。
重新绑定外层状态时使用 nonlocal;每个回调需要独立值或状态时,在创建时绑定参数,或者为每次迭代调用一次工厂。
是什么,为什么存在
闭包是一个函数对象,以及它对定义位置中所需外层绑定的访问能力。在 Python 中,最常见的形式是外层函数创建并返回一个内层函数。外层调用已经返回,内层函数仍可使用那次调用留下的绑定。
内层函数使用、但不在自身代码块中绑定的名称叫作 自由变量(free variable) 。提供该绑定的词法作用域叫作 外层作用域(enclosing scope) 。函数的代码与这些绑定合在一起,才有调用时所需的完整上下文。
闭包让函数在作为值传递时仍遵守词法作用域。它适合把少量配置附在回调上,或者通过一个很窄的函数接口保存状态,而无需模块全局变量。函数工厂、装饰器、验证器、事件处理器,以及传给 sorted() 的键函数中都能见到它。
返回内层函数不是形成闭包的语义条件,只是让闭包的生命周期最容易观察。只要嵌套函数引用了外层函数的绑定,它在外层调用期间执行时也仍是闭包。反过来,嵌套函数若只使用参数、局部名称和全局名称,__closure__ 就是 None。
闭包适合一种主要操作和少量容易命名的状态。如果调用方需要多个公开操作、验证规则、可序列化表示或继承关系,类通常能更直接地表达契约。选择依据是接口与所有权,而不是笼统的速度或内存说法。
工作原理
Python 按源码中的嵌套关系解析名称,而不是查看谁调用了当前函数。编译器发现内层代码引用了外层函数绑定的名称时,会把该名称归类为自由变量,并安排共享存储。调用栈上另一个同名局部变量不会改变这种选择。
每次执行外层函数,都会创建属于本次调用的局部绑定。创建内层函数时,函数对象取得访问所需绑定的路径;返回函数不会把对象值复制成快照。后续调用读取的是绑定当时指向的对象,所以重新绑定会改变闭包之后看到的结果。
一次闭包创建和调用可以按下面的顺序理解:
- 调用外层函数,为这次调用创建局部绑定。
- 执行内层函数定义,识别并连接它实际使用的外层绑定。
- 把内层函数传出外层调用,或者在外层调用中直接使用它。
- 外层调用结束后,仍被闭包引用的绑定继续存活。
- 调用闭包时,名称查找读取或更新同一个绑定。
闭包不会自动保留外层函数的整个局部命名空间。只有内层代码实际作为自由变量使用的绑定需要留下。不过,一个绑定可能指向很大的可变对象或对象图,所以「只捕获一个名称」并不等于保留的数据一定很少。
读取、修改与重新绑定
读取自由变量不需要声明。修改自由变量所指向的可变对象也不需要声明,因为绑定本身没有变化。只有内层函数要让名称指向另一个对象时,才需要 nonlocal。
| 内层操作 | 发生的事 | 是否需要 nonlocal |
|---|---|---|
return count | 读取外层绑定 | 否 |
items.append(value) | 修改绑定所指向的列表 | 否 |
count += 1 | 读取后重新绑定名称 | 是 |
items = [] | 把名称重新绑定到新列表 | 是 |
count += 1 容易让人误判。整数不能原地修改,因此该语句先读取旧整数,再计算新整数,最后给 count 赋值。只要函数体中存在这种赋值,count 默认就被归类为该函数的局部名称;没有 nonlocal count 时,读取尚未赋值的局部名称会抛出 UnboundLocalError。
nonlocal 的目标
nonlocal name 指向最近一个已经绑定该名称的外层函数作用域。它不会创建外层绑定,也不能指向模块全局作用域。编译器找不到合适绑定时,会在代码执行前抛出 SyntaxError。
global 与 nonlocal 解决的不是同一个问题。global 把赋值指向模块命名空间;把生成代码中的 nonlocal 机械替换为 global,会把本应属于某次工厂调用的状态变成整个模块共享的状态。
同一次外层调用创建的多个函数可以共享绑定;不同外层调用通常取得不同绑定。因此,「函数来自同一个工厂」不表示它们共享状态,真正决定共享关系的是它们来自哪一次工厂调用,以及引用了哪个绑定。
示例
下面四个示例从只读配置开始,再加入可变状态、共享操作和循环回调。所有输出都由本地 python3 实际执行对应文件得到。
携带只读配置
标签工厂把前缀配置一次,返回只接收订单编号的函数。prefix 只被读取,因此不需要 nonlocal。
def make_labeler(prefix):
def label(order_id):
return f"{prefix}-{order_id:04d}"
return label
invoice_label = make_labeler("INV")
return_label = make_labeler("RET")
print(invoice_label(7))
print(return_label(7))
print(invoice_label.__code__.co_freevars)
print(invoice_label.__closure__[0].cell_contents)INV-0007
RET-0007
('prefix',)
INV两次 make_labeler() 调用各自创建一个 prefix 绑定,所以两个标签函数互不影响。co_freevars 给出自由变量名称,__closure__ 中相同位置的 cell 保存对应绑定;这种按位置检查适合诊断,不适合作为业务接口。
字符串对象恰好不可变,但这不是闭包复制了字符串。函数仍通过自己的绑定访问 "INV"。如果外层代码后来通过另一个闭包重新绑定同一个名称,读取函数就会看到新对象。
用 nonlocal 保存状态
计数器需要让 count 指向一个新整数,所以 record() 明确声明 nonlocal count。每次工厂调用都产生独立计数。
def make_attempt_counter(start=0):
count = start
def record():
nonlocal count
count += 1
return count
return record
email_attempt = make_attempt_counter()
sms_attempt = make_attempt_counter(10)
print(f"email: {email_attempt()}, {email_attempt()}")
print(f"sms: {sms_attempt()}")
print(f"email: {email_attempt()}")email: 1, 2
sms: 11
email: 3交错调用表明两个计数器没有泄漏状态。把 email_attempt 赋给另一个名称不会创建新计数器,因为两个名称仍指向同一个函数对象;需要独立状态时,必须再次调用工厂。
这个接口只允许调用方推进计数,不能直接把 count 设成任意值。它提供的是作用域形成的封装约定,不是安全边界;检查函数对象的代码仍能看到 cell 内容。
让多个操作共享一个 cell
一次 make_quota() 调用返回两个函数。reserve() 重新绑定 remaining,available() 读取它;两者连接到同一个 cell。
def make_quota(limit):
remaining = limit
def reserve(units):
nonlocal remaining
if units <= 0:
raise ValueError("units must be positive")
if units > remaining:
return False
remaining -= units
return True
def available():
return remaining
return reserve, available
reserve, available = make_quota(5)
print(reserve(2), available())
print(reserve(4), available())
print(reserve(3), available())True 3
False 3
True 0第二次预留失败后,remaining 没有变化。两个函数共享状态使这个结果可以从 available() 观察到。若操作继续增多,返回一组匿名位置上的函数会变得难读,此时带有 reserve() 和 available() 方法的类通常更合适。
共享 cell 不提供同步。若多个线程或异步任务可以交错执行检查与扣减,if units > remaining 和 remaining -= units 不是一个业务原子操作;需要根据执行模型增加锁或把状态交给单一所有者。
对比延迟绑定与创建时绑定
循环中的两个列表都生成三个 lambda。第一组直接引用循环变量,第二组把当轮对象保存为默认参数。
def make_handlers(queue_names):
late_handlers = []
bound_handlers = []
for queue_name in queue_names:
# 这一组函数共享循环变量的 cell。
late_handlers.append(lambda: queue_name)
# 默认参数在创建函数时保存当前对象。
bound_handlers.append(lambda queue_name=queue_name: queue_name)
return late_handlers, bound_handlers
late, bound = make_handlers(["fast", "bulk", "slow"])
print([handler() for handler in late])
print([handler() for handler in bound])
print(late[0].__closure__[0] is late[1].__closure__[0])
print(bound[0].__closure__)['slow', 'slow', 'slow']
['fast', 'bulk', 'slow']
True
None第一组函数在循环结束后才读取 queue_name,此时共享 cell 指向 "slow"。这就是 延迟绑定(late binding) 陷阱。列表推导式有自己的作用域,但在同一次推导中创建并稍后调用的闭包仍会共享那个推导变量。
第二组的左侧 queue_name 是 lambda 的局部形参,右侧名称在执行 lambda 定义时求值。保存后的对象位于函数默认值中,而不是闭包 cell 中,所以 __closure__ 是 None。如果调用方不应覆盖这个参数,调用一次辅助工厂通常比暴露默认参数更清楚。
陷阱
把闭包说成值快照
修复方法: 画出每个自由名称对应的绑定,并标明函数创建与函数调用发生的时间。需要快照时,用默认参数、辅助工厂或显式复制表达这个决定,同时说明复制是浅层还是深层。
遗漏或误用 nonlocal
修复方法: 确实要重新绑定外层函数状态时,靠近函数开头写出 nonlocal。只读或原地修改可变对象时不要机械添加它;目标属于模块时才使用 global。遇到 UnboundLocalError,先检查函数中所有赋值位置,而不只是报错行。
错误放置工厂调用
修复方法: 先写清状态属于应用、队列、请求还是单个回调,再把工厂调用放在对应生命周期的边界。测试时交错调用两个应该独立的实例,也连续调用一个应该保留状态的实例。
在循环中隐藏延迟绑定
修复方法: 在循环结束后再调用所有生成函数,并断言每个结果。只需要固定一个值时可用默认参数;每个回调还需要独立可变状态时,应为每次迭代调用一次命名工厂。
捕获生命周期过长的对象
修复方法: 提取回调真正需要的小型值,不要为了一个字段捕获整个上下文对象。所有者结束时解除注册;文件、套接字和事务等资源应由上下文管理器明确管理,而不是等待闭包被回收。
依赖 __closure__ 的位置
修复方法: 诊断时把 function.__code__.co_freevars 与 function.__closure__ 按位置配对,或者使用 inspect.getclosurevars() 分类查看绑定。生产代码应通过公开函数接口读取或改变状态,不要把 cell 布局当作协议。
绑定、函数对象与 cell
语言层面最重要的保证是词法名称解析和外层绑定的持续可用性。CPython 用 cell 对象(cell object) 实现这种共享存储。其他 Python 实现必须保持可观察语义,但内部结构不必采用完全相同的字节码或对象布局。
编译期名称分类
编译器在函数运行前就确定名称属于局部、自由、cell 还是全局类别。外层函数的局部名称若被内层函数引用,会在外层代码对象的 co_cellvars 中出现;对于内层代码对象,同一名称会出现在 co_freevars 中。
这种分类以整个代码块为单位,而不是沿执行路径临时决定。内层函数中任何赋值都会让目标默认为局部名称,除非 global 或 nonlocal 声明改变分类。因此,把赋值放进 if False: 也不会让更早的读取自动回到外层绑定。
| 观察位置 | 属性 | 表示的内容 |
|---|---|---|
| 外层代码对象 | co_cellvars | 同时由内层代码引用的外层局部名称 |
| 内层代码对象 | co_freevars | 必须由外层作用域提供的名称 |
| 内层函数对象 | __closure__ | 与 co_freevars 按位置对应的 cell 元组 |
| 诊断辅助函数 | inspect.getclosurevars() | 已解析的非局部、全局、内置与未绑定名称 |
代码对象描述的是编译结果,可以由多次工厂调用创建的函数对象共享。__closure__ 属于函数对象,连接的是某次运行产生的 cell。不要把共享代码对象误认为共享运行时状态。
cell 的标识与共享关系
一个 cell 是保存当前对象引用的小型容器。对捕获名称执行 nonlocal 重新绑定时,变化的是 cell 中的引用;其他共享该 cell 的闭包会在下一次读取时看到新对象。对捕获列表执行 append() 时,cell 仍指向原列表,但列表内容已经变化。
同一次外层调用可以创建多个共享 cell 的函数,这正是 reserve() 和 available() 看到同一配额的原因。再次调用工厂会创建新的外层绑定和新 cell,所以新的配额与旧配额隔离。比较 cell 的对象标识可以用于测试这种关系,但不应成为业务控制流。
__closure__ 要么是 None,要么是 cell 元组。读取 cell_contents 可以帮助定位泄漏状态;直接修改 cell 内容会把应用代码绑在实现细节上,还会绕过工厂本来提供的校验和不变量。
默认参数不是捕获 cell
默认表达式在执行 def 或 lambda 表达式时求值,结果保存在函数的默认参数数据中。lambda item=item: item 的左侧 item 是局部形参,因此函数体不需要从外层读取这个名称。这个技巧解决了时机问题,但它不改变 Python 的闭包规则。
默认参数也只保存对象引用,不会自动复制可变对象。如果循环变量指向之后仍会修改的字典,那么每个默认参数虽然各自保存当轮字典对象,调用时仍可能看到该字典的新内容。需要内容快照时,应在绑定前显式复制,并选择符合契约的复制深度。
functools.partial() 可以预先绑定已有可调用对象的实参。它返回偏函数对象,不是通过自由变量保存这些实参的 Python 闭包。选择默认参数、辅助工厂或 partial() 时,应优先考虑调用签名和可读性,而不是把三者说成同一种机制。
多层嵌套与类作用域
多层函数嵌套中,nonlocal 选择最近一个已有同名绑定的外层函数作用域。它不能跳过最近绑定去指定更远的同名绑定,也没有语法按层命名目标。出现多层同名状态时,改名往往比继续依赖隐式层级更清楚。
普通类命名空间不是方法体的外层函数作用域。在类体中写 rate = 0.2,方法里直接使用 rate 不会像嵌套函数那样捕获它;方法应通过 self.rate、type(self).rate 或类名访问类属性。把类体和函数体都叫「外层」会掩盖这项差异。
定义在方法内部的函数可以捕获该方法的局部名称,包括 self。这也意味着返回的回调可能让整个实例保持可达。只需要实例中的一个不可变字段时,先把字段值放入语义明确的局部名称,再决定是否捕获它。
诊断共享状态
怀疑闭包状态错误时,先从源码和调用生命周期入手,再检查运行时对象。下面这组检查通常足够定位问题:
- 用
co_freevars列出内层函数实际读取的自由名称。 - 把这些名称与
__closure__中的 cell 按位置配对。 - 比较两个函数是否引用同一个 cell,而不只比较
cell_contents是否相等。 - 用
inspect.getclosurevars()区分非局部名称、全局名称、内置名称与未绑定名称。
相等的内容不能证明状态共享。两个独立计数器都可能从整数 0 开始,却拥有不同 cell;相反,共享 cell 在不同时刻可以指向不同对象。所有权判断需要看 cell 标识和工厂调用边界。
生命周期与执行模型
闭包让捕获绑定至少活到最后一个引用它的函数不可达为止,但具体回收时间仍由 Python 实现和引用关系决定。不要让正确性依赖析构时机。必须按时释放的资源需要显式关闭、上下文管理器或明确的注销操作。
闭包状态没有特殊的并发保证。线程、任务、信号处理器或重入回调若能访问同一个闭包,检查后更新仍可能发生交错。解决方法与其他共享可变状态相同:缩小所有权、串行化访问,或者使用适合执行模型的同步原语。
标准 pickle 依靠模块中的限定名称定位普通函数,嵌套的局部函数通常无法按这种方式导入。需要把工作提交给进程池或跨进程传输时,不要假设闭包自然可序列化;顶层函数配合显式数据参数通常更可靠。
元数据与包装器行为
装饰器返回的闭包是新的函数对象。没有 functools.wraps() 时,它的 __name__、文档、注解和 __wrapped__ 链接描述的是包装器,而不是被包装函数。这会干扰回溯信息、API 文档和检查签名的工具。
wraps() 会复制选定元数据,并为检查工具暴露被包装对象;它不会改变包装器捕获了哪些值。它也不会隔离状态、增加同步或让局部包装器可序列化。这些仍是独立的设计决定。
类型注解可以描述返回的可调用对象签名,但普通 Python 执行不会强制注解。需要让装饰器在静态类型层面保留任意可调用签名时,可以使用 ParamSpec;运行时闭包机制不会因此改变。
测试生命周期契约
大多数闭包测试应使用公开的可调用接口,而不是 __closure__。执行一组调用,再断言返回值或可观察副作用。这样即使实现以后从闭包改成类,测试仍然有效。
延迟绑定测试必须把调用推迟到循环或注册阶段之后。在循环中立即调用会读到当轮值,可能让错误实现显得正确。迭代值还应彼此不同,避免重复的最终值被测试数据掩盖。
状态隔离测试至少需要两个工厂结果。交错调用它们,再确认各自的序列独立推进。对于刻意共享状态的一组操作,则应在每次修改后从两个接口观察结果。
闭包还是类
闭包在接口只有一种主要调用、状态很少且不需要公开检查时很顺手。它还能让配置后的函数直接交给要求可调用对象的 API。名称良好的工厂与返回函数应说明状态属于谁,以及工厂需要调用多少次。
类适合多个操作、明确属性、协议实现、序列化和独立测试状态转换。把五六个闭包塞进字典或位置元组,往往已经在手工模拟对象接口。此时改用类不是否定闭包,而是让结构与公开契约一致。
延伸阅读
3个问题 · 1 道输出预测题 · 0 道找错题