字节码一窥:用 dis 把推导式拆开看
承接推导式篇留下的两个「为什么」——为什么推导式比 for+append 略快、为什么循环变量不外泄——用标准库 dis 反汇编亲手验证:3.12 前推导式是隐藏函数、PEP 709 内联优化、生成器表达式为何始终独立,附「字节码是实现细节不可依赖」的边界说明。
前置知识
- 已读完 列表推导式进阶:会写四种推导式,听过「3.12 起推导式被内联,更快」这句话;
- 不需要任何编译原理背景。
你现在要解决什么问题
推导式篇里你记了两句结论,都是「信我」式的:推导式比等价循环略快;推导式里的循环变量不会泄漏到外面。背后原因各用一句「作用域」「内联」带过——这种结论记多了心里不踏实:万一版本变了呢?
这篇用一个你还没见过的标准库工具 dis(disassembler,反汇编器)把 Python 源码拆到解释器真正执行的指令层,亲手看到这两件事的机制。看完你会顺带获得一个可迁移的能力:以后遇到「两个写法哪个快」「为什么这里报错」的问题,多了一个拆开看的手段。
先认识 dis:看解释器的「汇编」
CPython 不是直接跑你的源码:它先把源码编译成一套紧凑的字节码指令,再由解释器循环逐条执行。dis 模块把这个中间层翻给你看,零安装:
import dis
def add(a, b):
return a + b
dis.dis(add) 1 RESUME 0
2 LOAD_FAST 0 (a)
LOAD_FAST 1 (b)
BINARY_OP 0 (+)
RETURN_VALUE读法自上而下:把局部变量 a 压上栈,把 b 压上栈,做加法,返回栈顶。「栈 + 指令」就是 CPython 执行任何 Python 代码的方式。不同版本的指令名与排布会有出入——这本身就是本文最后的重点。
实验一:推导式里藏着函数吗
3.11 及以前,推导式在编译时被包成一个看不见的隐藏函数:执行时要创建函数对象、调用、返回。用 dis 验证你手上的解释器属于哪一代:
import dis, sys
print(sys.version)
dis.dis("[x * 2 for x in range(3)]")在 3.12+ 上,你会看到推导式的指令被直接排在外层代码里:一串 FOR_ITER 循环体,开头没有 MAKE_FUNCTION、结尾没有 CALL 这类函数调用指令。而拿 3.11 跑同一段,输出里会赫然出现 MAKE_FUNCTION 与 CALL——推导式被当函数调用了一次。
这就是那句「3.12 起内联」的全部内容(PEP 709):省掉一次函数调用的固定开销。收益不大但真实,一份实测参考(10 万次循环、不同机器会有出入):
[x for x in range(100)] 3.11 约 3.2 微秒 3.12 约 2.1 微秒 约 1.5 倍
[x**2 for x in range(100) if x % 2] 3.11 约 4.8 3.12 约 3.4 约 1.4 倍
{x: x**2 for x in range(100)} 3.11 约 4.5 微秒 3.12 约 3.1 微秒 约 1.45 倍自己复测用 python -m timeit,别背这份表。
实验二:循环变量不泄漏,原来是「帧」在把关
推导式篇说过:y = [x * 2 for x in range(3)] 执行完,x 并不存在。现在机制可以讲透了——3.11 以前,推导式是独立函数,函数有自己的局部命名空间(帧),循环变量自然住在帧里,函数一返回帧就销毁。
3.12 内联之后,「独立帧」在实现上没有了,但语言行为保持不变:编译器在编译期就把推导式里的循环变量单独管理,禁止它泄漏、也禁止外面读写它。这是一个很有代表性的设计决策——实现变了,语义不变。你可以顺手验证一个内联时代的边角行为:
x = "外部 x"
y = [x for x in range(3)]
print(x) # 还是 "外部 x"推导式里的 x 完全不影响外层同名变量,两个时代的 Python 都如此。
实验三:生成器表达式为什么始终是独立的
把实验一的对象换成生成器表达式:
dis.dis("(x * 2 for x in range(3))")无论哪个版本,你都会看到它创建了一个独立的代码对象,名字就叫 <genexpr>。为什么不内联它?因为生成器表达式的生命周期可能超出所在表达式:它可能被传出去、被慢慢消费——必须是一个能独立存活的函数对象。回忆推导式篇那个 200 字节的 sys.getsizeof:生成器对象携带的正是这个独立函数的帧状态(执行到哪了、局部变量有哪些)。
至此推导式篇的三个「为什么」全部对上了:列表版内联省调用开销;生成器版独立帧换来 O(1) 内存;作用域隔离源自「逻辑上仍是一个作用域单元」。
动手实验:亲眼看一次版本差异
如果你机器上有多个 Python(030 篇的 pyenv/uv 正好用上),跑同一行观察字节码随版本演变:
uv run --python 3.11 python -m dis -c "[x for x in range(3)]"
uv run --python 3.13 python -m dis -c "[x for x in range(3)]"python -m dis -c "代码" 不写文件就能反汇编一行代码。两份输出几乎必然长得不一样——这就是想让你看到的。
坑点与边界
坑一:字节码是 CPython 的实现细节,不是语言承诺。指令名(BINARY_OP、RESUME)与排布在版本间任意变动,3.11 到 3.12 的推导式变化就是活例子。所以:用 dis 理解机制,永远不要针对具体字节码做优化或写依赖它的代码。
坑二:判断快慢以 timeit 实测为准,dis 只是解释「为什么」。两个写法的字节码条数差不等于运行时间差,指令成本不同。
坑三:PyPy 等「其他 Python」的字节码体系完全不同。你拆的是 CPython,别把结论外推。
什么时候应该 / 不应该
应该:对「同名两种写法性能不同」「作用域诡异行为」先用 dis 拆开建立直觉;版本升级后好奇「某语法变快了还是慢了」时对比一次;读框架源码遇到无法解释的行为时下潜一层。
不应该:在性能报告里引用字节码条数;写依赖特定指令排布的代码;对初学者一上来就讲字节码(先建立 Python 层的模型)。
官方文档
- dis 模块:https://docs.python.org/zh-cn/3/library/dis.html
- PEP 709(推导式内联):https://peps.python.org/pep-0709/
自我检查
- 能说出 CPython 执行源码的两个阶段(编译成字节码、解释执行);
- 能用 dis 判断手上的解释器是否内联了推导式(找 MAKE_FUNCTION / CALL);
- 能解释「实现变了、语义不变」在 3.12 推导式上的具体含义;
- 能说出生成器表达式不被内联的原因;
- 记得「字节码不可依赖」这条红线。
练习
预测题:dis.dis("a = 1; a += 2") 的输出里有几条「压栈」类指令?先猜再跑。
修改题:把推导式篇 timeit 实验中的两个写法分别 dis 一遍,找出二者指令序列的差异点,并用它解释 timeit 的结果。
排错题:同事声称「我数了字节码,写法 A 比 B 少 5 条指令,所以 A 快」。写出你反驳时用的两条论据(指令成本不同;不同版本字节码排布会变)。
挑战题:分别 dis 一个普通函数、一个生成器函数(带 yield 的)、一个 async 函数,观察三者编译产物开头指令的差异,写三句话说明「调用得到的东西」分别是值、生成器对象、协程对象。
下一步
- 回主线:推导式的语法与用法沉淀在 列表推导式进阶,生成器的暂停恢复机制在 生成器深水区;
- 想再下潜一层(解释器如何执行字节码、帧对象与求值循环):官方源码导读 https://devguide.python.org/internals/exploring/