码农高天
PEP 667依赖静态编译信息解析locals,遇到PEP 709动态剥离闭包变量的内联推导式时会强行解包非cell对象,最终通过在C语言层补充类型检查消除崩溃。
AI-assisted summary of 这五行Python程序的结果,谁都不可能猜得到 — original source. Check the original for full context.
Source published:
Key ideas
- 一段仅有五行的 Python 代码在跨版本执行时表现大相径庭:Python 3.11 及更早版本字典输出包含特殊变量 '.0',3.12 仅保留普通局部变量,而 3.13 则会直接因段错误(Segmentation fault)崩溃。 (0:00)
- Python 3.12 的 PEP 709 引入了推导式内联(Inline Comprehensions),取消了原先独立创建 code object 的函数调用开销,改用 LOAD_FAST_AND_CLEAR 在当前栈帧内实现变量隔离。 (0:00)
- Python 3.13 崩溃的主因是 PEP 667 重构 locals() 时,解释器依靠静态编译期信息判定闭包变量,却在运行时遇到了内联推导式动态写入的非 cell 对象,导致强行解包引发段错误。 (5:00)
- 官方最终通过在 C 层增加动态类型检查修复了该崩溃,尽管在手动传入 cell_type 的极端边界情况下仍存在理论瑕疵,但在工程权衡中成功消除了致命的段错误缺陷。 (5:00)
Detailed notes
## Background 本内容由 YouTube 频道“码农高天”于 2025 年 2 月 22 日发布。视频简介指出,短短五行的 Python 程序背后蕴含着诸多底层学问,探讨了从 PEP 709 到 PEP 667 等多个特性的改动如何机缘巧合地凑在一起,最终引发了一个极其罕见的特殊运行案例。
## TL;DR 视频深入剖析了一段仅有五行却在不同 Python 版本中表现截然不同的代码:在 Python 3.11 及更早版本中会输出包含迭代器临时变量的字典,在 3.12 中得益于 PEP 709 的推导式内联优化移除了临时变量,而在 3.13 中则因 PEP 667 对 locals 机制的重构直接导致了段错误崩溃。崩溃的根本原因在于静态编译期对闭包变量的判定与运行时内联推导式动态复用变量槽位之间的冲突,导致解释器尝试对普通整数执行解包操作。码农高天详细解释了底层的字节码运行逻辑,并分享了自己在 CPython 源码中提交动态校验补丁的修复过程与工程权衡。
## Atmosphere & style 视频开篇带着一种技术黑客式的挑战趣味,主讲人自信断言无人能猜中输出,迅速勾起观众的好奇心与解题欲。随着 CPython 字节码与栈帧机制的层层剥离,语调转向严谨透彻的硬核源码教学,穿插着作者自嘲“大傻子做改动”的幽默风趣。整体观感既像是在看一场抽丝剥茧的底层 Debug 侦探秀,又在结尾升华出工业级软件工程中“实用优于完美”的深刻感悟。
## Bulletin - [00:00](https://www.youtube.com/watch?v=ltCNkcl9HGI&t=0s) 一段仅有五行的 Python 代码在跨版本执行时表现大相径庭:Python 3.11 及更早版本字典输出包含特殊变量 '.0',3.12 仅保留普通局部变量,而 3.13 则会直接因段错误(Segmentation fault)崩溃。 - [00:00](https://www.youtube.com/watch?v=ltCNkcl9HGI&t=0s) Python 3.12 的 PEP 709 引入了推导式内联(Inline Comprehensions),取消了原先独立创建 code object 的函数调用开销,改用 LOAD_FAST_AND_CLEAR 在当前栈帧内实现变量隔离。 - [05:00](https://www.youtube.com/watch?v=ltCNkcl9HGI&t=300s) Python 3.13 崩溃的主因是 PEP 667 重构 locals() 时,解释器依靠静态编译期信息判定闭包变量,却在运行时遇到了内联推导式动态写入的非 cell 对象,导致强行解包引发段错误。 - [05:00](https://www.youtube.com/watch?v=ltCNkcl9HGI&t=300s) 官方最终通过在 C 层增加动态类型检查修复了该崩溃,尽管在手动传入 cell_type 的极端边界情况下仍存在理论瑕疵,但在工程权衡中成功消除了致命的段错误缺陷。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=ltCNkcl9HGI&t=0s) 相同五行 Python 代码在 3.11(含 '.0' 键)、3.12(无 '.0')和 3.13(直接段错误崩溃)中展现出完全不同的运行结果。 - [00:00](https://www.youtube.com/watch?v=ltCNkcl9HGI&t=0s) 在 Python 3.11 及以前,列表推导式会生成独立的 code object 并像临时函数一样被调用,迭代器被存放在名为 '.0' 的特殊局部变量中。 - [00:00](https://www.youtube.com/watch?v=ltCNkcl9HGI&t=0s) Python 3.12 实施了 PEP 709(推导式内联优化),让推导式直接在外层函数的栈帧(frame)内原地执行,避免了高昂的函数调用开销。 - [00:00](https://www.youtube.com/watch?v=ltCNkcl9HGI&t=0s) 为保证作用域隔离,PEP 709 引入了字节码指令 LOAD_FAST_AND_CLEAR,在内联推导式执行前暂存并清空同名局部变量槽位。 - [05:00](https://www.youtube.com/watch?v=ltCNkcl9HGI&t=300s) 代码中未执行的匿名函数 lambda: k 会在编译期将变量 k 静态标记为闭包变量,并在函数启动时由 MAKE_CELL 封装为 cell object。 - [05:00](https://www.youtube.com/watch?v=ltCNkcl9HGI&t=300s) 内联推导式模拟独立函数语义时,将原本存放 cell object 的变量槽覆盖写入了纯数字对象(非 cell 对象)。 - [05:00](https://www.youtube.com/watch?v=ltCNkcl9HGI&t=300s) Python 3.13 引入的 PEP 667 重构了 locals() 机制,底层的 _PyFrame_LocalsProxy_GetVal 依靠 code object 静态属性判定变量是否属于闭包。 - [05:00](https://www.youtube.com/watch?v=ltCNkcl9HGI&t=300s) 静态判定与动态内联产生根本冲突:解释器坚信 k 是闭包并调用 PyCell_Get 强行解包,最终因操作非 cell 对象触发断言失败和段错误。 - [05:00](https://www.youtube.com/watch?v=ltCNkcl9HGI&t=300s) 高天提交的修复补丁在 C 语言层增加了动态类型校验,确保对象确为 cell object 时才进行解包,否则直接返回原值。 - [05:00](https://www.youtube.com/watch?v=ltCNkcl9HGI&t=300s) 该修复方案展示了典型的工业界权衡:无法在不重构解释器架构的前提下完全覆盖所有极端 corner case,但消除崩溃让系统‘绝对更好’本身就具有极高工程价值。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=ltCNkcl9HGI&t=0s) 五行神秘代码与跨版本行为差异:PEP 709 的内联推导式机制
视频开篇展示了一段仅有五行的 Python 程序,并提出了一个看似简单实则极为刁钻的提问:这段代码运行后到底会打印出什么?对此,码农高天直言:“这道题我不认为世界上有任何一个人可以完整的答出来。你如果不信的话可以暂停一下,然后写出你的答案。”紧接着公布的真实运行结果令人大跌眼镜,不同 Python 版本之间展现出了完全不同的行为:
| Python 版本 | 运行结果 / 行为表现 | |:--- |:--- | | **Python 3.11 及更早版本** | 输出字典包含 `'.0'`(对应一个 iterator)以及 `'k': 0` | | **Python 3.12** | 字典中仅有 `'k': 0`,没有了特殊键 `'.0'` | | **Python 3.13** | 直接触发段错误崩溃(Segmentation fault,简称 sigfault) |
这段代码中包含了一个列表推导式(List Comprehension)。在 Python 3.11 及以前,列表推导式会新建一个单独的 `code object`,其运行方式就如同在后台悄悄生成并调用了一个独立的临时函数。该函数为了迭代外部传入的序列,会把传入的 iterable 存入一个具有特殊命名格式的局部变量中,即 `'.0'`。然而,重新创建函数并进行调用的开销较大,运行效率偏低。
为了优化性能,Python 3.12 引入了 PEP 709(Inline Comprehensions,推导式内联优化)。在 3.12 中,推导式不再创建新的 `code object` 去发起函数调用,而是直接在当前函数的栈帧(frame)内执行。但从用户语义上看,推导式内部的局部变量依然必须与外部的局部变量相互隔离。例如即使推导式内部和外部函数的局部变量名都叫 `k`,推导式迭代结束后外部的 `k` 依然不能被篡改。在字节码(bytecode)层面上,CPython 借助 `LOAD_FAST_AND_CLEAR` 实现了这一隔离:该指令先把原有的局部变量 `k` 暂存起来,并将该槽位清空;随后推导式运行时的 `STORE_FAST` 便能安全地向局部变量 `k` 写入新值而不污染原数据。同时,由于推导式已直接内联到当前 frame,运行时可以直接去索引列表对象而无需再借助临时变量存储迭代器,这就解释了为何 Python 3.12 的 `locals()` 结果中再也见不到 `'.0'`。
```mermaid flowchart TD accTitle: PEP 709 推导式运行机制对比 accDescr: 对比 Python 3.11 以前创建临时函数与 Python 3.12 内联推导式的栈帧差异。
subgraph Legacy["Python 3.11 及更早版本"] A["外层函数 Frame"] -->|"创建并调用新函数"| B["推导式独立 Code Object"] B -->|"参数存为特殊变量"| C["'.0' (Iterator)"] end
subgraph Inline["Python 3.12 (PEP 709)"] D["外层函数 Frame"] -->|"原地展开执行"| E["Inline Comprehension"] E -->|"LOAD_FAST_AND_CLEAR"| F["暂存并隔离原有变量 k"] E -->|"直接访问源列表"| G["无需生成临时变量 '.0'"] end ```
---
[05:00](https://www.youtube.com/watch?v=ltCNkcl9HGI&t=300s) 闭包静态判定与动态内联的冲突:PEP 667 引发的崩溃与修复妥协
列表推导式的内联机制在 Python 3.12 与 3.13 中并没有原则变化,导致 3.13 发生 Segmentation fault 的真正祸首是 PEP 667 对 `locals()` 的底层机制重构。码农高天调侃道:“如果大家有印象的话,我之前提到过,有一个大傻子做了 PEP 667,它改变了 locals 的运行机制,就是这个改动啊,直接把 Python 3.13 给 crash 了。”
在原代码的第二行,写着一个看似毫无作用、既未调用也未保存的匿名函数 `lambda: k`。正是这行代码,让变量 `k` 从一个普通的局部变量变成了被闭包捕获的闭包变量。在 CPython 中,闭包变量在底层是由 `cell object` 负责包装存储的。当函数启动时,字节码会调用 `MAKE_CELL` 将 `k` 转化为 cell object。然而,当程序执行到内联推导式时,为了模拟独立函数的语义,推导式内部循环写入的 `k` 应该表现为纯粹的普通局部变量(fast variable)。因此,`LOAD_FAST_AND_CLEAR` 将保存有 cell object 的闭包变量 `k` 偷偷暂存并清空,推导式随后直接将单纯的数值 `0` 通过 `STORE_FAST` 写回变量槽位,此时变量槽内只存着一个数值对象,根本没有包装任何 cell object。
崩溃的导火索就在此时发生:推导式内部紧接着调用了 `locals()`。在 PEP 667 引入的底层函数 `_PyFrame_LocalsProxy_GetVal` 中,解释器需要依据 `code object` 静态记录的信息来判断某个位置的变量是否属于闭包变量。如果被标记为闭包变量,它就会调用 `PyCell_Get` 强行把底层的 cell object 打开并取值。由于推导式被内联,它与外层函数共享同一个 `code object`;编译器在静态编译期看到 `lambda: k` 时,就断定 `k` 是一个闭包变量。但处于内联推导式中时,真正的闭包已经被挪走,变量槽中实际上只是一个整数 `0`。当代码试图对一个非 cell 对象强行解包时,断言失败,直接在底层引发了段错误。
针对这个 bug,核心矛盾在于:“CodeObject 认为一个变量是不是闭包变量是静态的,这件事是在编译期决定的;而由于 inline comprehension 的引进,这个变量是不是闭包变量其实是动态的,有时候是有,有时候不是。你用一个静态的标准去判断一个动态的变量,当然就会出问题。”
为了修复此崩溃,高天提交的补丁在静态判定之后补上了一层动态防御检查:检查该变量所保存的对象是否真的是一个 cell object;如果不是,就直接返回该值而不强行拆包。但这又引出了 Python 过于灵活所带来的死角:用户可以在 Python 中手动实例化一个 `cell_type` 对象并赋予普通局部变量。面对这种变量本身是普通变量、内部恰好保存了一个 cell object 的极端边界情况(corner case),当前的 CPython 机制在不重写编译器或解释器架构的前提下根本无法区分。尽管留下了边界瑕疵,由于段错误是性质最恶劣的崩溃级缺陷,该补丁仍顺利被官方合并。码农高天由此总结道,在实际的工业界开发中,完美并非总是首要指标:“在工厂里这个也非常常见,你的工作并不是要把一个问题解决得尽善尽美它才有价值,绝对更好已经是一个非常了不起的成就了。”
- Python 的静态编译特性(编译阶段根据 AST 预先生成静态的变量作用域属性)与推导式内联(运行时同一栈帧内动态切换变量的局部/闭包形态)产生了根本性冲突。 - 在 Debug 版本的 Python 3.13 源码中,该错误会在执行 `PyCell_Get` 相关的断言处直接触发 Assertion Error,明确提示该 value 并非 cell。 - 最终的修复方案属于实用主义权衡:在 C 语言层增加动态类型校验,消除了致命的 Segmentation fault,将破坏系统的 crash 降级为一个极难触碰的语义边界情况。
## Notable quotes > [05:00](https://www.youtube.com/watch?v=ltCNkcl9HGI&t=300s) 你的工作并不是要把一个问题解决得尽善尽美它才有价值,绝对更好已经是一个非常了不起的成就了。 — 码农高天
## Mentioned Python, 码农高天, PEP 709, PEP 667, CPython
## Video description 短短的五行程序都猜不到结果?这里面可有不少学问。从PEP 709到PEP 667,多少个改动凑到一起才触发了这个特殊的案例?
[Source](https://www.youtube.com/watch?v=ltCNkcl9HGI)