闭包

闭包让函数在离开定义位置后仍能访问外层绑定;理解 cell、nonlocal 与延迟绑定,才能安全地保存状态并生成回调。

难度 进阶 时长 标准深度约 12分钟
版本 Python 3.14
what

闭包(closure)是关联了外层词法绑定的函数;即使定义它的调用已经结束,函数仍能读取或更新这些绑定。

trap

闭包保留的是变量绑定,不是创建函数时冻结的值,因此循环中生成的回调可能全都读到最后一次迭代的值。

fix

重新绑定外层状态时使用 nonlocal;每个回调需要独立值或状态时,在创建时绑定参数,或者为每次迭代调用一次工厂。

是什么,为什么存在

闭包是一个函数对象,以及它对定义位置中所需外层绑定的访问能力。在 Python 中,最常见的形式是外层函数创建并返回一个内层函数。外层调用已经返回,内层函数仍可使用那次调用留下的绑定。

内层函数使用、但不在自身代码块中绑定的名称叫作 自由变量(free variable) 。提供该绑定的词法作用域叫作 外层作用域(enclosing scope) 。函数的代码与这些绑定合在一起,才有调用时所需的完整上下文。

闭包让函数在作为值传递时仍遵守词法作用域。它适合把少量配置附在回调上,或者通过一个很窄的函数接口保存状态,而无需模块全局变量。函数工厂、装饰器、验证器、事件处理器,以及传给 sorted() 的键函数中都能见到它。

返回内层函数不是形成闭包的语义条件,只是让闭包的生命周期最容易观察。只要嵌套函数引用了外层函数的绑定,它在外层调用期间执行时也仍是闭包。反过来,嵌套函数若只使用参数、局部名称和全局名称,__closure__ 就是 None

闭包适合一种主要操作和少量容易命名的状态。如果调用方需要多个公开操作、验证规则、可序列化表示或继承关系,类通常能更直接地表达契约。选择依据是接口与所有权,而不是笼统的速度或内存说法。

工作原理

Python 按源码中的嵌套关系解析名称,而不是查看谁调用了当前函数。编译器发现内层代码引用了外层函数绑定的名称时,会把该名称归类为自由变量,并安排共享存储。调用栈上另一个同名局部变量不会改变这种选择。

每次执行外层函数,都会创建属于本次调用的局部绑定。创建内层函数时,函数对象取得访问所需绑定的路径;返回函数不会把对象值复制成快照。后续调用读取的是绑定当时指向的对象,所以重新绑定会改变闭包之后看到的结果。

一次闭包创建和调用可以按下面的顺序理解:

  1. 调用外层函数,为这次调用创建局部绑定。
  2. 执行内层函数定义,识别并连接它实际使用的外层绑定。
  3. 把内层函数传出外层调用,或者在外层调用中直接使用它。
  4. 外层调用结束后,仍被闭包引用的绑定继续存活。
  5. 调用闭包时,名称查找读取或更新同一个绑定。

闭包不会自动保留外层函数的整个局部命名空间。只有内层代码实际作为自由变量使用的绑定需要留下。不过,一个绑定可能指向很大的可变对象或对象图,所以「只捕获一个名称」并不等于保留的数据一定很少。

读取、修改与重新绑定

读取自由变量不需要声明。修改自由变量所指向的可变对象也不需要声明,因为绑定本身没有变化。只有内层函数要让名称指向另一个对象时,才需要 nonlocal

内层操作发生的事是否需要 nonlocal
return count读取外层绑定
items.append(value)修改绑定所指向的列表
count += 1读取后重新绑定名称
items = []把名称重新绑定到新列表

count += 1 容易让人误判。整数不能原地修改,因此该语句先读取旧整数,再计算新整数,最后给 count 赋值。只要函数体中存在这种赋值,count 默认就被归类为该函数的局部名称;没有 nonlocal count 时,读取尚未赋值的局部名称会抛出 UnboundLocalError

nonlocal 的目标

nonlocal name 指向最近一个已经绑定该名称的外层函数作用域。它不会创建外层绑定,也不能指向模块全局作用域。编译器找不到合适绑定时,会在代码执行前抛出 SyntaxError

globalnonlocal 解决的不是同一个问题。global 把赋值指向模块命名空间;把生成代码中的 nonlocal 机械替换为 global,会把本应属于某次工厂调用的状态变成整个模块共享的状态。

同一次外层调用创建的多个函数可以共享绑定;不同外层调用通常取得不同绑定。因此,「函数来自同一个工厂」不表示它们共享状态,真正决定共享关系的是它们来自哪一次工厂调用,以及引用了哪个绑定。

示例

下面四个示例从只读配置开始,再加入可变状态、共享操作和循环回调。所有输出都由本地 python3 实际执行对应文件得到。

携带只读配置

标签工厂把前缀配置一次,返回只接收订单编号的函数。prefix 只被读取,因此不需要 nonlocal

label_factory.py
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。每次工厂调用都产生独立计数。

attempt_counter.py
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() 重新绑定 remainingavailable() 读取它;两者连接到同一个 cell。

