PEP 289求值时机分流解释Python生成器运算偏差

【python】几乎没人做对的题目?聊聊生成器表达式里不为人知的秘密 — 码农高天

码农高天

生成器表达式仅对最外层for循环立即求值,内层判断与嵌套循环在运行期才动态绑定全局变量,从而导致重赋值后的数据出现非预期交集。

AI-assisted summary of 【python】几乎没人做对的题目?聊聊生成器表达式里不为人知的秘密 — original source. Check the original for full context.

Source published:

Key ideas

  • Python 生成器表达式在求值时机上存在不对称性:仅有最外层迭代对象会在定义阶段立即求值并固化,其余所有内层表达式与条件过滤均延迟至运行期解析。 (1:50)
  • 根据 PEP 289 规范,如果在生成器表达式定义后、实际消费前修改全局变量,生成器内部引用的变量将动态读取最新值,从而产生反直觉的交集过滤或笛卡尔积行为。 (1:50)
  • 底层字节码显示,生成器表达式被编译为独立的代码对象,外层迭代器作为初始参数传入函数,而内层条件则通过 LOAD_GLOBAL 指令在迭代运行时动态查找。 (1:50)

Detailed notes

## Background 该视频由技术博主“码农高天”于 2023 年 10 月 16 日发布在 YouTube 平台。视频简介提到,本期内容深入探讨了生成器表达式中极易被忽视、甚至作者本人第一次都想错的小细节,以此检验观众能否在不知有坑的情况下答对这道极具迷惑性的代码题。

## TL;DR 视频以一道看似荒谬的 Python 生成器代码切入,展示了当外部列表变量在生成器定义与调用之间被篡改时,输出结果既非初始列表也非修改后列表的反常识现象。高天通过拆解字节码和还原生成器内部函数,结合 PEP 289 规范阐明了根本原因:生成器表达式中仅有最外层的 for 循环会在定义期立即求值并绑定迭代器,而内部的条件判断和内层循环均遵循惰性机制,延迟到运行期才进行全局变量查找。最后,视频通过嵌套循环实战测验巩固了这一求值规则,提醒开发者警惕语法糖背后的底层语义差异。

## Atmosphere & style 视频基调轻松幽默且兼具极客探究特质,博主高天如同面对面解密悬疑谜题般抛出反直觉代码,并穿插着让观众把“懵了”打在弹幕上的调侃互动。随着底层字节码与 PEP 规范的层层推进,整体情绪从最初令人挠头的困惑迅速转化为豁然开朗的透彻感,让枯燥的语言规范讲解充满了趣味性和顿悟感。

## Bulletin - [01:50](https://www.youtube.com/watch?v=s71JhBQ3T9c&t=110s) Python 生成器表达式在求值时机上存在不对称性:仅有最外层迭代对象会在定义阶段立即求值并固化,其余所有内层表达式与条件过滤均延迟至运行期解析。 - [01:50](https://www.youtube.com/watch?v=s71JhBQ3T9c&t=110s) 根据 PEP 289 规范,如果在生成器表达式定义后、实际消费前修改全局变量,生成器内部引用的变量将动态读取最新值,从而产生反直觉的交集过滤或笛卡尔积行为。 - [01:50](https://www.youtube.com/watch?v=s71JhBQ3T9c&t=110s) 底层字节码显示,生成器表达式被编译为独立的代码对象,外层迭代器作为初始参数传入函数,而内层条件则通过 LOAD_GLOBAL 指令在迭代运行时动态查找。

