码农高天
Python函数在递归调用自身时通过LOAD_GLOBAL指令解析全局命名空间,外部对函数名的重绑定会直接瓦解内部递归,开发者须通过闭包将调用锁定在私有作用域中。
AI-assisted summary of 【python】我调用的我不是我?你绝对不知道的python递归陷阱 — original source. Check the original for full context.
Source published:
Key ideas
- Python 函数内部调用同名函数时并非直接引用自身对象,而是通过 LOAD_GLOBAL 指令在全局作用域查找该名字,导致函数行为易受外部全局变量修改的影响。 (3:00)
- 列表推导式在 Python 3 中拥有独立的匿名函数作用域(使用 STORE_FAST/LOAD_FAST),不会改变模块级变量;而模块最外层的显式 for 循环使用 STORE_NAME,会直接覆盖全局变量,导致两者调用同名函数的输出结果产生显著差异。 (0:00)
- 当全局递归函数被重新赋值(如 fib = g)后,即使通过别名变量调用原函数,其内部递归仍会查找全局名称,进而调用被篡改后的新逻辑。 (5:00)
- 利用闭包(Closure)在函数内嵌套定义私有递归函数,可促使字节码采用 LOAD_DEREF 解析名称,彻底切断对全局命名空间的依赖以规避作用域污染。 (5:00)
Detailed notes
## Background 该视频由技术博主“码农高天”于2024年4月12日发布在YouTube平台上。视频简介指出,这是一次关于Python递归陷阱的深入探讨,旨在揭示“你以为的递归可能并不是你以为的递归”这一反直觉现象。
## TL;DR 高天通过一段极简代码演示了反常现象:列表推导式调用函数属性与显式for循环的输出完全不一致,前者连续打印8个7,而后者正常输出0到7。通过深入Python字节码,他揭示了函数内部调用同名自身时并非引用自身对象,而是通过LOAD_GLOBAL指令向全局作用域查找名称的底层机制。基于这一特性,他剖析了递归函数若在全局被重写将导致递归错乱的严重风险,并给出了利用嵌套闭包(LOAD_DEREF)隔离全局作用域的安全解决方案。
## Atmosphere & style 视频风格硬核严谨且充满解谜般的戏剧性,开篇抛出看似等价却输出迥异的极简代码,迅速抓住观众的好奇心与困惑感。随着字节码指令的层层拆解,叙述节奏由浅入深,高天以敏锐而冷静的工程师视角带领观众直击底层机制的“可怕真相”。整期内容既有发现诡异Bug时的烧脑与震撼,又有揭开Python底层作用域面纱后的豁然开朗。
## Bulletin - [03:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=180s) Python 函数内部调用同名函数时并非直接引用自身对象,而是通过 LOAD_GLOBAL 指令在全局作用域查找该名字,导致函数行为易受外部全局变量修改的影响。 - [00:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=0s) 列表推导式在 Python 3 中拥有独立的匿名函数作用域(使用 STORE_FAST/LOAD_FAST),不会改变模块级变量;而模块最外层的显式 for 循环使用 STORE_NAME,会直接覆盖全局变量,导致两者调用同名函数的输出结果产生显著差异。 - [05:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=300s) 当全局递归函数被重新赋值(如 fib = g)后,即使通过别名变量调用原函数,其内部递归仍会查找全局名称,进而调用被篡改后的新逻辑。 - [05:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=300s) 利用闭包(Closure)在函数内嵌套定义私有递归函数,可促使字节码采用 LOAD_DEREF 解析名称,彻底切断对全局命名空间的依赖以规避作用域污染。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=0s) 在同一作用域通过循环动态定义同名函数并给其绑定属性时,各函数内部访问同名属性的行为容易产生反直觉结果。 - [00:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=0s) 列表推导式 [f.index for f in FUNCTIONS] 能够正常输出 0 到 7,但执行 [f() for f in FUNCTIONS] 时却会反常地连续输出 8 个 7。 - [00:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=0s) 将列表推导式改写为最外层的显式 for 循环遍历调用 f(),输出结果又会从 8 个 7 恢复为 0 到 7。 - [03:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=180s) 字节码分析表明,函数内部执行 return f.index 时并非自引用,而是触发 LOAD_GLOBAL f 指令前往最外层全局命名空间查找。 - [03:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=180s) Python 3 的列表推导式运行在独立的局部作用域中,内部变量更新不会污染全局变量 f,使得全局 f 始终停留在循环终态(index=7)。 - [03:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=180s) 显式 for 循环运行在模块顶层,每次迭代均执行 STORE_NAME 实时覆盖全局变量 f,导致函数调用每次都读取到当次循环对应的对象。 - [05:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=300s) 递归函数的自调用高度依赖全局变量名解析,一旦全局同名变量被覆盖,原函数内部的递归步骤就会转向新逻辑。 - [05:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=300s) 若将递归函数赋值给别名 F 并将原函数名重新指向返回 0 的函数,调用 F 将无法正常完成原递归,而是返回 0。 - [05:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=300s) 防范全局递归陷阱的有效策略是使用闭包,在外层函数内部定义私有递归函数并通过 LOAD_DEREF 实现安全自调用。 - [05:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=300s) 闭包结构能够将递归调用封闭在局部命名空间中,外部即使在全局声明同名变量也无法干扰内部递归逻辑。 - [05:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=300s) 理解 Python 字节码层面的作用域规则,是排查推导式与显式循环不一致等深层隐蔽 Bug 的核心关键。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=0s) 函数内的同名引用与反直觉的输出结果
码农高天在开篇抛出了一个关于递归的“坑”,并用一段极其精简但行为诡异的代码来展示其发现的根源。他首先创建了一个空列表 `FUNCTIONS = []`,然后写了一个简单的 `for` 循环(循环 8 次)。在每次循环中,都定义了一个新的函数 `f()`,其内容只有一行:`return f.index`。虽然普通的 Python 函数默认没有 `index` 属性,代码检查工具(如 mypy)也会给出警告提示函数没有该属性,但他直接忽略警告,强行给新定义的函数对象绑定属性 `f.index = n`,并把每个函数对象 `f` 添加到 `FUNCTIONS` 列表中。按照直觉,这个列表里保存了 8 个函数对象,它们的 `.index` 属性应该依次是 0 到 7。
```mermaid flowchart TD accTitle: List Comprehension 与显式 For 循环的行为差异 accDescr: 展示为何 List Comprehension 调用 f() 打印 8 个 7,而显式 For 循环打印 0 到 7。
subgraph ListComp["推导式:List Comprehension"] LC1["生成独立匿名函数作用域"] LC2["使用 STORE_FAST / LOAD_FAST 处理局部变量 f"] LC3["不改变全局变量 f(全局 f 停留在 index=7)"] LC4["调用 f() 时内部 LOAD_GLOBAL f 获取 index=7 ➜ 打印 8 个 7"] LC1 --> LC2 --> LC3 --> LC4 end
subgraph ExplicitLoop["显式循环:Explicit For Loop"] EL1["运行在模块最外层"] EL2["使用 STORE_NAME 覆盖全局变量 f"] EL3["每次循环改变全局变量 f(依次为 0 到 7)"] EL4["调用 f() 时获取当前全局 f.index ➜ 打印 0 到 7"] EL1 --> EL2 --> EL3 --> EL4 end ```
为了验证这一点,高天先写了一个列表推导式(List Comprehension):`[f.index for f in FUNCTIONS]`,结果正如预期输出了 `0` 到 `7`。然而精彩的反直觉现象紧接着发生:当把提取属性 `f.index` 换成函数调用 `f()` 时,大家原本以为 `f()` 执行 `return f.index` 同样会返回 `0` 到 `7`,实际运行结果却不可思议地打印出了 8 个 `7`。更令人困惑的是,如果把这行列表推导式改写为理论上完全等价的显式 `for` 循环:
```python for f in FUNCTIONS: print(f()) ```
这段显式循环代码的输出竟然又变回了 `0` 到 `7`。面对如此反常的执行现象,高天引导大家深入 Python 的字节码(Bytecode)去寻找真相。
- `FUNCTIONS` 列表在循环结束后存储了 8 个独立的函数对象。 - 直接读取属性 `[f.index for f in FUNCTIONS]` 输出 `[0, 1, 2, 3, 4, 5, 6, 7]`。 - 调用函数 `[f() for f in FUNCTIONS]` 却输出了 8 个 `7`。 - 改写为模块最外层的显式 `for f in FUNCTIONS: print(f())`,输出又变回了 `0` 到 `7`。
[03:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=180s) 字节码揭秘:LOAD_GLOBAL 与作用域的碰撞
要搞明白为什么调用结果完全不同,必须查看 `f` 函数编译后的字节码。高天指出,`f` 函数生成的指令非常简单,但关键在于它执行的是一条 `LOAD_GLOBAL f` 指令。也就是说,在执行 `return f.index` 时,Python 并非从内部正在调用的那个函数本身去取属性,而是直接跑到最外层的全局作用域去查找名为 `f` 的变量,再去访问它的 `index`。正如码农高天所强调的那样:“如果你脑子转的快的话你会发现一个非常可怕的事情就是当我们在一个名字叫F的函数里面使用F的时候我们并不是在使用正在被定义的F函数而是跑回了全局变量里面去寻找F。”
弄清了这层全局查找机制,两段代码的差异便迎刃而解: 1. **列表推导式(List Comprehension)**:在 Python 3 中,列表推导式本质上被编译并执行为一个独立的匿名函数作用域。在推导式内部迭代时,循环变量 `f` 使用的是局部变量操作指令 `STORE_FAST` 和 `LOAD_FAST`。因此,在推导式遍历 `FUNCTIONS` 时,模块层级的全局变量 `f` 完全没有受到影响,它依然停留在最初构造循环结束时的状态——即第 8 个函数对象(其 `index` 为 `7`)。当推导式调用各个局部函数 `f()` 时,函数内部通过 `LOAD_GLOBAL` 找到的全局变量 `f` 永远是这最后一个函数,因而连续输出了 8 个 `7`。 2. **显式的 For 循环**:由于该循环写在文件的最外层,每次循环迭代从 `FUNCTIONS` 中取出函数赋给 `f` 时,使用的是 `STORE_NAME` 指令,这会直接修改并覆盖最外层的全局变量 `f`。因此,在第 13 行的每一次显式迭代中,全局变量 `f` 都在实时更新为当前的函数对象,调用 `f()` 触发 `LOAD_GLOBAL f` 时自然每次都读取到了当次循环赋予的新全局对象,从而打印出 `0` 到 `7`。
- `f()` 内部并没有指向自身对象引用的机制,而是直接通过 `LOAD_GLOBAL f` 解析名字。 - 推导式处于独立的作用域内,`STORE_FAST` 保障局部变量不污染外部,全局 `f` 始终为循环终止时的对象(index 属性为 7)。 - 显式最外层循环的 `STORE_NAME` 每次迭代都会覆盖全局变量 `f`。
[05:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=300s) 递归陷阱与利用闭包进行安全封装
这种查找全局变量的机制带来了极其严重的隐患,即当我们写递归函数时,函数内部尝试调用自己的时候,完全可能踩中大坑。高天以最经典的斐波那契数列(Fibonacci)递归实现为例:正常定义 `def fib(n):...` 后调用 `fib(5)` 返回值是 `8`。但如果外部逻辑做了一个看似无害的操作——先把 `fib` 保存给变量 `F = fib`,然后又重新定义或者替换了全局的 `fib` 函数(例如定义了一个直接返回 0 的函数 `def g(n): return 0` 并将其赋值给 `fib = g`),此时再去执行我们以为完全保留了原算法的 `F(5)`,输出结果直接变成了 `0`。码农高天精辟地总结了这一递归困境:“也就是说在我们写地归函数的时候我们没有办法保证这个地规函数调用自己的时候调用的还是自己。” 因为原函数内部的递归步骤在解析 `fib(...)` 时依然是去查找全局作用域,而全局的 `fib` 早已被替换成了别的东西。
```mermaid flowchart TD accTitle: 递归函数在全局被篡改与闭包封装防护对比 accDescr: 展示全局递归如何被 g 函数篡改导致返回 0,以及闭包如何使用 LOAD_DEREF 锁定作用域。
subgraph GlobalRisk["全局查找风险"] F1["调用 F(5)(原 fib 函数)"] --> F2["计算 fib(4) + fib(3)"] F2 -->|"LOAD_GLOBAL fib"| F3["查找全局名称 fib"] F3 -->|"已被赋值为 g"| F4["调用 g(n) ➜ 返回 0"] end
subgraph ClosureSafe["闭包封装安全机制"] C1["外部调用 Fib(5)"] --> C2["调用内部 _fib(5)"] C2 -->|"LOAD_DEREF _fib"| C3["从外层局部闭包作用域解析"] C3 -->|"隔离全局命名空间"| C4["外部篡改 _fib 或 fib 均无法破坏内部递归"] end ```
为了彻底规避老是在全局作用域搞来搞去导致的潜在污染,高天给出了解决思路:通过作用域将递归操作封装起来。具体做法是在外层函数 `fib` 内部嵌套定义一个内部私有递归函数 `_fib`,并由外层 `fib` 返回调用 `_fib` 的结果。通过这种闭包(Closure)结构,内部函数在递归调用自身时,使用的不再是 `LOAD_GLOBAL`,而是在该闭包的作用域链内寻找(使用 `LOAD_DEREF`)。此时,即便外部代码在全局作用域同样定义了一个 `_fib = g` 并试图覆盖,内部递归也不会跑到全局去查找,函数依然能够稳定且正确地运行。
高天最后坦言,虽然视频中的代码示例为了在极短行数内凸显核心机制而显得有些刻意与极端,但全局名称污染与引用错位却是真实存在的。如果在真实工程代码中遇到列表推导式与显式 `for` 循环打印不一致的情况,很多人可能根本无法在第一时间反应过来根源所在。
- 在全局作用域篡改函数名称(如 `fib = g`),会导致原函数内部尚未完成的递归步骤调入新逻辑。 - 闭包封装能够切断对全局变量的依赖,使内部私有函数在封闭命名空间中安全递归。 - 尽管直接篡改递归函数引用的写法并不常见,但理解字节码的全局查找逻辑对于排查 Python 中微妙的作用域 Bug 极为关键。
## Notable quotes > [03:00](https://www.youtube.com/watch?v=SGEZdP6kpmg&t=180s) 当我们在一个名字叫F的函数里面使用F的时候我们并不是在使用正在被定义的F函数而是跑回了全局变量里面去寻找F。 — 码农高天
## Mentioned 码农高天, Python, mypy, 斐波那契数列
## Video description 你以为的递归不是你以为的?python在做递归的时候可能出现的陷阱!
[Source](https://www.youtube.com/watch?v=SGEZdP6kpmg)