# 闭包

Source: https://codewiki.com/zh/python/closures/

> - **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`。

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

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

## 示例

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

### 携带只读配置

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

<!-- quick -->

```python
# file: 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)
```

```text
INV-0007
RET-0007
('prefix',)
INV
```


<!-- /quick -->

两次 `make_labeler()` 调用各自创建一个 `prefix` 绑定，所以两个标签函数互不影响。`co_freevars` 给出自由变量名称，`__closure__` 中相同位置的 cell 保存对应绑定；这种按位置检查适合诊断，不适合作为业务接口。

字符串对象恰好不可变，但这不是闭包复制了字符串。函数仍通过自己的绑定访问 `"INV"`。如果外层代码后来通过另一个闭包重新绑定同一个名称，读取函数就会看到新对象。

### 用 `nonlocal` 保存状态

计数器需要让 `count` 指向一个新整数，所以 `record()` 明确声明 `nonlocal count`。每次工厂调用都产生独立计数。

```python
# file: 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()}")
```

```text
email: 1, 2
sms: 11
email: 3
```

交错调用表明两个计数器没有泄漏状态。把 `email_attempt` 赋给另一个名称不会创建新计数器，因为两个名称仍指向同一个函数对象；需要独立状态时，必须再次调用工厂。

这个接口只允许调用方推进计数，不能直接把 `count` 设成任意值。它提供的是作用域形成的封装约定，不是安全边界；检查函数对象的代码仍能看到 cell 内容。

### 让多个操作共享一个 cell

一次 `make_quota()` 调用返回两个函数。`reserve()` 重新绑定 `remaining`，`available()` 读取它；两者连接到同一个 cell。

```python
# file: 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())
```

```text
True 3
False 3
True 0
```

第二次预留失败后，`remaining` 没有变化。两个函数共享状态使这个结果可以从 `available()` 观察到。若操作继续增多，返回一组匿名位置上的函数会变得难读，此时带有 `reserve()` 和 `available()` 方法的类通常更合适。

共享 cell 不提供同步。若多个线程或异步任务可以交错执行检查与扣减，`if units > remaining` 和 `remaining -= units` 不是一个业务原子操作；需要根据执行模型增加锁或把状态交给单一所有者。

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

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

```python
# file: 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__)
```

```text
['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`，先检查函数中所有赋值位置，而不只是报错行。

### 错误放置工厂调用

> **陷阱:** 在循环外只调用一次有状态工厂，再把同一个结果注册给多个使用方，会让它们意外共享 cell。相反，在每次处理事件时重新调用工厂，又会让本应持续的状态不断归零。

**修复方法：** 先写清状态属于应用、队列、请求还是单个回调，再把工厂调用放在对应生命周期的边界。测试时交错调用两个应该独立的实例，也连续调用一个应该保留状态的实例。

### 在循环中隐藏延迟绑定

> **陷阱:** 延迟绑定不只出现在 `lambda` 中。循环内的嵌套 `def`、回调注册、任务完成处理器和推导式都可能让多个函数共享最后一个迭代绑定。

**修复方法：** 在循环结束后再调用所有生成函数，并断言每个结果。只需要固定一个值时可用默认参数；每个回调还需要独立可变状态时，应为每次迭代调用一次命名工厂。

### 捕获生命周期过长的对象

> **陷阱:** 长期注册的闭包会让它使用的绑定保持可达。若绑定指向请求上下文、缓存、服务容器或大型数据集，这些对象可能比实际工作存活更久。

**修复方法：** 提取回调真正需要的小型值，不要为了一个字段捕获整个上下文对象。所有者结束时解除注册；文件、套接字和事务等资源应由上下文管理器明确管理，而不是等待闭包被回收。

### 依赖 `__closure__` 的位置

> **陷阱:** `function.__closure__[0]` 没有稳定的业务含义。增加另一个自由变量后，名称与 cell 的位置可能变化；没有自由变量时，`__closure__` 还是 `None`。

**修复方法：** 诊断时把 `function.__code__.co_freevars` 与 `function.__closure__` 按位置配对，或者使用 `inspect.getclosurevars()` 分类查看绑定。生产代码应通过公开函数接口读取或改变状态，不要把 cell 布局当作协议。

<!-- deep -->

## 绑定、函数对象与 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`。这也意味着返回的回调可能让整个实例保持可达。只需要实例中的一个不可变字段时，先把字段值放入语义明确的局部名称，再决定是否捕获它。

### 诊断共享状态

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

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。名称良好的工厂与返回函数应说明状态属于谁，以及工厂需要调用多少次。

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

<!-- /deep -->

[检查点: python/closures](https://codewiki.com/zh/python/closures/#checkpoint)

## 延伸阅读

- [Python 语言参考：名称与绑定](https://docs.python.org/3.14/reference/executionmodel.html#naming-and-binding)
- [Python 语言参考：`nonlocal` 语句](https://docs.python.org/3.14/reference/simple_stmts.html#the-nonlocal-statement)
- [Python 数据模型：用户定义函数](https://docs.python.org/3.14/reference/datamodel.html#user-defined-functions)
- [Python `inspect.getclosurevars()`](https://docs.python.org/3.14/library/inspect.html#inspect.getclosurevars)
- [Python 编程常见问题：循环中的 lambda](https://docs.python.org/3.14/faq/programming.html#why-do-lambdas-defined-in-a-loop-with-different-values-all-return-the-same-result)
- [PEP 3104：访问外层作用域中的名称](https://peps.python.org/pep-3104/)