## Key takeaways - [00:00](https://www.youtube.com/watch?v=s71JhBQ3T9c&t=0s) 生成器表达式遵循惰性求值原则,不会在定义时分配完整内存,而是按需产出元素。 - [00:00](https://www.youtube.com/watch?v=s71JhBQ3T9c&t=0s) 在生成器定义与实际消费之间修改全局变量时,看似恒真的条件过滤 `if n in list` 会导致反常识的输出结果。 - [00:00](https://www.youtube.com/watch?v=s71JhBQ3T9c&t=0s) 对于代码 `g = (n for n in list if n in list)`,在将 `list` 从 `[1..5]` 改为 `[0..2]` 后,实际输出为两个列表的交集 `[1, 2]`。 - [01:50](https://www.youtube.com/watch?v=s71JhBQ3T9c&t=110s) 生成器表达式在底层被编译为一个独立的代码对象,本质上等价于定义并调用一个匿名生成器函数。 - [01:50](https://www.youtube.com/watch?v=s71JhBQ3T9c&t=110s) 外部环境通过 `GET_ITER` 获取最外层迭代器,并在生成器定义时通过函数调用将其作为实参立即固化。 - [01:50](https://www.youtube.com/watch?v=s71JhBQ3T9c&t=110s) 生成器函数体内的条件判断通过 `LOAD_GLOBAL` 和 `CONTAINS` 指令运行,必须等到生成器被消费时才动态读取全局变量。 - [01:50](https://www.youtube.com/watch?v=s71JhBQ3T9c&t=110s) PEP 289 规范明确规定:生成器表达式中只有最外层的 for 表达式是立刻求值的,其余表达式均在生成器运行期间求值。 - [01:50](https://www.youtube.com/watch?v=s71JhBQ3T9c&t=110s) 等价的纯 Python 实现相当于 `def __gen(iter_arg): ...`,其中参数绑定了旧迭代器,而内部循环体则动态引用全局命名空间。 - [04:20](https://www.youtube.com/watch?v=s71JhBQ3T9c&t=260s) 在嵌套推导式 `((a, b) for a in list for b in list)` 中,最外层变量 `a` 绑定定义时的旧列表,而内层循环变量 `b` 则绑定运行期的新列表。 - [04:20](https://www.youtube.com/watch?v=s71JhBQ3T9c&t=260s) 嵌套推导式在重新赋值后的实际运行结果表现为旧列表与新列表的笛卡尔积,而非纯粹基于新列表或旧列表展开。

## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=s71JhBQ3T9c&t=0s) 生成器表达式的诡异陷阱:看似无用的条件判断

码农高天以一段关于生成器推导式(generator comprehension,通常也称为 generator expression)的趣味代码切入主题。他首先展示了三行非常简洁的基础代码:第一行定义了一个列表 `list = [1, 2, 3, 4, 5]`;第二行通过括号语法构建了一个生成器对象 `g = (n for n in list)`;第三行则通过 `list(g)` 将生成器中的元素重新聚合成列表并打印输出。与会直接开辟全新内存空间的列表推导式(list comprehension)不同,生成器表达式仅生成一个生成器对象,不会立即分配完整内存,而是遵循惰性求值原则按需产出元素。这道基础题的结果毫无悬念就是 `[1, 2, 3, 4, 5]`。

随后,高天在推导式末尾追加了一个过滤条件:`g = (n for n in list if n in list)`。表面上看,由于变量 `n` 本就是从 `list` 中迭代取出的,`if n in list` 这个判断恒为真,因此代码输出依然是 `[1, 2, 3, 4, 5]`。初看之下,这个过滤条件似乎完全是多此一举的废话。然而,高天紧接着引入了一个打破直觉的变化——在生成器定义之后、实际调用 `list(g)` 消费生成器之前,修改全局变量 `list` 的指向:

```python list = [1, 2, 3, 4, 5] g = (n for n in list if n in list) list = [0, 1, 2] print(list(g)) ```

面对修改后的代码,很多人会猜测最终输出要么是最初的 `[1, 2, 3, 4, 5]`,要么是更新后的 `[0, 1, 2]`。然而,Python 实际给出的运行结果却是出人意料的 `[1, 2]`。面对这个反常识的现象,码农高天调侃道:“如果你懵了请把懵了打在弹幕上我相信如果你之前没有认真的钻研过这件事的话你是很难想到这个结果的为什么是一二呢它完全不make sense 对不对”。

---

