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

浙公网安备 33010602011771号