Python编译器在字节码阶段锁定作用域 异常块隐式删变量防泄漏

【python】详解变量作用域,局部闭包还是全局? — 码农高天

码农高天

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)

中文简报

Python编译器在字节码阶段锁定作用域 异常块隐式删变量防泄漏 Python变量作用域完全由编译期静态扫描赋值语句决定,且解释器在except块退出时会强制执行删除操作以切断执行帧循环引用导致的内存泄漏。 视频系统性地剖析了 Python 变量作用域在编译期的判定机制,强调局部、闭包与全局作用域的划分本质上是静态字节码生成时的“自内向外找等号”过程。高天详细区分了模块级全局变量与真正的内置对象(Built-ins),并说明了 Python 缺乏块级作用域而采用函数级作用域的特性。接着,他通过异常捕获中为了打破循环引用而自动清理异常变量的机制,揭示了 UnboundLocalError 的深层原因。最后,视频对比了 Python 3.11 与 3.12 在列表推导式实现上的变动,展示了内联化字节码带来的作用域隔离谜题。 Python 变量的作用域并非在运行时解析,而是在编译生成字节码阶段通过“由内而外找赋值语句”的静态规则确定为局部变量、闭包变量或全局变量。 (0:00) 全局变量底层由模块级命名空间和 Built-ins 组成,`LOAD_GLOBAL` 指令会优先查找当前模块再回退至内置对象,且直接修改 `builtins` 模块会污染整个进程环境。 (5:00) Python 仅具有函数级作用域而无控制流块级作用域;为避免异常与调用栈帧构成循环引用导致内存泄漏,`except ... as e:` 代码块会在退出时隐式删除变量 `e`,导致后续访问报 `UnboundLocalError`。 (7:22) Python 3.12 彻底内联化了列表推导式以消除额外的栈帧开销,但依然能在单层函数栈帧内保证推导式循环变量不污染外部同名局部变量。 (9:40)

Reading summary

码农高天指出,Python 变量作用域的核心判定机制并非发生在运行时,而是在编译生成字节码阶段便根据赋值位置确立;开发者常误解的全局、闭包与局部变量行为,本质上都受制于这一静态编译逻辑与底层运行时的生命周期管理。 编译期静态裁决与三类作用域判定 (0:00) 不同于将变量简单划分为全局和局部的常规理解,高天主张将 Python 变量细分为局部变量(Local)、闭包变量(Closure / Free Variable)与全局变量(Global)。编译期判定遵循“找等号”的由内而外原则:只要当前函数体内部存在针对该变量的赋值语句,即便读取写在赋值之前,编译器也会直接将其标为局部变量(生成 LOAD_FAST 类指令),运行时先读后写则直接报错;若局部未赋值但在外层嵌套函数存在赋值,则标记为闭包变量(LOAD_DEREF);若内外均无赋值,编译器默认将其归入全局变量(LOAD_GLOBAL),即便模块级未定义该变量,编译也能通过,仅在运行时未找到才报错。 显式关键字在编译期存在严格程度差异。使用 nonlocal 强制声明闭包变量时,编译器会进行静态验证,一旦外层嵌套函数未绑定该变量,将直接抛出 SyntaxError 编译错误;而使用 global 声明全局变量时,编译器完全不做存在性检查。这种设计体现了局部由内而外的屏蔽特性,内层一旦有赋值绑定便不会向外渗透查找。 全局变量的双层结构与遮蔽机制 (5:00) 编译器对全局变量不做存在性检查的原因,在于全局变量由模块级全局变量(Module-level)与内置对象(Built-ins)两层结构共同构成。诸如 abs()、len() 或 Exception 等均位于 Built-ins 命名空间中。模块级全局变量的物理边界仅限于所属的单个文件,跨文件必须显式导入;而 Built-ins 则是整个 Python 进程中无需导入即可访问的真正全局对象。 当虚拟机执行 LOAD_GLOBAL 指令时,查找链永远严格遵循先当前模块 globals()、后 builtins.__dict__ 的回退机制。这一优先级导致模块内部定义同名变量会直接遮蔽内置对象(例如在模块中定义 abs = 1 将使内置函数失效)。若通过 import builtins 直接覆写 builtins 模块属性,其破坏性副作用将波及整个进程内所有模块。针对闭包变量,内层与外层函数则是共享同一个 Cell 对象的引用,其本质相当于外层函数的局部级共享变量。 函数作用域约束与异常引用的强制清理 (7:22) Python 不存在 C/C++ 或 Java 式的块级作用域(Block Scope),其局部作用域仅由函数(Function Scope)界定。诸如 for 循环和 if 条件分支并不会创建独立作用域,在循环结束后继续访问迭代变量或循环体内赋值的变量依然完全合法有效。 然而局部作用域中存在隐式清理陷阱。若在 try-except 块中捕获异常至变量 e,即使在进入异常块前已定义过 e = 1,异常块结束后执行 print(e) 仍会抛出 UnboundLocalError。高天解释,这是 Python 为避免内存泄漏的主动防护:异常实例内部绑定了 traceback,traceback 持有当前执行帧 frame,而 frame 的局部变量字典又持有该异常实例变量,三者构成了引用循环(Exception -> Traceback -> Frame -> e -> Exception),导致函数退出后垃圾无法被引用计数即时回收。为此,Python 解释器在 except 块退出时强行插入了类似 del e 的解除绑定操作。 列表推导式的内联演进与变量隔离 (9:40) 在列表中使用与外层相同的变量名(如 x = 0 后执行 [x for x in range(5)]),后续 print(x) 依然输出 0 而不会被循环变量覆盖。在 Python 3.11 及更早版本中,这一隔离是通过将推导式编译为独立的匿名内联函数实现的,推导式内部变量拥有独立的函数作用域。 从 Python 3.12 开始,解释器为了消除额外的函数调用开销,对列表推导式的字节码生成机制进行了重构,将其真正内联(Inlined)到宿主函数中。字节码显示其循环已直接在当前栈帧内通过 STORE_FAST 写入局部变量。 列表推导式在 Python 3.12 彻底内联化、既无独立栈帧也直接在宿主栈帧内执行 STORE_FAST 的前提下,究竟通过何种底层机制保障外部同名局部变量不被覆盖,高天将其留作进阶机制的思考题,未在本次内容中展开详解。