[01:50](https://www.youtube.com/watch?v=s71JhBQ3T9c&t=110s) 深入字节码与 PEP 289:最外层与内层的求值时机差异

为了探究为何输出会是 `[1, 2]`,高天带领观众查看了底层字节码(bytecode)。生成器表达式在底层实际上被编译成了一个独立的代码对象(code object),其本质相当于定义并调用了一个匿名的生成器函数:

```mermaid flowchart TD accTitle: 生成器表达式求值时机分流图 accDescr: 展示最外层迭代对象在定义期求值,而内部条件判断在运行期延迟求值的过程

subgraph DefinitionPhase ["生成器定义阶段(立即求值)"] A["读取外层 iterable: list = [1, 2, 3, 4, 5]"] --> B["获取迭代器: iter(list)"] B --> C["传入生成器函数作为初始参数"] end

subgraph RuntimePhase ["生成器运行阶段(延迟求值)"] C -.->|"list(g) 触发运行"| D["FOR_ITER 遍历传入的迭代器 [1, 2, 3, 4, 5]"] D --> E["LOAD_GLOBAL 读取当前全局变量 list: [0, 1, 2]"] E --> F{"CONTAINS: n in [0, 1, 2]"} F -->|"True (1, 2)"| G["Yield 产出元素"] F -->|"False (3, 4, 5)"| H["跳过"] end ```

在外部调用环境中,Python 会先执行 `LOAD_NAME` 把最初的 `list` 加载进操作数栈,调用 `GET_ITER` 取得其迭代器,随后通过 `CALL_FUNCTION` 调用生成器代码对象,并将该迭代器作为参数直接传给生成器函数。而在生成的函数体字节码内部,第二行是负责循环解包的 `FOR_ITER`,但到了第五行处理 `if n in list` 时,指令执行的却是 `LOAD_GLOBAL`,接着通过 `CONTAINS` 操作符进行包含关系比对,最后根据判断结果执行分支跳转。

这意味着在 `g = (n for n in list if n in list)` 中,看似相同的两个 `list` 实际上根本不是同一个对象: 1. **前面的 `list`**:在定义生成器表达式的瞬间就已经完成了求值并取出了迭代器,锁定的是初始的 `[1, 2, 3, 4, 5]`。 2. **后面的 `list`**:直到生成器真正被消费、进入内部函数循环执行时才动态查找全局命名空间,此时查找到的是已经被重新赋值的 `[0, 1, 2]`。

该机制下,生成器实质上是在原列表 `[1, 2, 3, 4, 5]` 中逐一取值,并逐个检查该值是否属于新列表 `[0, 1, 2]`,两者取交集正是 `[1, 2]`。

高天指出,这个设计的历史源头可以追溯到 Python 2.4 引入的 **PEP 289(Generator Expressions)**。PEP 289 明确规范了生成器表达式的等价转换逻辑与语法糖规则,并严格规定:“只有最外层的for表达式是立刻求值的而剩下的表达式都会在生成器运行的时候再求值”。

如果将该生成器表达式还原为纯 Python 的等价实现,相当于定义了如下内部生成器函数:

```python def __gen(iter_arg): for n in iter_arg: # 此处的 list 为全局查找,运行期才进行解析 if n in list: yield n

g = __gen(iter(list)) ```

外层的 `iter(list)` 在绑定时便固化了第一版列表,而内部的 `if n in list` 则在迭代调用时实时查找全局变量 `list`,从而完美解释了这一看似奇异的运行机制。

---

[04:20](https://www.youtube.com/watch?v=s71JhBQ3T9c&t=260s) 嵌套循环实战测验与求值规则总结

掌握了这一底层规则后,高天给观众留下了一道进阶思考题,考察嵌套推导式场景下的求值顺序:

```python list = [1, 2, 3] g = ((a, b) for a in list for b in list) list = [1, 2] print(list(g)) ```

在提示观众暂停思考后,高天揭晓了答案:运行结果为 `[(1, 1), (1, 2), (2, 1), (2, 2), (3, 1), (3, 2)]`。

产生这一结果的根源依然完全遵循 PEP 289 的核心定律:在嵌套推导式中,最外层的循环 `for a in list` 是立即求值的,因此在生成器构建之初,`a` 迭代的数据源就已经固定为当时的 `[1, 2, 3]`;而内层嵌套的循环 `for b in list` 属于非最外层表达式,它被延迟到生成器实际运行时才去解析变量 `list`。此时全局变量 `list` 已经被重新赋值为了 `[1, 2]`,所以 `b` 只能在 `1` 和 `2` 之间遍历。内外两层组合展开后,正好构成了 `a` 取自 1、2、3,`b` 取自 1、2 的笛卡尔积。高天最后总结这一知识点并提醒观众,看似简单的语法糖背后常常隐藏着语言规范层面的严谨细节,并在片尾呼吁大家关注并一键三连。

## Notable quotes > [01:50](https://www.youtube.com/watch?v=s71JhBQ3T9c&t=110s) 只有最外层的for表达式是立刻求值的而剩下的表达式都会在生成器运行的时候再求值 — PEP 289

## Mentioned 码农高天, Python, PEP 289, Python 2.4, YouTube

## Video description 这期视频聊聊生成器表达式里面不常见但是没啥人会的小细节,这代码我第一次都想错了。在不知道有坑的情况下,你能一次做对嘛?

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

中文简报

PEP 289 求值时机分流解释 Python 生成器运算偏差 生成器表达式仅对最外层 for 循环立即求值,内层判断与嵌套循环在运行期才动态绑定全局变量,从而导致重赋值后的数据出现非预期交集。 视频以一道看似荒谬的 Python 生成器代码切入,展示了当外部列表变量在生成器定义与调用之间被篡改时,输出结果既非初始列表也非修改后列表的反常识现象。高天通过拆解字节码和还原生成器内部函数,结合 PEP 289 规范阐明了根本原因:生成器表达式中仅有最外层的 for 循环会在定义期立即求值并绑定迭代器,而内部的条件判断和内层循环均遵循惰性机制,延迟到运行期才进行全局变量查找。最后,视频通过嵌套循环实战测验巩固了这一求值规则,提醒开发者警惕语法糖背后的底层语义差异。 Python 生成器表达式在求值时机上存在不对称性:仅有最外层迭代对象会在定义阶段立即求值并固化,其余所有内层表达式与条件过滤均延迟至运行期解析。 (1:50) 根据 PEP 289 规范,如果在生成器表达式定义后、实际消费前修改全局变量,生成器内部引用的变量将动态读取最新值,从而产生反直觉的交集过滤或笛卡尔积行为。 (1:50) 底层字节码显示,生成器表达式被编译为独立的代码对象,外层迭代器作为初始参数传入函数,而内层条件则通过 LOAD_GLOBAL 指令在迭代运行时动态查找。 (1:50)

Reading summary

码农高天指出,Python 生成器表达式存在一种反直觉的求值行为:同一表达式内看似相同的变量,可能分别绑定在定义期与运行期。该机制由 PEP 289 规范所决定,即只有最外层的 for 循环表达式会立即求值,其余表达式均延迟至运行期解析。 修改变量引发的反直觉交集结果 (0:00) 在常规认知中,生成器表达式 `g = (n for n in list if n in list)` 包含的过滤条件似乎恒为真。但如果在生成器定义完成后、调用 `list(g)` 消费之前,将外部变量修改为 `list = [0, 1, 2]`(初始定义为 `[1, 2, 3, 4, 5]`),最终输出既非全集 `[1, 2, 3, 4, 5]` 也非新列表 `[0, 1, 2]`,而是交集 `[1, 2]`。这一现象表明,生成器表达式的不同部分并非在同一时间点完成取值。 PEP 289 规范与字节码层面的求值分流 (1:50) 底层字节码显示,生成器表达式会被编译为一个独立的匿名生成器函数代码对象。在表达式定义时,外部环境先执行 `LOAD_NAME` 与 `GET_ITER`,将最初的 `list` 转为迭代器并作为参数传入生成器函数,完成立即求值;而在函数体内部执行 `if n in list` 时,指令则是运行期动态执行 `LOAD_GLOBAL` 读取当前的全局变量。PEP 289(Python 2.4 引入)严格规定了这种机制:只有最外层的 for 表达式会立刻求值,而内部的条件判断与后续表达式都会推迟到生成器实际运行时再动态查找,从而导致循环对象与过滤对象分属不同版本。 嵌套推导式中的内外层延迟验证 (4:20) 同样的求值时机差异也存在于嵌套生成器中。对于 `g = ((a, b) for a in list for b in list)`,若定义后将 `list` 从 `[1, 2, 3]` 重新赋值为 `[1, 2]`,最终结果为 `[(1, 1), (1, 2), (2, 1), (2, 2), (3, 1), (3, 2)]`。这是因为最外层循环 `for a in list` 在定义时已锁定了最初的 3 个元素,而内层嵌套循环 `for b in list` 属于非最外层表达式,直到执行时才读取已被缩减为 2 个元素的新全局列表,两者组合形成了不对称的笛卡尔积。 生成器表达式在语法糖封装下隐藏了严谨的求值规范。开发者在推导式中引用可变全局变量或进行外部重命名时,若忽视最外层与内层求值时机的脱节,极易引入难以排查的逻辑缺陷。

Source quotes

[1:50] PEP 289: 只有最外层的for表达式是立刻求值的而剩下的表达式都会在生成器运行的时候再求值

Original transcript

[0:00] 这期视频讲一段我前一阵看到的非常有趣的代码它是跟generator comprehension有关的也叫generator expression大家看我们左边有三行代码都比较简单第二行是generator comprehension或者叫generator expression你可能之前接触过或者没接触过它的意思就是给建立一个generator然后这个generator从list里面逐一的yield出来它里面的element你可能见过跟它长得比较像的list comprehension对不对他们两个的区别是一个直接建立了一个list他会使用一块新的内存而一个只产生了一个generator他并不会立刻分配内存而是一个一个的往外拿那我们之前的视频讲过生成器对吧这里就不赘述了好那我们第三行的listg就是把这个generator里面的东西一个一个拿出来拼成一个list请问现在代码会输出什么这个问题应该非常简单答案是这样的这条代码没有任何神秘的地方就是n4ninlist把list里面每一个东西拿出来做个generator然后再把它变回list这就是12345这是一个入门傻瓜级题目好 我们把第二行变一下那第二行我们加了一个条件ifninlist请问现在的输出是什么这问题也很简单啊输出跟之前的程序是一样的因为你在list里面取值嘛然后if n in list那n当然在list里了对不对那如果你到这里都能看懂的话你肯定会说你是不是傻对吧这后面这个东西有任何意义吗如果你这么想的话我们看下面这个代码我们在建立这个generator之后修改了list那现在打印出来是什么呢是12345呢还是012呢我们看一下python给出来的结果如果你懵了请把懵了打在弹幕上我相信如果你之前没有认真的钻研过这件事的话你是很难想到这个结果的为什么是一二呢它完全不make sense 对不对老规矩先看backcode好 我们注意到这个generator expression实际上产生了一个code object也就是本质上来说它相当于定义了一个新的函数这个函数我们仔细看一下在第二行是for iter 这里做了一个正儿八经的for loop但是在第五行他做的是load global接下来他做了一个contains 就是做了一个比较然后12行做了一个branch 什么意思呢这个函数的iterable是在定义expression的时候被传进来的而后面这个条件的比较是用的全局名list我们可以看上面真正调用的部分它是先load name把这个list先给读进了stack然后做了一个get iter也就是拿到了它的iterator接着做了一个call function也就是它调用了这个生成器函数生成了一个生成器对象把这个生成器对象存到了这里从python的角度看就是第二行的这两个list不是同一个list前面这个list是在做这个生成器的时候就确定了也就是第一行定义的list而后面这个list是在生成器开始运行的时候才确定的也就是说这一段代码等价于这段代码它迭代的时候用的是原来list1而比较的时候用的是后面list2这就解释了为什么我们的结果是1 2因为12345里属于012的就是1和2好 这个令人头晕目眩的规矩是谁定的呢让我们找回源头在python2.4引入的pyp289里较明确的规定了这个语法棠的等价形式同时我们注意这一行只有最外层的for表达式是立刻求值的而剩下的表达式都会在生成器运行的时候再求值那我们回过头来再看这段代码是不是稍微清晰一些了如果还不够清晰呢我把它再写成它的等价形式大家看刚才那段程序是等价于这段程序的它相当于定义了一个新的函数然后传进去这个list iterator也就是在建立这个生成器的时候是直接的使用到了这个list并且把这个iterator作为参数传到了这个生成器函数里而这个生成器函数里面这个if n in list里面这个list在运行的时候会尝试找list这个名字而list是一个全局变量它就会找这个全局变量也就会找到之前的012好讲了这么多我们来做一道练习题list刚开始是123我新建了一个generatorgenerator的每一个元素是一个tupleab后面写的是for a in listfor b in list在第三行我把list变成了er请问g生成的list是什么东西如果你真的想到这道题在这里 [5:00] 一下暂停一波马上就说大了运行结果是这样的1112 2122 3132为什么 因为最外层的for loop 也就是for a in list 是当时就求职的所以A是在1 2 3里取而后面的里面的for being list是在生成器运行的时候求值的所以B是从一二里取你做对了吗好 那这期视频就到这里如果喜欢的话记得点一下关注别忘了一键三连哦优优独播剧场——YoYo Television Series Exclusive