Python编译机制重写变量寻址 赋值语句触发读写异常

【python】定义好的变量读不出来?详解全局变量和自由变量的使用! — 码农高天

码农高天

Python在编译期依据函数内是否存在赋值操作强行绑定局部作用域,导致未加global或nonlocal声明的代码无论书写先后都会在运行时抛出未定义错误。

AI-assisted summary of 【python】定义好的变量读不出来?详解全局变量和自由变量的使用! — original source. Check the original for full context.

Source published:

Key ideas

  • Python 作用域判定发生在编译阶段,只要函数内存在对某变量的赋值操作,该函数内对该变量的所有引用都会被默认标记为局部变量(使用 LOAD_FAST 指令),即便读取语句写在赋值语句之前也会触发 UnboundLocalError。 (0:00)
  • 在嵌套函数闭包中若要修改外层函数的自由变量,必须显式使用 nonlocal 声明;若使用 global 会错误指向顶层全局命名空间,而若不加声明则会被视为内层函数的独立局部变量。 (3:00)
  • 民间常说的“全局变量可读不可写”容易误导开发者;判定是否需要 global 声明的唯一标准是变量符号本身是否被赋值绑定(如出现在等号左边),修改容器内部状态(如 d['k'] = v)本质上只是读取变量后操作内部内容,无需 global。 (3:00)

Detailed notes

## Background 该视频由“码农高天”于2024年5月12日发布在YouTube平台上。视频简介指出,本期内容旨在深入剖析Python中使用全局变量和自由变量时常踩的坑,并详细解答何时需要显式添加global与nonlocal声明。

## TL;DR 视频从Python中全局变量在函数内因赋值而导致读取崩溃(UnboundLocalError)的反直觉现象切入,通过对比字节码揭示了Python在编译阶段判定变量作用域的底层机制。作者指出,只要函数内部存在对变量的赋值操作,无论赋值语句写在第几行,Python编译期都会默认将其标记为局部变量并生成LOAD_FAST指令。对于闭包结构中的自由变量,则需使用nonlocal而非global进行显式声明。最后,作者澄清了“可读不可写”这一常见误区的本质,强调必须严格观察变量符号本身是否出现在等号左边被重新绑定,而非单纯看对象内容是否被原地修改。

## Atmosphere & style 视频展现了硬核而又接地气的技术拆解风格,主讲人以开发者日常极易踩坑的困惑为引子,直奔底层逻辑。讲解语调平静沉稳、条理清晰,通过对比字节码和代码执行路径,抽丝剥茧地还原出Python编译器的运作真相。整体氛围既有直击语言设计“反人类”痛点的共鸣感,又有看清底层运行机制后豁然开朗的解惑快感。

## Bulletin - [00:00](https://www.youtube.com/watch?v=tLcDQhy9Ew0&t=0s) Python 作用域判定发生在编译阶段,只要函数内存在对某变量的赋值操作,该函数内对该变量的所有引用都会被默认标记为局部变量(使用 LOAD_FAST 指令),即便读取语句写在赋值语句之前也会触发 UnboundLocalError。 - [03:00](https://www.youtube.com/watch?v=tLcDQhy9Ew0&t=180s) 在嵌套函数闭包中若要修改外层函数的自由变量,必须显式使用 nonlocal 声明;若使用 global 会错误指向顶层全局命名空间,而若不加声明则会被视为内层函数的独立局部变量。 - [03:00](https://www.youtube.com/watch?v=tLcDQhy9Ew0&t=180s) 民间常说的“全局变量可读不可写”容易误导开发者;判定是否需要 global 声明的唯一标准是变量符号本身是否被赋值绑定(如出现在等号左边),修改容器内部状态(如 d['k'] = v)本质上只是读取变量后操作内部内容,无需 global。

