码农高天
闭包通过编译期生成的Cell盒子包装共享变量,并将其持久化存入内层函数对象的closure属性中,使变量脱离外层函数调用栈后仍能常驻堆内存。
AI-assisted summary of 【python】闭包的实现机制。嵌套函数怎么共享变量的? — original source. Check the original for full context.
Source published:
Key ideas
- 闭包绝非在外层函数定义时对局部变量进行静态快照或复制,重新赋值依然能被内层函数实时捕获,这推翻了初学者常见的状态复制假设。 (0:00)
- Python 编译器在编译期就会静态分析变量作用域,将跨作用域共享的变量标记并生成 MAKE_CELL、STORE_DEREF 等专用字节码指令,而非普通的局部变量指令。 (3:00)
- 共享变量的底层依赖 cell object(盒子机制)实现间接引用,所有嵌套作用域读写的均是该盒子内部维护的对象指针。 (3:00)
- 外层函数栈帧销毁后变量不被回收的核心原因,在于 SET_FUNCTION_ATTRIBUTE 指令将包含 cell 的元组直接持久化绑定至内层函数对象的 __closure__(func_closure)属性上。 (6:00)
Detailed notes
## Background 该视频由技术博主“码农高天”于2024年9月6日发布在YouTube平台上。视频简介指出,本期内容聚焦于Python中闭包(closure)的底层实现机制,深入剖析嵌套函数如何能够直接访问并共享外部函数的变量。
## TL;DR 视频首先通过示例反驳了“闭包仅是复制局部变量”的直觉误区,抛出嵌套函数为何能实时同步外部变量重新赋值以及外层栈帧销毁后变量如何存活两大核心疑问。随后,高天反编译字节码,揭示了编译期识别的MAKE_CELL、STORE_DEREF和LOAD_DEREF指令,并以形象的“盒子”比喻解释了cell object作为间接引用的中介机制。最后,视频指出内层函数对象通过SET_FUNCTION_ATTRIBUTE将闭包元组保存在__closure__属性中以维持引用计数,从本质上将闭包抽象为一个打破常规栈帧生命周期的“局部化小全局”空间。
## Atmosphere & style 整期视频洋溢着极具极客范儿的硬核技术探索氛围,节奏明快紧凑且直奔底层本质。高天凭借其资深的CPython源码功底,以从容自信且条理清晰的步调抽丝剥茧,祭出反编译字节码这一“传统艺能”层层揭秘。生动的“盒子”比喻化繁为简,让枯燥的虚拟机指令与内存模型变得具象可感,观看过程充满推导破案般的逻辑快感与顿悟感。
## Bulletin - [00:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=0s) 闭包绝非在外层函数定义时对局部变量进行静态快照或复制,重新赋值依然能被内层函数实时捕获,这推翻了初学者常见的状态复制假设。 - [03:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=180s) Python 编译器在编译期就会静态分析变量作用域,将跨作用域共享的变量标记并生成 MAKE_CELL、STORE_DEREF 等专用字节码指令,而非普通的局部变量指令。 - [03:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=180s) 共享变量的底层依赖 cell object(盒子机制)实现间接引用,所有嵌套作用域读写的均是该盒子内部维护的对象指针。 - [06:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=360s) 外层函数栈帧销毁后变量不被回收的核心原因,在于 SET_FUNCTION_ATTRIBUTE 指令将包含 cell 的元组直接持久化绑定至内层函数对象的 __closure__(func_closure)属性上。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=0s) 闭包(Closure)允许内部嵌套函数跨作用域访问并操作外部函数的变量,实现跨调用周期的状态共享。 - [00:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=0s) 即使在嵌套函数定义完成后再对外部变量重新赋值,内部函数仍能读取到最新值,证明闭包不是简单的局部变量值复制。 - [00:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=0s) 闭包引发了两个核心底层疑问:变量重新赋值时的状态实时同步机制,以及外部栈帧销毁后变量内存为何能继续驻留。 - [03:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=180s) Python 在编译期进行静态分析,决定一个变量是生成普通局部变量指令(如 STORE_FAST),还是闭包指令(如 STORE_DEREF)。 - [03:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=180s) 字节码 MAKE_CELL 负责在底层创建一个 cell object 包装对象,充当容纳变量指针的“盒子”。 - [03:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=180s) STORE_DEREF 与 LOAD_DEREF 分别通过向 cell 盒子写入与从中解引用,确保所有相关作用域操作的都是同一个内存引用。 - [03:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=180s) 跨作用域变量在宿主函数中被称为 Cell-Vars,而在引用的嵌套函数中被称为 Free-Vars(自由变量)。 - [06:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=360s) SET_FUNCTION_ATTRIBUTE 字节码将装有 cell 引用的元组写入返回函数对象的 func_closure 结构中。 - [06:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=360s) 外部函数的局部栈帧在运行结束后销毁,但函数对象本身常驻堆内存,并通过 __closure__ 属性持续保持对 cell object 的引用计数。 - [06:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=360s) 检查函数对象的 g.__closure__[0] 可以直接验证其持有的 cell 内部地址与目标数据对象的 id 完全一致。 - [06:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=360s) 闭包的本质是构建了一个“局部化的全局命名空间”,让外层函数成为其内部嵌套函数的私有“小全局”。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=0s) 闭包的定义与两个核心疑问
高天开篇直奔主题,探讨 Python 中闭包(Closure)的底层实现机制。他首先通过一段典型的闭包代码进行说明:外层定义一个函数 `f`,其内部初始化了一个变量 `data = []`(空列表);在 `f` 内部又定义了一个嵌套函数 `inner`,它接收一个参数 `value`,内部直接使用了外层的 `data` 变量,将其追加进列表并返回该列表。外层函数 `f` 最终把 `inner` 函数作为对象返回。将 `f()` 的调用结果赋给变量 `g` 后,连续调用 `g(1)` 和 `g(2)`,控制台分别打印出 `[1]` 和 `[1, 2]`。这证明两次调用操作的是同一个列表,并且能够跨越调用周期共享状态。
高天指出,`data` 既不是 `inner` 的常规局部变量(因为 Python 的局部变量以函数为作用域单位),也不是全局变量,“那这种能从一个函数里去读取其他函数变量的机制,我们叫做闭包”。
针对这种现象,有人可能会直觉地认为实现很简单:只要在定义嵌套函数时,把外层函数的局部变量复制一份给内层即可。但高天通过一个反例推翻了这种“简单复制说”:如果在定义完 `inner` 函数之后、在 `f` 返回之前,对 `data` 进行重新赋值(例如赋为一个新的列表,而非原地修改),结果显示 `inner` 打印出来的依然是重新赋值后的新列表。这表明 `inner` 能够持续追踪外层函数中 `data` 变量的变化,绝非简单的定格快照。
由此引出了两个深层次的技术疑问: 1. **状态同步机制**:如果是简单的局部变量复制,为何外层对变量重新赋值后,内层函数能够即时感知并同步? 2. **生命周期谜团**:Python 的局部变量依附于函数运行期的栈帧,函数运行结束后栈帧销毁,局部变量的引用理应失效。既然 `f()` 已经执行完毕返回,为什么本该死去的 `data` 依然能常驻在内存中,供后续多次调用的 `g` 使用?
高天强调,本期使用的是 Python mainline version(前沿开发版源码)。尽管最近几个版本的字节码(bytecode)变动较大,但闭包的核心架构与总体实现方向始终是一致的。
```mermaid flowchart TD accTitle: 闭包外部函数与内部函数的引用关系 accDescr: 展示函数 f 内部创建变量与闭包函数,返回后通过 g 调用的结构流程
subgraph OuterScope ["外层函数 f() 调用期"] A["初始化变量 data = []"] --> B["定义嵌套函数 inner(value)"] B --> C["对 data 重新赋值或变更操作"] C --> D["返回 inner 函数对象"] end
D --> E["赋值给外部变量 g = f()"]
subgraph ExecutionScope ["生命周期结束后的独立调用"] E --> F["调用 g(1) -> 操作同一个 data"] F --> G["调用 g(2) -> 持续追踪最新 data 状态"] end ```
---
[03:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=180s) 字节码揭秘:Cell 对象与变量共享
带着疑问,高天祭出“传统艺能”——反编译查看函数的字节码(bytecode)。通过对比外层函数 `f` 和内层函数 `inner` 的 Code Object,可以发现一系列平常罕见的特定字节码指令: - 在 `f` 的字节码中,出现了 `MAKE_CELL`、`STORE_DEREF` 以及 `SET_FUNCTION_ATTRIBUTE`(附带 closure 参数); - 在 `inner` 的字节码中,读取参数 `value` 使用的是常规的局部变量加载指令 `LOAD_FAST`,而读取 `data` 时却使用了特殊的 `LOAD_DEREF`。
高天逐一拆解这些指令的真实作用。首先是 `MAKE_CELL`,它的底层核心逻辑极度纯粹,就是创建一个 `cell object`。高天打了一个非常生动形象的比喻:“你可以把它理解为一个盒子,我们把变量装到这个盒子里,而在每一个 scope 里修改变量的时候,实际上修改的是盒子里面保存的那个变量,这样就可以保证每一个 scope 看到的是同一个变量,而不是一个变量被复制了很多次。”
接着是 `STORE_DEREF`。它的运行逻辑与盒子模型完全吻合:取出装着变量的 cell 盒子,把盒子里原有的对象指针扔掉,再将新的变量引用装入盒中。正因如此,无论哪个作用域去打开这个盒子,拿到的都是最新装入的值,完美解释了为什么重新赋值依然能被内层函数追踪。
更关键的是指令生成的决策时机。变量到底编译成普通的 `STORE_FAST` 还是包装 cell 的 `STORE_DEREF`,完全由 Python 编译期(Compile Time)决定。编译器静态分析代码结构,识别出一个变量究竟属于纯粹的单函数局部变量,还是跨越嵌套作用域的闭包共享变量。在编译期生成不同的字节码,从而在运行时产生完全不同的行为。
内层函数 `inner` 在读取 `data` 时所用的 `LOAD_DEREF` 也是对称的逻辑:先从内部上下文拿到这个特定的 cell 盒子,再从盒子里解引用取出实际存储的值。对于同一个 `data` 变量,在宿主函数 `f` 中它被称为 **Cell-Vars**(名称正是取自 `cell object`),而在消费它的内层函数 `inner` 内部则被称为 **Free-Vars**(自由变量,即“非我所定义、从外部捕获的变量”)。在函数实例化时,Python 会调用底层的 `copy_freevars` 逻辑完成这些 cell 引用的传递对接。
---
[06:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=360s) 闭包的生命周期本质与“小全局”模型
虽然理清了 `cell object` 盒子的运作,但核心的内存生命周期问题仍未解决:Python 依赖引用计数机制管理内存垃圾回收,既然外层函数 `f` 已经执行结束,运行栈被清空,究竟是谁在持有这个 cell 盒子,从而维持 `data` 的生命?
高天指出了答案的关键转折点:字节码指令 `SET_FUNCTION_ATTRIBUTE`。在构造返回的 `inner` 函数对象时,该指令把包含所有共享 cell 的元组(tuple)作为 `closure` 属性写入了新生成的 Function Object 中(对应底层 C 结构体的 `func_closure`)。
高天一语道破天机:“也就是说,这些 cell variable 实际上活到了 function object 里面。” - 当执行 `g = f()` 时,外层创建并返回了一个函数对象 `g`; - 虽然函数对象在未被调用时内部不包含任何局部变量栈帧,但它本身作为一个持久驻留在堆内存的对象,其属性 `__closure__`(即 `func_closure`)牢牢持有着包含 `cell object` 的元组; - `cell object` 内部再持有实际的 `list` 对象(即 `data`)。
通过打印检测可以发现,`g.__closure__[0]` 确实就是一个 `cell`,其内部引用的内存地址与最终返回打印的 `data` 列表的 `id` 完全一致。只要变量 `g` 依然存活,对该函数对象的引用不断,闭包元组及内部的 cell 盒子就绝不会被垃圾回收释放,进而保证了共享数据在堆内存中的永续生存。
最后,高天对闭包的本质做了一个高屋建瓴的抽象总结。他认为闭包在机制上其实并不神秘,实质上就是一种“建立局部化全局命名空间”的手段。如果不看第一行最外层的 `def f():`,将内层代码平铺,`data` 就像是一个顶层全局变量,而 `inner` 只是读取这个全局变量并执行运算:
> “所谓闭包,形式上就是让 f 这个函数成为了一个新的小全局,在函数 f 里可以定义属于这个函数 f 的‘全局变量’,然后所有定义在这个函数里的其他函数都可以去使用这个全局变量。”
闭包最神奇与精妙之处,就在于打破了常规局部栈帧的生死限制:即使宿主函数 `f()` 已经彻底执行结束,甚至外部把对 `f` 自身的引用完全抹去,这些活在内层函数对象 `func_closure` 里的闭包变量依然鲜活地存在于内存之中。
```mermaid flowchart LR accTitle: Python 闭包变量在堆内存中的引用链 accDescr: 展示函数对象 g 通过 func_closure 链式保持引用,从而防止变量被垃圾回收释放
subgraph FunctionObj ["Function Object: g"] A["函数对象 g"] -->|"持有属性"| B["__closure__ 元组 (Tuple)"] end
subgraph CellWrapper ["Cell 封装层"] B -->|"元组元素"| C["Cell Object (盒子)"] end
subgraph HeapData ["实际数据对象"] C -->|"内部指针 ob_ref"| D["List Object: data [1, 2]"] end ```
- **核心概念对比**: - **Cell-Vars**:在外层函数 `f` 中定义、需要逃逸出自身栈帧供内层使用的变量。 - **Free-Vars**:在内层函数 `inner` 中直接使用、但未在当前函数局部作用域内声明的自由变量。 - **关键底层字节码**: - `MAKE_CELL`:在当前作用域为共享变量创建 `cell object` 包装盒。 - `STORE_DEREF`:将对象引用装入特定的 cell 盒子中。 - `LOAD_DEREF`:通过 cell 盒子间接解引用获取数据对象。 - `SET_FUNCTION_ATTRIBUTE`:将装有 cell 引用的元组持久化绑定到内层函数对象的闭包属性上。
## Notable quotes > [06:00](https://www.youtube.com/watch?v=Flce9y5Qn38&t=360s) 所谓闭包,形式上就是让 f 这个函数成为了一个新的小全局,在函数 f 里可以定义属于这个函数 f 的‘全局变量’,然后所有定义在这个函数里的其他函数都可以去使用这个全局变量。 — 高天
## Mentioned 高天, Python, YouTube
## Video description 这期视频我们讲一下闭包,也就是closure的实现机制。我们在一个函数里定义另一个函数的时候,会发现内部的函数可以直接使用外部函数的变量,那这个机制的内部实现是什么样的呢?
[Source](https://www.youtube.com/watch?v=Flce9y5Qn38)