AIGC标识 Python 的闭包到底“包”住了什么?拆开 cell 对象看一眼

系列:Python 底层设计逻辑|类型:原理拆解型|分发:CSDN / 博客园 / 腾讯云 / 知乎(回答改写)
字数:约 1700 字|实验环境:CPython 3.13.14(64 位,Windows)

Python 的闭包到底"包"住了什么?拆开 cell 对象看一眼

结论先行:闭包不是"把变量复制进来",而是把一个叫 cell 的盒子跟函数绑在了一起。这个盒子里的内容在函数创建之后还能变——Python 著名的"循环里全是 2"的坑、闭包让大对象多活十年、以及闭包比可调用类还费内存,三个看似无关的现象,全是同一个 cell 对象在背后起作用。这篇用 6 组实验把它拆开。


1. 先看一组实验结果:__closure__ 里有什么

def outer(x):
    def inner(y):
        return x + y
    return inner

f = outer(10)
f.__closure__        # (<cell at 0x...: int object at 0x...>,)
f.__closure__[0].cell_contents   # 10
f.__code__.co_freevars           # ('x',)

一个闭包函数身上挂着 __closure__,里面是一个 cell 对象,cell_contents 就是被"包"的那个值。co_freevars 记着自由变量的名字。

对照实验:如果内层函数压根没用外层变量,__closure__ 直接是 None:

def outer2(x):
    def inner2(y):
        return y + 1     # 没用 x
    return inner2
outer2(10).__closure__   # None

判断一个函数是不是闭包,标准就一条:__closure__ 是不是 None。 不是看它嵌了几层,也不看它写在哪个函数里面。

2. cell 是"引用"不是"拷贝"

def make():
    i = 0
    def inc():
        nonlocal i
        i += 1
        return i
    return inc

c = make()
c()   # 1
c()   # 2

两次调用返回不同值,说明 i 没有被"拍照"进闭包。cell 里装的是引用,外层变量活着,cell 就能改;外层函数早返回了,cell 就是那个变量唯一的家。

这就是 nonlocal 存在的原因:它改的不是外层函数栈上的某个格子(那个格子早没了),而是 cell 盒子里的内容。

3. 经典坑:循环里的闭包为什么全是 2

funcs = []
for i in range(3):
    funcs.append(lambda: i)

[fn() for fn in funcs]    # [2, 2, 2]

不是 bug,是 cell 机制的直接后果:循环的三轮里,三个 lambda 共享同一个 cell。实测:

fns = make_three()      # 循环里定义三个引用 i 的函数
fns[0].__closure__[0] is fns[1].__closure__[0]   # True
fns[0].__closure__[0].cell_contents              # 2

is 返回 True——三个闭包攥着同一个盒子,循环结束 i 定格在 2,于是所有闭包一起返回 2。

三种修法,本质都是绕开共享 cell:

修法 写法 原理
默认参数 lambda i=i: i 默认值在创建时求值,存进函数自己的 __defaults__,不走 cell
工厂函数 def make(i): return lambda: i 每轮都是新作用域,cell 各自独立
偏函数 functools.partial 同上,参数提前绑定

4. cell 的隐藏代价:变量延寿

def keeper():
    big = [0] * 100000      # 一个 800KB 的大对象
    def show():
        return len(big)
    return show

h = keeper()
h()                        # 100000 —— big 还活着

keeper 早就返回了,big 却死不了:cell 引用着它,函数对象引用着 cell,h 引用着函数。一条引用链把本该释放的内存吊了十年。

反过来用,这就是有意的缓存手段;无意中触发,就是内存泄漏的标准姿势——回调函数、事件监听里尤其常见。

5. 反常识:闭包比可调用类更费内存

直觉上闭包"轻",实测恰恰相反。各创建 10000 个:

方案 内存占用 折合每个
闭包 outer(i) 2808 KB 288 B
可调用类实例 Adder(i) 1172 KB 120 B

差了一倍多。原因:每次调用 outer 都会 MAKE_FUNCTION 造一个新的函数对象,外加一个 cell;而 10000 个类实例共享同一个 __call__,只需 10000 个小实例。

结论:只要数据和行为要打包,类反而更省。闭包的省心在于写起来短,不在于跑起来轻。

6. 字节码层面:三个指令说完全部

反汇编 outer,闭包机制在字节码里一眼可见:

MAKE_CELL        0 (x)          # 外层:把 x 装进 cell
LOAD_CONST       <code inner>
SET_FUNCTION_ATTRIBUTE 8 (closure)   # 把 cell 绑到函数上

内层函数则是:

COPY_FREE_VARS   1             # 复制 cell 引用进局部作用域
LOAD_DEREF       1 (x)         # 读 x = 走 cell,不是走局部变量表

LOAD_DEREF 和普通 LOAD_FAST 的区别,就是闭包和普通局部变量的区别:一个多跳一次盒子。 所以闭包调用有微小的额外开销,日常代码完全可以忽略,但热路径上值得知道。

7. 实用结论

  1. 循环里造闭包,一律显式绑定(默认参数或工厂函数),别赌运气。
  2. 闭包会让被捕获的变量延寿,回调/监听器场景要小心内存泄漏。
  3. 批量创建"带数据的行为",类实例比闭包省一半内存。
  4. 判断闭包看 __closure__ 是不是 None,与嵌套层数无关。

(全文完)

posted @ 2026-10-18 11:40  小小张说故事  阅读(7)  评论(0)    收藏  举报