## Key takeaways - [00:00](https://www.youtube.com/watch?v=tLcDQhy9Ew0&t=0s) 在函数内仅读取全局变量时程序可正常运行,但一旦加入修改该变量的赋值语句,即便读取放在赋值之前也会抛出 UnboundLocalError 报错。 - [00:00](https://www.youtube.com/watch?v=tLcDQhy9Ew0&t=0s) 通过对比 Python 字节码可以发现,纯读取时使用的是 LOAD_GLOBAL 指令,而一旦作用域内出现赋值,读取指令就会被全部替换为局部变量读取指令 LOAD_FAST。 - [00:00](https://www.youtube.com/watch?v=tLcDQhy9Ew0&t=0s) 变量的作用域类型由 Python 在编译阶段通盘决定,并非按运行时代码执行的先后顺序动态判定。 - [00:00](https://www.youtube.com/watch?v=tLcDQhy9Ew0&t=0s) 当函数内未显式声明 global 且存在对变量的赋值操作时,Python 会默认将该变量视为当前函数的局部变量。 - [00:00](https://www.youtube.com/watch?v=tLcDQhy9Ew0&t=0s) 在函数中若需要对顶层全局变量进行重新绑定赋值,必须在作用域开头使用 global 关键字进行显式声明。 - [03:00](https://www.youtube.com/watch?v=tLcDQhy9Ew0&t=180s) 闭包结构中同样存在作用域判定机制,在内层嵌套函数中对同名变量赋值默认只会创建内层的局部变量,无法直接修改外层函数的变量值。 - [03:00](https://www.youtube.com/watch?v=tLcDQhy9Ew0&t=180s) 在闭包中尝试修改外层函数的自由变量时不能使用 global,而必须使用 nonlocal 关键字显式声明。 - [03:00](https://www.youtube.com/watch?v=tLcDQhy9Ew0&t=180s) “全局变量可读不可写”的说法语义含糊,更精确的判断标准是该变量名是否在当前作用域内出现在等号左边被重新赋值绑定。 - [03:00](https://www.youtube.com/watch?v=tLcDQhy9Ew0&t=180s) 对全局字典或列表进行原地修改(如 d['key'] = value)在字节码层面属于先读取全局变量再执行下标操作,未重新绑定变量本身,因此不需要 global 声明。

## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=tLcDQhy9Ew0&t=0s) 全局变量“可读不可写”的报错困惑与字节码底层机制

在 Python 中使用全局变量时,很多初学者都会遇到一个极其违背直觉的陷阱。码农高天首先给出一个简单示例:外部定义了一个全局变量 `count = 0`,接着定义函数 `f()` 并在其中执行 `print(count)`,调用 `f()` 时程序会顺利打印出 `0`。然而,如果稍作改动,在函数内加入一行 `count = count + 1`,程序就会直接崩溃报错,提示局部变量在赋值前就被引用(UnboundLocalError)。许多开发者在懵圈之余会尝试调试,想看看函数内部的 `count` 到底是什么,于是在进入函数的第一行就先写 `print(count)`,试图在执行 `count = count + 1` 之前把值打出来;但令人惊讶的是,原本能正常打印的代码此时也会抛出同样的错误。

去网上搜索或求助他人后,通常得到的解决方案是在函数最开头加上一行 `global count` 显式声明,程序便会按照预期顺利运行。但背后的核心疑问依然存在:为什么原本好好的读取操作,一旦后续加上了赋值,前面就读不出来了?通过对比 Python 生成的字节码,码农高天揭示了底层真相: - 在最初只有读取的函数中,Python 加载变量时使用的是指令 `LOAD_GLOBAL`,即从全局作用域中查找变量。 - 当函数中加入了 `count = count + 1` 之后,加载指令瞬间变成了 `LOAD_FAST`,也就是读取局部变量。既然读取的是局部变量,而此时局部作用域内尚未对它完成赋值,自然就会直接抛出未定义异常。 - 当显式加上 `global count` 后,指令才重新被指定为 `LOAD_GLOBAL`。

码农高天强调,这是由于 Python 在编译阶段就决定了变量的作用域类型:“因为他在编译的时候,发现了一个对 count 的 assignment。当你在一个函数里尝试对一个变量赋值的时候,Python 就会默认这个变量是一个局部变量,无论你赋值的位置是在哪。”这意味着即使在第四行打印、第五行才赋值,Python 在编译期就已经把整个函数内的 `count` 标记成了局部变量,并在局部变量表中查找它,最终导致第四行的打印崩溃。码农高天总结道:“所以说当你的函数里存在对全局变量的赋值的时候,你就必须要显式的声明这个变量是一个全局变量;反之当你这个函数里只使用了这个变量而没有对这个变量赋值的时候,Python 就会机智的在全局变量里去寻找这个变量。”

```mermaid flowchart TD accTitle: Python函数内变量作用域的编译期判断逻辑 accDescr: 展示Python编译器根据函数内是否有赋值语句来决定使用LOAD_FAST还是LOAD_GLOBAL的过程 subgraph CompileStage["编译期分析 (Compile Time)"] A["检查函数内部作用域"] --> B{"是否存在对变量的赋值操作?"} B -->|"是 (且未显式声明)"| C["默认标记为局部变量 (Local)"] B -->|"否 (且非参数)"| D["标记为全局/自由变量 (Global/Free)"] C --> E["使用 LOAD_FAST 指令读取"] D --> F["使用 LOAD_GLOBAL 指令读取"] end subgraph RuntimeStage["运行期行为 (Runtime)"] E --> G{"读取时局部变量是否已赋值?"} G -->|"未赋值"| H["抛出 UnboundLocalError 报错"] G -->|"已赋值"| I["正常读取局部变量"] F --> J["在全局作用域中成功找到并读取"] end ```

