码农高天
Python变量作用域完全由编译期静态扫描赋值语句决定,且解释器在except块退出时会强制执行删除操作以切断执行帧循环引用导致的内存泄漏。
AI-assisted summary of 【python】详解变量作用域,局部闭包还是全局? — original source. Check the original for full context.
Source published:
Key ideas
- Python 变量的作用域并非在运行时解析,而是在编译生成字节码阶段通过“由内而外找赋值语句”的静态规则确定为局部变量、闭包变量或全局变量。 (0:00)
- 全局变量底层由模块级命名空间和 Built-ins 组成,`LOAD_GLOBAL` 指令会优先查找当前模块再回退至内置对象,且直接修改 `builtins` 模块会污染整个进程环境。 (5:00)
- Python 仅具有函数级作用域而无控制流块级作用域;为避免异常与调用栈帧构成循环引用导致内存泄漏,`except ... as e:` 代码块会在退出时隐式删除变量 `e`,导致后续访问报 `UnboundLocalError`。 (7:22)
- Python 3.12 彻底内联化了列表推导式以消除额外的栈帧开销,但依然能在单层函数栈帧内保证推导式循环变量不污染外部同名局部变量。 (9:40)
Detailed notes
## Background 本期视频由博主“码农高天”于 2024 年 9 月 7 日发布在 YouTube 平台。视频围绕 Python 语言中变量作用域的判定机制展开,深入解析了常见的理解误区与底层实现,并在片尾抛出了一个引发思考的小问题。
## TL;DR 视频系统性地剖析了 Python 变量作用域在编译期的判定机制,强调局部、闭包与全局作用域的划分本质上是静态字节码生成时的“自内向外找等号”过程。高天详细区分了模块级全局变量与真正的内置对象(Built-ins),并说明了 Python 缺乏块级作用域而采用函数级作用域的特性。接着,他通过异常捕获中为了打破循环引用而自动清理异常变量的机制,揭示了 UnboundLocalError 的深层原因。最后,视频对比了 Python 3.11 与 3.12 在列表推导式实现上的变动,展示了内联化字节码带来的作用域隔离谜题。
## Atmosphere & style 视频展现出极其严谨且硬核的技术探究氛围,高天以剥茧抽丝的逻辑直击 Python CPython 底层的字节码与内存机制。讲解风格沉稳清晰、不兜圈子,通过代码示例与字节码反编译一步步推导真相。整场分享充满启发性,不仅化解了多处反直觉的语言陷阱,更在结尾处留下了耐人寻味的性能优化悬念,带给开发者极佳的思维激荡与探索欲。
## Bulletin - [00:00](https://www.youtube.com/watch?v=k9D_HYaW_68&t=0s) Python 变量的作用域并非在运行时解析,而是在编译生成字节码阶段通过“由内而外找赋值语句”的静态规则确定为局部变量、闭包变量或全局变量。 - [05:00](https://www.youtube.com/watch?v=k9D_HYaW_68&t=300s) 全局变量底层由模块级命名空间和 Built-ins 组成,`LOAD_GLOBAL` 指令会优先查找当前模块再回退至内置对象,且直接修改 `builtins` 模块会污染整个进程环境。 - [07:22](https://www.youtube.com/watch?v=k9D_HYaW_68&t=442s) Python 仅具有函数级作用域而无控制流块级作用域;为避免异常与调用栈帧构成循环引用导致内存泄漏,`except ... as e:` 代码块会在退出时隐式删除变量 `e`,导致后续访问报 `UnboundLocalError`。 - [09:40](https://www.youtube.com/watch?v=k9D_HYaW_68&t=580s) Python 3.12 彻底内联化了列表推导式以消除额外的栈帧开销,但依然能在单层函数栈帧内保证推导式循环变量不污染外部同名局部变量。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=k9D_HYaW_68&t=0s) Python 变量作用域可划分为全局变量(Global)、局部变量(Local)和闭包变量(Closure / Free Variable)三种类型。 - [00:45](https://www.youtube.com/watch?v=k9D_HYaW_68&t=45s) 编译期判定变量类型的核心逻辑是自内向外“找等号”,只要当前函数内存在赋值语句,编译器便会直接标记为局部变量并生成 `LOAD_FAST` 指令。 - [01:15](https://www.youtube.com/watch?v=k9D_HYaW_68&t=75s) 如果局部和外层嵌套函数均未找到赋值,编译器默认将其视为全局变量,即使模块层级未定义也不会在编译期报错。 - [02:05](https://www.youtube.com/watch?v=k9D_HYaW_68&t=125s) `nonlocal` 声明会在编译期严格检查外层函数是否存在对应变量,若不存在直接报 `SyntaxError`;而 `global` 声明则不做静态存在性检查。 - [05:00](https://www.youtube.com/watch?v=k9D_HYaW_68&t=300s) 全局作用域本质上分为模块级别(Module-level)全局变量和内置对象(Built-ins)两层结构。 - [05:40](https://www.youtube.com/watch?v=k9D_HYaW_68&t=340s) `LOAD_GLOBAL` 执行时遵循先模块后内置的查找链,因此模块级定义的同名变量会直接遮蔽(Shadow)同名内置函数或对象。 - [06:55](https://www.youtube.com/watch?v=k9D_HYaW_68&t=415s) 闭包变量的本质是外层与内层函数共享同一个 Cell 对象的引用,相当于外层函数级别的共享变量。 - [07:22](https://www.youtube.com/watch?v=k9D_HYaW_68&t=442s) Python 不存在代码块作用域(Block Scope),`for` 循环和 `if` 分支中绑定的变量在跳出分支后仍可在当前函数作用域内自由访问。 - [08:10](https://www.youtube.com/watch?v=k9D_HYaW_68&t=490s) 在 `except Exception as e` 块结束后访问 `e` 会抛出 `UnboundLocalError`,因为解释器退出该块时会隐式执行类似 `del e` 的清理操作。 - [08:45](https://www.youtube.com/watch?v=k9D_HYaW_68&t=525s) 异常捕获变量被隐式删除的根本原因是为了打破 `Exception -> Traceback -> Frame -> Local Variable -> Exception` 的致命引用循环以防止内存泄漏。 - [09:40](https://www.youtube.com/watch?v=k9D_HYaW_68&t=580s) 在 Python 3.11 及更早版本中,列表推导式通过创建独立的匿名函数栈帧来隔离循环变量与外部同名变量。 - [10:15](https://www.youtube.com/watch?v=k9D_HYaW_68&t=615s) Python 3.12 对列表推导式进行了内联优化,移除了额外的函数调用栈帧,使得循环赋值操作直接在宿主函数帧内执行但仍保持外部变量隔离。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=k9D_HYaW_68&t=0s) 编译期决定的三种作用域与变量查找机制
关于 Python 变量的作用域,常见教程通常将其笼统地划分为全局变量和局部变量,但高天更倾向于划分为三种:全局变量(Global)、局部变量(Local)和闭包变量(Closure / Free Variable)。之所以这样分类,其核心逻辑在于作用域判定并非发生在运行时,而是在编译生成字节码时就已经确立了。高天通过一段简单的嵌套代码进行了直观展示:在模块级别定义了全局变量 `g = 0`,在外部函数 `outer()` 中定义了 `c = 1`,在内部嵌套函数 `f()` 中定义了局部变量 `L = 2`,并在 `f()` 内部打印这三个变量。当仅调用 `outer()` 并打印 `f` 的字节码时,嵌套函数 `f` 本身并未实际执行,但生成的字节码已明确将 `g` 标记为全局变量、`c` 标记为闭包变量、`L` 标记为局部变量。
编译器在编译期判断变量作用域的最直观方式可以概括为“找等号”,即采取由内而外的原则,去探查变量最先在哪个作用域内被赋值。如果在当前函数体内发现了对该变量的赋值语句,即便该赋值语句写在变量读取之后,编译器依然会毫不犹豫地认定其为局部变量(此时字节码会使用 `LOAD_FAST` 或 `LOAD_FAST_AND_CLEAR` / `LOAD_FAST_CHECK`,运行时若先读后写则直接报错);一旦局部作用域内没有赋值,编译器就会向外层嵌套函数查找,如果在外部函数中找到了赋值,就将其判定为闭包变量(字节码使用 `LOAD_DEREF`);如果局部和外层闭包均未找到赋值,编译器就会直接将其默认归为全局变量(字节码使用 `LOAD_GLOBAL`)。正因如此,哪怕删去模块级别的 `g = 0`,编译期生成的指令完全一致,依然是全局变量,直到实际运行发现找不到该变量时才会抛出错误。
这种默认机制还可以通过关键字进行人为干预。开发者可以使用 `nonlocal` 强行声明变量为闭包变量,也可以使用 `global` 声明其为全局变量。然而两者在编译器的处理上存在一个关键区别:当声明为 `nonlocal` 时,Python 编译器会在编译期严格检查外层函数中到底存不存在这个变量,如果外层没有赋值,编译期就会直接抛出语法错误;反之,若使用 `global` 声明全局变量,编译器则完全不做任何存在性检查。高天指出:“你在module level是否给一个变量复值完全不影响Python compiler判断变量的作用只要Python compiler在局部在臂包都没有找到就直接会默认它是一个全局变量”。
```mermaid flowchart TD accTitle: Python 编译期变量作用域判定逻辑 accDescr: 展示 Python 编译器根据赋值语句从内向外判定变量为局部、闭包或全局作用域的完整流程。 subgraph S1["作用域判定流程"] Start["分析函数内部变量引用"] --> ChkLocal{"当前函数内部<br/>是否有赋值或绑定?"} ChkLocal -- "是" --> Local["判定为局部变量<br/>(LOAD_FAST)"] ChkLocal -- "否" --> ChkClosure{"外层嵌套函数中<br/>是否有赋值?"} ChkClosure -- "是 (或显式声明 nonlocal)" --> Closure["判定为闭包变量<br/>(LOAD_DEREF)"] ChkClosure -- "否 (或显式声明 global)" --> Global["默认判定为全局变量<br/>(LOAD_GLOBAL)"] end subgraph S2["编译期静态检查差异"] Closure --> ChkExist{"nonlocal 声明时<br/>外层是否存在对应变量?"} ChkExist -- "不存在" --> Err["抛出 SyntaxError 编译错误"] Global --> NoChk["不做静态检查<br/>推迟至运行时解析"] end ```
- **由内而外屏蔽原则**:一旦在内层确定了变量类型,编译器便不再继续向外层寻找;即便外层和模块级都有同名变量赋值,只要内层有赋值,内层变量就永远是局部变量。 - **`nonlocal` 编译期静态验证**:显式指定 `nonlocal` 时,外层作用域必须真实绑定了该变量,否则无法通过字节码编译阶段。 - **`global` 宽容策略**:声明 `global` 后,即使全局作用域没有任何初始化语句,编译也能顺畅通过。
---
[05:00](https://www.youtube.com/watch?v=k9D_HYaW_68&t=300s) 全局变量的双层结构:Module Level 与 Built-ins
针对全局变量在编译期“不予检查”的奇怪表现,高天进一步拆解了所谓“全局”背后的深层结构。在 Python 底层,全局变量实际上由两个不同的作用域组合而成:一个是狭义的模块级别全局变量(Module-level Global Variables),另一个是内置对象(Built-ins)。我们日常调用的内置函数(如 `abs()`、`len()`)以及直接能够捕获或抛出的内置异常(如 `Exception`、`KeyError`),全部存放于 Built-ins 命名空间中。
模块级别全局变量与 Built-ins 的核心差异在于作用范围的物理边界:在某个 `.py` 文件中定义的 `G = 0`,其全局可见性仅限于当前模块内部,其他文件无法直接使用,必须通过 `from example import G` 或 `import example` 后以属性方式访问;而 Built-ins 则是真正意义上的全局对象,在任何 Python 文件内均无需任何导入即可直接使用。当 Python 虚拟机执行 `LOAD_GLOBAL` 指令时,它的底层查找链是分层的:先在当前模块级别的全局字典查找,若找不到,再回退到 Built-ins 字典中查找。高天强调:“所谓的全局是文件意义上或者module意义上的全局而Built-ins里面的那些东西它是真正的全局在任何一个python文件里都可以使用的全局变量”。
正因为这种分层回退机制,模块级别的变量定义会直接遮蔽(Shadow)内置对象。例如在脚本中自定义一个名为 `abs = 1` 的全局变量,后续直接调用 `abs` 就会获取到该整数,导致原有的内置取绝对值函数在当前模块失效。更激进的操作是直接导入 `builtins` 模块并覆写其属性:执行 `import builtins; builtins.abs = "Hello World"` 后,整个 Python 运行环境中的所有文件再去调用 `abs` 时,得到的都将是字符串 `"Hello World"` 并抛出类型错误。谈到闭包变量时,高天总结道,当外层函数被赋值的变量同时在内层函数中使用时,两个函数所引用的实际上是同一个变量对象,可以将其视作“外层函数级别的全局变量”。
- **全局查找优先级**:`LOAD_GLOBAL` 指令解析顺序永远是 `Current Module globals() -> builtins.__dict__`。 - **内置对象污染风险**:直接修改 `builtins` 模块内的属性会产生全局副作用,波及整个进程内所有已加载和后续加载的模块。 - **闭包本质**:内层与外层函数共享同一块 Cell 对象的引用,使该变量在离开外层执行环境后依然能够保持生命周期。
---
[07:22](https://www.youtube.com/watch?v=k9D_HYaW_68&t=442s) Python 的 Function Scope 与异常捕获中的变量清理
在探讨局部变量时,很多人以为局部变量逻辑很简单,但高天指出这里隐藏着不少语言特有的陷阱。首先,Python 并不具备 C/C++ 或 Java 等语言中的“块级作用域”(Block Scope / Code Block Scope),其局部作用域仅仅由函数(Function Scope)所定义。诸如 `for` 循环或 `if` 分支并不会开启新的变量空间。例如在一个函数内执行:
```python for i in range(10): k = i print(i, k) ```
在许多严格遵循代码块作用域的语言里,脱离循环体访问 `i` 或 `k` 会直接报未定义错误,但在 Python 中,外部代码可以毫无障碍地打印出 `9 9`,因为它们和外层函数处于完全相同的局部作用域。
紧接着,高天抛出了一个极易踩坑的经典问题:如果先定义 `e = 1`,随后进入 `try-except` 代码块抛出异常并捕获至变量 `e`(如 `try: raise Exception except Exception as e: pass`),在异常处理块结束后立即调用 `print(e)`,最终的输出会是什么?绝大多数未曾深究过该机制的人都会理所当然地认为输出是最初的 `1` 或者是捕获的异常对象。然而,实际运行结果却是直接抛出 `UnboundLocalError`,提示该变量未关联任何值。
这种看似荒谬的表现源自 Python 对内存泄漏的主动防护机制。高天解释道,当 Python 抛出异常时,该 `Exception` 实例内部会绑定详细的 `traceback` 对象;而 `traceback` 内部又持有当前的执行帧(`frame`);当前 `frame` 内的局部变量字典又恰好保存着赋值了该异常对象的变量 `e`。这就形成了一个致命的引用循环(Reference Cycle):`Exception -> Traceback -> Frame -> e -> Exception`。如果解释器不加以干预,哪怕整个函数执行结束,由于引用计数的循环依赖,函数内部所有的局部变量都无法被即时释放,必须被动等待周期性的垃圾回收器(Garbage Collector)扫描清理。高天指出:“正是出于这方面的考量所以Python在实现的时候就在这个try block之后把保存的exception变量给删掉了”。Python 在 `except` 块结束退出时强行插入了类似 `del e` 的清理操作,直接把局部变量的绑定干掉。因此,如果业务逻辑确实需要在异常块外部继续使用该异常实例,就必须在捕获后显式将其赋值保存至另一个完全不同的变量名中。
- **作用域边界**:除函数定义(以及推导式/类定义)外,`if`、`while`、`for`、`try` 等控制流结构均不会新建作用域。 - **异常引用循环链**:`e -> Exception -> __traceback__ -> Frame (f_locals) -> e`。 - **规避机制**:`except E as target:` 会在退出该块时隐式执行 `del target`,消除变量绑定以打破循环引用。
---
[09:40](https://www.youtube.com/watch?v=k9D_HYaW_68&t=580s) 列表推导式的变量作用域悬念(Python 3.11 与 3.12 的差异)
在梳理完函数作用域与异常块的局部变量行为后,高天展示了另一段引人深思的代码案例:首先初始化局部变量 `x = 0`,紧接着执行一段包含了相同变量名的列表推导式:
```python x = 0 _ = [x for x in range(5)] print(x) ```
执行后终端打印出的 `x` 依然是 `0`,而并没有像普通的 `for` 循环那样被循环变量的终值覆盖。针对这个现象,许多熟谙 Python 内部机制的开发者会立刻给出解释:在列表推导式求值时,Python 实际上隐式创建并调用了一个匿名的内联函数,推导式内部的 `x` 是定义在那个独立匿名函数内的局部变量,因此外层的 `x = 0` 自然毫发无损。
高天肯定了这个答案在以往的准确性,但同时给出了最新的技术纠偏:这个匿名函数的解释仅适用于 Python 3.11 及更早期的版本。从 Python 3.12 开始,解释器为了性能优化,彻底重构了推导式的字节码生成机制——列表推导式被真正地内联化(Inlined)了,执行时完全不再产生额外的函数调用开销。从 Python 3.12 的字节码中可以清晰看到,推导式中的循环确实直接在当前栈帧内对局部变量 `x` 执行了 `STORE_FAST`。
这就引出了一个自相矛盾的深层谜题:既然在 Python 3.12 中推导式已被完全内联、既没有闭包也没有独立栈帧,循环甚至直接向局部变量执行了写入操作,为什么在列表推导式执行完毕之后,后续的 `print(x)` 依然坚挺地输出了执行前的 `0`?关于“内联后的循环变量到底是如何在同一个函数栈帧内做到彼此隔离且互不污染”的实现原理,高天总结道:“然而在3.12之后的版本里就不存在这个匿名函数了也就是这个list comprehension被真正的inline了它没有再产生另外一个function call”,并因时长限制将该问题的底层推演留作思考题供大家自行探索。
- **Python 3.11 以前**:推导式编译为隐含的代码对象(Code Object),运行时通过栈调用独立执行,拥有独立的函数作用域。 - **Python 3.12 改进**:推导式直接内联进宿主函数作用域,消除了调用栈帧开销,字节码直接通过当前栈帧管理循环迭代。 - **课后悬念**:内联化后既能直接执行 `STORE_FAST` 又不破坏外部同名局部变量的底层实现机制。
## Notable quotes > [05:00](https://www.youtube.com/watch?v=k9D_HYaW_68&t=300s) 所谓的全局是文件意义上或者module意义上的全局而Built-ins里面的那些东西它是真正的全局在任何一个python文件里都可以使用的全局变量 — 高天
## Mentioned 高天, Python, Python 3.11, Python 3.12, C/C++, Java
## Video description 这期视频聊一下Python中对变量的作用域的判断方式,以及一些常见的问题。视频的最后留了一个小问题,大家可以自行研究。
[Source](https://www.youtube.com/watch?v=k9D_HYaW_68)