shared_quota.py
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 > remainingremaining -= units 不是一个业务原子操作;需要根据执行模型增加锁或把状态交给单一所有者。

对比延迟绑定与创建时绑定

循环中的两个列表都生成三个 lambda。第一组直接引用循环变量,第二组把当轮对象保存为默认参数。

loop_handlers.py
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_freevarsfunction.__closure__ 按位置配对,或者使用 inspect.getclosurevars() 分类查看绑定。生产代码应通过公开函数接口读取或改变状态,不要把 cell 布局当作协议。

深入 绑定、函数对象与 cell

绑定、函数对象与 cell

语言层面最重要的保证是词法名称解析和外层绑定的持续可用性。CPython 用 cell 对象(cell object) 实现这种共享存储。其他 Python 实现必须保持可观察语义,但内部结构不必采用完全相同的字节码或对象布局。

编译期名称分类

编译器在函数运行前就确定名称属于局部、自由、cell 还是全局类别。外层函数的局部名称若被内层函数引用,会在外层代码对象的 co_cellvars 中出现;对于内层代码对象,同一名称会出现在 co_freevars 中。

这种分类以整个代码块为单位,而不是沿执行路径临时决定。内层函数中任何赋值都会让目标默认为局部名称,除非 globalnonlocal 声明改变分类。因此,把赋值放进 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.ratetype(self).rate 或类名访问类属性。把类体和函数体都叫「外层」会掩盖这项差异。

定义在方法内部的函数可以捕获该方法的局部名称,包括 self。这也意味着返回的回调可能让整个实例保持可达。只需要实例中的一个不可变字段时,先把字段值放入语义明确的局部名称,再决定是否捕获它。

诊断共享状态

怀疑闭包状态错误时,先从源码和调用生命周期入手,再检查运行时对象。下面这组检查通常足够定位问题:

  1. co_freevars 列出内层函数实际读取的自由名称。
  2. 把这些名称与 __closure__ 中的 cell 按位置配对。
  3. 比较两个函数是否引用同一个 cell,而不只比较 cell_contents 是否相等。
  4. inspect.getclosurevars() 区分非局部名称、全局名称、内置名称与未绑定名称。

相等的内容不能证明状态共享。两个独立计数器都可能从整数 0 开始,却拥有不同 cell;相反,共享 cell 在不同时刻可以指向不同对象。所有权判断需要看 cell 标识和工厂调用边界。

生命周期与执行模型

闭包让捕获绑定至少活到最后一个引用它的函数不可达为止,但具体回收时间仍由 Python 实现和引用关系决定。不要让正确性依赖析构时机。必须按时释放的资源需要显式关闭、上下文管理器或明确的注销操作。

闭包状态没有特殊的并发保证。线程、任务、信号处理器或重入回调若能访问同一个闭包,检查后更新仍可能发生交错。解决方法与其他共享可变状态相同:缩小所有权、串行化访问,或者使用适合执行模型的同步原语。

标准 pickle 依靠模块中的限定名称定位普通函数,嵌套的局部函数通常无法按这种方式导入。需要把工作提交给进程池或跨进程传输时,不要假设闭包自然可序列化;顶层函数配合显式数据参数通常更可靠。

元数据与包装器行为

装饰器返回的闭包是新的函数对象。没有 functools.wraps() 时,它的 __name__、文档、注解和 __wrapped__ 链接描述的是包装器,而不是被包装函数。这会干扰回溯信息、API 文档和检查签名的工具。

wraps() 会复制选定元数据,并为检查工具暴露被包装对象;它不会改变包装器捕获了哪些值。它也不会隔离状态、增加同步或让局部包装器可序列化。这些仍是独立的设计决定。

类型注解可以描述返回的可调用对象签名,但普通 Python 执行不会强制注解。需要让装饰器在静态类型层面保留任意可调用签名时,可以使用 ParamSpec;运行时闭包机制不会因此改变。

测试生命周期契约

大多数闭包测试应使用公开的可调用接口,而不是 __closure__。执行一组调用,再断言返回值或可观察副作用。这样即使实现以后从闭包改成类,测试仍然有效。

延迟绑定测试必须把调用推迟到循环或注册阶段之后。在循环中立即调用会读到当轮值,可能让错误实现显得正确。迭代值还应彼此不同,避免重复的最终值被测试数据掩盖。

状态隔离测试至少需要两个工厂结果。交错调用它们,再确认各自的序列独立推进。对于刻意共享状态的一组操作,则应在每次修改后从两个接口观察结果。

闭包还是类

闭包在接口只有一种主要调用、状态很少且不需要公开检查时很顺手。它还能让配置后的函数直接交给要求可调用对象的 API。名称良好的工厂与返回函数应说明状态属于谁,以及工厂需要调用多少次。

类适合多个操作、明确属性、协议实现、序列化和独立测试状态转换。把五六个闭包塞进字典或位置元组,往往已经在手工模拟对象接口。此时改用类不是否定闭包,而是让结构与公开契约一致。

延伸阅读

检查点

3个问题 · 1 道输出预测题 · 0 道找错题

下一篇 装饰器 functools 函数工具 Classes objects 即将上线
复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?