Source quotes

[5:00] 高天: 所谓的全局是文件意义上或者module意义上的全局而Built-ins里面的那些东西它是真正的全局在任何一个python文件里都可以使用的全局变量

Original transcript

[0:00] 这期视频我们聊一下Python中变量的作用域我们之前出过一期视频啊比较详细的聊了一下全局变量和币包变量这期视频我们就更全面的去了解一下跟python变量作用有关的事情我们见到更多的说法可能是有全局变量和局部变量但是我个人一般会认为有三种啊全局变量局务变量和币包变量那我之所以会这么分类是因为这三种变量在绝大多数情况下都是编译期被决定的我们看左边这条程序一共定义了三个变量第二行在module level有一个g=0第五行在这个out函数里面有一个c等于1第七行在F函数里有一个L等于2而在第8行我们把GCL都打印出来我们来运行一下这个out函数注意我们运行outra函数的时候是不执行这个f的这个out函数只定义了这个F并且打印了它的字节码我们可以看它的字节码在没有运行F的时候 编译出来这个字节码就知道了 G是一个全局变量 C是一个币包变量 而L是一个局部变量那我们先来简单说一下在编译期python是怎么判断一个变量的作用域的或者说当python想使用一个变量的时候他是去哪找的一个最直观的理解方式就是找等号也就是从里往外找这个变量是在哪个作用域内被复值的我们看L是在F内部被复制的所以在F里面去寻找L的时候他就会认为它是一个局部变量而C是在外层函数被复制的所以他会认为C是一个B包变量如果既不是局部变量也不是臂包变量他就会认为他是一个全局变量我们可以看哪怕我们把这个对G的负值给删掉他编译出来的结果依然是一样的他会认为这个G是一个全局变量你在运行这个F函数的时候Tato跟你报错说这个G没有地方而对于局部变量来说哪怕这个变量的负值是在你使用这个变量之后Python也会认为它是一个局部变量我们看这里的字节码它依然用的是loadfast当然这里加了一个check是因为python的compiler发现了他先使用后复值的而对变量作用力的判断是遵循着由内而外的原则的也就是只要在相对内部的地方发现了这个变量是其中一种变量那就不再往外涨了比如这里在F里面已经有对L的负值了那python就认为它是一个局部变量不管在它的外层函数和module level有没有这个变量的复值这个变量都是局部变量对于臂包变量当然也是一样的大家可以看到对于我们这条代码Python依然认为L是局部变量而C是笔包变量那其实就像刚才我们说的你在module level是否给一个变量复值完全不影响Python compiler判断变量的作用只要Python compiler在局部在臂包都没有找到就直接会默认它是一个全局变量只不过在运行的时候他可能会发现全局没有这个变量那这是Python compiler的默认行为对吧我们可以强行的告诉他这个变量不是全局变量我们可以用这个non-local关键字告诉python的compiler这个变量它是一个臂包变量可以看到这个时候Python就把这个变量当做币包变量来处理了这里注意一下当我们把这个变量声明为臂包变量的时候拍葬师会检查这个臂包变量是否存在的如果我们把外层函数里面L等于2给注释掉可以看到Python在编译期是会出错的他会告诉你这个臂包变量它压根不存在相似的我们可以用global关键字来声明这个变量是一个全局变量可以看到Python这里就认为L是全局变量了而对于全局变量就像我们刚才说的他是不会去检查全局里面有没有这个变量的所以大家看哪怕我们把这个L等于2给注释掉这段代码依然是可以正常编译的好我们会发现全局变量怪怪的对吧怎么一到他就啥也不检查了呢为什么他和臂包变量局部变量哎有这么多不一样的这里就涉及到一件事情就是我们刚才所谓的全局变量其实是由两个部分组成的它们分别是module level的全局变量和所谓的built-ins也就是内置对象在python中是有非常多的内置对象的我们最常见的那些内置函数以及我们可以直接race的那些内置的exceptions他们都是内置对象那我们这个狭义的module level的全聚变量和这些builtins他们最大的区别在于module level the global variables只是在这个module或者说在这个文件里是所谓全局的 [5:00] 比如说我现在在这个文件里边有一个G等于0那在另外一个python文件里面我是没有办法直接打印出G来的我就得 from example import g或者是 import example 然后example.g也就是这个全局变量所谓的全局是文件意义上或者module意义上的全局而Built-ins里面的那些东西它是真正的全局在任何一个python文件里都可以使用的全局变量而当我们在做load global的时候它会先在module level里面涨有没有全局变量如果没找着就会去built-ins里面找有没有这个全局变量我之所以把这两个实际上作用与不同的全局变量放到一起是因为他们都会有lobal触发但是大家要知道啊这两个实际上是两个作用域所以有人在讲这个变量作用域的时候也会把他们俩分开讲我们看这段函数我们打印这个ABS我们知道它是一个内置函数对吧这个load global实际上就检查了一下我们这个文件里面有没有定义ABS如果没有的话他就去built-ins里面找有没有ABS这也就意味着如果我们在这个文件里面定义了全局变量同样的代码它就会打印我们在文件级别定义的全局变量也就是module level要优于builtins当然我们可以干更疯狂的事情我们可以通过import built-ins来直接修改这些内置对象我们让built-in steel abs等于hello world这个时候我们再打印出来的ABS也是Hello World而当我们进行了这个操作之后不管哪个文件使用ABS这个函数啊他都会是hello world当然他现在已经不是一个函数了对吧他就会报错以上说的是全局变量啊那对于臂包变量的机制,我们在一期视频里面曾经详细的介绍过在使用的时候大家只要意识到当一个在外层函数被复制的变量在内层函数也被使用了的时候就形成了臂包而臂包简单的来说就是这两个变量现在就是一个变量了你可以把它认为是外层函数级别的全局变量所谓作用愈慢我们可以看到内层函数是能意识到这个变量是币包变量的同样的外层函数也能意识到这是一个臂包变量如果大家真的对机智感兴趣可以参考我之前的期视频最后我们说一下局部原谅可能有人觉得局部员有什么好讲的其实还是有啊最重要的就是Python的局部变量只有一个scope就是function scope换句话说Python除了定义函数之外并不会产生新的变量作用域比如我们给的这个非常经典的代码在其他的一些语言中可能 print i k 是要报错的因为I是一个循环变量而K是在这个循环里面被复制的变量那有一些有所谓code block scope 的语言他的loop他的if statement可能都会产生新的作用欲但是 Python 不会所以左边这条代码 printik 会打印出来 99因为在最后一次循环结束的时候I是9 K也是9然后对于局部变量呢有一个比较特殊的情况大家可以看左边这边代码我上来让1等于1然后我 raise 了一个 exception把这个Exception 保存到E请问这个exception结束之后我打印E结果是什么这个题如果你能非常有信心的作对啊说明你之前做过这道题他结果是这么一个东西啊他告诉你义这个里面啊什么都没有这个结果是不是非常荒谬啊我上来就给E复值了后来我把义覆盖了他最后怎么也不能是什么都没有啊这里就涉及到Python一个特殊的机制啊由于在raise exception的时候,这个exception里保存了trace back而traceback里边又有frameframe里面保存了局部变量所以在这里这个局部变量E就产生了一个reference cycle也就是所谓引用循环那如果我们不去主动解除这个循环这个函数哪怕在运行之后他所有的局部变量都没有办法被正常的释放直到下一个垃圾回收那正是出于这方面的考量所以Python在实现的时候就在这个try block之后把保存的exception变量给删掉了你可以认为他在这里做了一个Delete E就是他把这个局部边缘的引用给强行干掉了来解决这个reference cycle的问题所以你在这个try block之后打一亿就会报错那如果你真的需要在这个try block之外使用这个exception你需要显示的把它保存到另外一个变量里这样你才能接着用的啊好那有了之前的经验我们再来看一下这段代码我们上来让x等于0然后我们做了一个东西叫 list comprehension只不过在这个 least comprehension 里我们又重复的用到了这个局部变量 X请问打印出来的X是多少没想到吧 还是零 [10:00] 为什么呢为什么这个X没有跟着这个list comprehension做改变呢可能有的人会说啊实际上这个list comprehension建立了一个新的匿名函数而这个循环变量X实际上是在那个匿名函数里面定义的这个答案非常好啊能说出这个答案说明你对3.11版本之前的python非常熟悉对于3.11之前的python来说这个答案是正确的对所谓X的复值确实是在另外一个匿名函数里面完成的然而在3.12之后的版本里就不存在这个匿名函数了也就是这个list comprehension被真正的inline了它没有再产生另外一个function call我们也可以看到的这个for loop里面是真的在对这个局部变量X进行store fast那为什么这个printX会打印出来这个list comprehension之前的0呢这一视频时间不够了这个问题呢我们就留给大家自己研究了好那这期视频这里喜欢点赞关注别忘了一见散伞