[03:00](https://www.youtube.com/watch?v=tLcDQhy9Ew0&t=180s) 闭包自由变量、`nonlocal` 声明与“写”的本质判定

这种编译期作用域判定的机制不仅适用于全局变量,同样适用于闭包(Closure)结构中的自由变量。例如在外部函数 `G` 中定义 `count = 0`,内部嵌套函数 `F` 中执行赋值操作: 1. 如果在 `F` 中直接写 `count = count + 1`,同样会发生读取在赋值之前的未绑定错误。 2. 如果在 `F` 中直接写 `count = 1`,虽然不会报错,但在执行完 `F()` 之后,外部函数 `G` 内部的 `count` 依然是 `0`。这是因为 `count = 1` 在编译时被视为了 `F` 自身的局部变量,并没有通过闭包机制去修改外层 `G` 的变量。 3. 如果此时在 `F` 中误写为 `global count`,程序依然会出错,因为目标变量并非最外层的全局变量,而是属于闭包外层的自由变量。此时必须使用 `nonlocal` 关键字来显式声明该变量不是局部变量。

码农高天坦言,Python 的这个特性“说实话有一点点反人类,不是非常的直观”。坊间常有一种总结叫做“全局变量可读不可写”,虽然好记,但极易造成混淆。以字典为例,如果在外部定义一个全局变量字典 `d`,在函数 `F` 内部执行字典内容的修改(例如通过下标 `d['key'] = value` 赋值),代码完全可以正常运行。如果单从语义上讲这算不算对 `d` 的写入,定义是相当含糊的。但从底层的字节码视角看,该操作本质上是先通过 `LOAD_GLOBAL` 读取了全局字典 `d`,随后通过下标操作符(subscript)修改其内部内容,并没有对变量 `d` 本身进行重新绑定赋值。

码农高天指出,为了彻底避免概念混淆,不能单凭常识理解的“写入”,而必须严格看变量在当前作用域内是否被“赋值”: > “因此我觉得一个更不容易混淆的方法还是要去看这个变量在这个 scope 之内有没有被赋值,也就是他有没有出现在等号的前面。只要他被赋值了,他就会被默认成是一个局部变量;如果他没有被赋值,他又不是一个输入的 argument,Python 就会认为它是一个全局变量。”

最后,码农高天再次强调,赋值语句出现的物理顺序并不能改变这一规则,即便读取代码写在赋值代码的前面,Python 在编译时依然会通盘将其认作局部变量。

- **显式声明的作用场景区分**: - `global`:用于显式声明变量属于模块顶层的全局命名空间。 - `nonlocal`:用于闭包内部,显式声明变量绑定在外层嵌套函数的局部作用域(自由变量),而非全局作用域。 - **变量类型的判断核心依据**:是否作为赋值目标(出现在赋值号 `=` 的左侧,或被显式赋值语句直接绑定)。字典或列表的原地修改(如 `d[k] = v` 或 `lst.append()`)属于对已读取对象的内部修改,并不属于对变量符号本身的赋值绑定,因此不需要加 `global`。

## Notable quotes > [00:00](https://www.youtube.com/watch?v=tLcDQhy9Ew0&t=0s) 当你在一个函数里尝试对一个变量赋值的时候,Python 就会默认这个变量是一个局部变量,无论你赋值的位置是在哪。 — 码农高天

> [00:00](https://www.youtube.com/watch?v=tLcDQhy9Ew0&t=0s) 当你的函数里存在对全局变量的赋值的时候,你就必须要显式的声明这个变量是一个全局变量;反之当你这个函数里只使用了这个变量而没有对这个变量赋值的时候,Python 就会机智的在全局变量里去寻找这个变量。 — 码农高天

## Mentioned Python, 码农高天

## Video description 这期视频我们讲一下python中使用全局变量和自由变量时候的一些坑。到底什么时候需要加global和nonlocal声明呢?

[Source](https://www.youtube.com/watch?v=tLcDQhy9Ew0)

中文简报

Python编译机制重写变量寻址 赋值语句触发读写异常 Python在编译期依据函数内是否存在赋值操作强行绑定局部作用域,导致未加global或nonlocal声明的代码无论书写先后都会在运行时抛出未定义错误。 视频从Python中全局变量在函数内因赋值而导致读取崩溃(UnboundLocalError)的反直觉现象切入,通过对比字节码揭示了Python在编译阶段判定变量作用域的底层机制。作者指出,只要函数内部存在对变量的赋值操作,无论赋值语句写在第几行,Python编译期都会默认将其标记为局部变量并生成LOAD_FAST指令。对于闭包结构中的自由变量,则需使用nonlocal而非global进行显式声明。最后,作者澄清了“可读不可写”这一常见误区的本质,强调必须严格观察变量符号本身是否出现在等号左边被重新绑定,而非单纯看对象内容是否被原地修改。 Python 作用域判定发生在编译阶段,只要函数内存在对某变量的赋值操作,该函数内对该变量的所有引用都会被默认标记为局部变量(使用 LOAD_FAST 指令),即便读取语句写在赋值语句之前也会触发 UnboundLocalError。 (0:00) 在嵌套函数闭包中若要修改外层函数的自由变量,必须显式使用 nonlocal 声明;若使用 global 会错误指向顶层全局命名空间,而若不加声明则会被视为内层函数的独立局部变量。 (3:00) 民间常说的“全局变量可读不可写”容易误导开发者;判定是否需要 global 声明的唯一标准是变量符号本身是否被赋值绑定(如出现在等号左边),修改容器内部状态(如 d['k'] = v)本质上只是读取变量后操作内部内容,无需 global。 (3:00)

Reading summary

码农高天指出,Python 在函数内对变量作用域的判定发生于编译期,只要函数内部存在针对该变量的赋值语句,该变量就会被默认标记为局部变量,导致后续或先前的读取指令转为针对未绑定局部变量的查找而抛出异常。 编译期赋值判定与字节码读取机制 (0:00) 在 Python 中,如果函数内部仅读取全局变量 `count`,字节码会生成 `LOAD_GLOBAL` 指令从全局作用域中查找变量并顺利输出。然而,一旦函数内部包含诸如 `count = count + 1` 的赋值语句,即使读取代码 `print(count)` 写在赋值语句之前,程序也会抛出 `UnboundLocalError`。码农高天通过对比字节码说明,在编译阶段,只要 Python 发现函数内存在针对某个变量的赋值操作,就会默认将该函数内的所有该同名变量标记为局部变量,进而将读取指令转变为 `LOAD_FAST`。由于运行时读取时尚未对该局部变量完成赋值,程序便会崩溃;只有在函数开头显式添加 `global count`,Python 才会重新生成 `LOAD_GLOBAL` 指令。 闭包结构中自由变量与 nonlocal 声明 (3:00) 这种编译期的作用域判定规则同样适用于闭包结构。当在外层函数中定义 `count = 0` 并在内部嵌套函数中执行 `count = count + 1` 时,内部函数同样会因读取未绑定的局部变量而报错;若直接在内部函数写 `count = 1`,虽然不会报错,但该操作仅会被视为内部函数的独立局部变量赋值,无法修改外层函数的 `count`。码农高天强调,在此类场景中不能使用 `global`,因为目标变量属于外层嵌套函数的自由变量而非模块级全局变量,开发者必须使用 `nonlocal` 关键字进行显式声明,才能正确跨层级绑定并修改外层作用域中的变量。 赋值绑定与容器对象内部修改的本质界定 (3:00) 坊间流传的“全局变量可读不可写”容易引发误解。码农高天分析指出,以全局字典 `d` 为例,在函数内部通过下标执行 `d['key'] = value` 可以顺利运行,这是因为底层逻辑是先通过 `LOAD_GLOBAL` 读取全局字典对象,再调用下标操作修改其内部内容,并没有对变量 `d` 本身进行重新绑定。因此,准确的判断标准并非语义上的“是否写入”,而是变量名本身是否在当前作用域内充当赋值目标(即是否出现在等号左侧被重新赋值)。只要变量未被赋值且非传入参数,Python 就会在全局或外部作用域中解析它;一旦存在赋值绑定,则必须依场景显式声明 `global` 或 `nonlocal`。 码农高天认为该机制虽然略显不符合直觉,但避免混淆的核心在于严格依据变量在作用域内是否存在符号级别的赋值绑定,而非依赖抽象的读写概念或语句书写的物理先后顺序。

Source quotes

[0:00] 码农高天: 当你在一个函数里尝试对一个变量赋值的时候,Python 就会默认这个变量是一个局部变量,无论你赋值的位置是在哪。 [0:00] 码农高天: 当你的函数里存在对全局变量的赋值的时候,你就必须要显式的声明这个变量是一个全局变量;反之当你这个函数里只使用了这个变量而没有对这个变量赋值的时候,Python 就会机智的在全局变量里去寻找这个变量。

Original transcript

[0:00] 这期视频我们讲一个跟全区变量有关的内容我们看这段程序我们有一个全局变量 count然后定义了一个函数 ff 里面就是 print count然后运行 f大家都很容易想出来这点程序结果对吧就打印出来一个零好现在问题来了如果我们在这个函数里面做count等于count+1在运行程序我们就会发现它报错了他说Camp还没复制就使用了这时候你肯定很懵对吧我定你了看他呀然后你说行,那我看一下在f里面看出到底是什么于是你在进入F这个函数之后跟刚才一样立刻打印了count你发现竟然还是不好使刚才还能成功打印的Tent 现在也找不着了这个时候如果你去搜索一下或者你问一下别人可能就会有人告诉你说你使用count的时候要先定义它是一个global variable然后你就学着网上的样子啊在函数最前面加了一个 global account这个时候再运行程序你就发现他竟然按照你想象的方式运行了但是内心深处还是有疑问的对吧为什么我刚才print count print好好的现在就要定义这个global count呢我们就先来看一下刚才那几个程序的字节码首先最开始的这个程序我们可以看到在函数F里当他尝试读取Count的时候他使用的是LoadGlobal也就是读取全局变量然而当我们在函数里加入了count等于count+1这行代码之后在读取count的时候,它就变成了loadfast,也就是读取局部变量那我们之前定义的 count 是一个全局变量在读取局部变量的时候自然就找不到它了在我们写了global count这行程序之后我们再读取count的时候它就又变成了load global了这样他自然也就正常工作了那为什么python在这种情况下会使用loadfast而不是loadglobal来读取count的值呢?因为他在编译的时候,发现了一个对Camp的assignment当你在一个函数里尝试对一个变量负值的时候python 就会默认这个变量是一个局部变量无论你复职的位置是在哪也就是说 即使我们是在第四行先print the count然后再在第五行对Camp进行了复制python依然会在编译的时候认为count这个东西是一个局部变量于是在打印的时候他也把它当成局部变量在局部变量的范围里面去寻找这个count所以说当你的函数里存在对全局变量的负值的时候你就必须要显示的声明这个变量是一个全局变量反之当你这个函数里只使用了这个变量而没有对这个变量复值的时候Python就会机智的在全局变量里去寻找这个变量事实上不光是全局变量如此Python的臂包也有这样的属性我们看我们在函数G里面定义了temp=0然后在G函数内部定义的F里面对Camp进行复制这样的代码依然是会报错的或者我们可以换一种形式在G函数里面定义的cant=0,然后在F函数里面尝试把cant变成1你会发现在运行完F函数之后,G里面的count依然是0这是因为在f函数里面你做cant=1的时候它改变的是f内部的局部变量而不是通过臂包机制改变G里面的局部变量那在这种情况下,如果我们把 count 声明成一个全局变量你会发现它依然是报错的因为我们需要的看法它不是一个全局变量它是一个在臂包外层的变量所以正确的声明方式是用non-local来声明这个变量不是一个局部变量那Python的这个特性呢说实话有一点点反人类啊不是非常的直观当然他这么设计肯定也有他的想法对吧那我听过一个对于这种说法的总结叫做全局变量可读不可写这个说法非常好记但是有一个让人容易混淆的地方我们看这段程序,我们定义了一个全局变量dictionaryd然后在函数F里面对D进行一个改变这段代码是完全可以正常工作的那你认为在F函数里面这个操作算是对D的写入吗这件事就多少有点含糊因为我们确实是改变了地但是我们改变D的方式 本质上从字节码来看 是首先读取了全局变量D然后再用subscribe去改变了全局变量地理的内容所以从 [5:00] 从我们的角度看,你可以认为他是血,也可以认为他不是血因此我觉得一个更不容易混淆的方法还是要去看这个变量在这个scope之内有没有被复值也就是他有没有出现在等号的前面只要他被复值了他就会被默认成是一个局部变量如果他没有被复值他又不是一个输入的argumentPython就会认为它是一个全局变量同时一定要注意这跟这个复值语句出现在哪里出现的顺序是没有关系的即便你对这个变量的读取是出现在复值之前的python也会认为它是一个局部变量好那这期视频就到这里希望给大家有所帮助喜欢的话记得点一下关注别忘了一键三连哦优优独播剧场——YoYo Television Series Exclusive