怎么用 dis 模块看 Python 字节码?能看出什么门道?
简化版
dis 是标准库自带的「字节码反汇编器」——把函数、类、代码字符串翻译成 CPython 虚拟机实际执行的指令序列,让你看到「这行 Python 代码到底做了几件事」。用法极简:import dis; dis.dis(func)。它的价值不在于让你去写字节码,而在于回答那些从源码层面看不出来的问题:为什么局部变量比全局变量快(LOAD_FAST 直接按数组下标取,LOAD_GLOBAL 要查两次字典)、为什么 a = 1 + 2 运行时没有加法(常量折叠在编译期就算完了)、为什么 list += x 和 list = list + x 语义不同(INPLACE_ADD vs BINARY_ADD)、为什么推导式里有个隐藏的函数作用域(编译出一个单独的 code object)。读法:每行是 行号 / 指令偏移 / 指令名 / 参数 / (可读参数),CPython 是栈式虚拟机——LOAD_* 把值压栈,BINARY_*/CALL 弹出参与运算再压回结果,STORE_* 弹出并保存。最重要的心态:字节码不是稳定 API,每个小版本都可能变(3.11 引入了「专用化自适应解释器」,3.12/3.13 又大改),所以它适合理解和学习,绝不适合作为「性能优化的依据」——真要优化请用 timeit 和 profiler 量。核心记忆:dis.dis(f) 看指令,栈式虚拟机压栈弹栈,用它解释语义和性能差异,但别依赖具体指令。
详细版
常用 API 与关键指令:
| API | 作用 |
|---|---|
dis.dis(obj) | 反汇编函数/类/方法/代码字符串(最常用) |
dis.dis(obj, depth=0) | 不递归到嵌套的 code object |
dis.code_info(f) | 打印常量表、变量名表、栈深度等元信息 |
dis.get_instructions(f) | 得到可迭代的 Instruction 对象(做分析用) |
f.__code__.co_consts / co_varnames / co_names | 直接看常量表和名字表 |
python -m dis file.py | 命令行反汇编整个模块 |
| 指令族 | 含义 |
|---|---|
LOAD_CONST | 把常量表里的值压栈(编译期就确定的值) |
LOAD_FAST / STORE_FAST | 局部变量(按数组下标访问,最快) |
LOAD_GLOBAL | 全局/内置(查 globals 字典,miss 再查 builtins) |
LOAD_DEREF | 闭包变量(cell 对象) |
LOAD_NAME | 模块层/类体(依次查 local→global→builtins,最慢) |
LOAD_ATTR / LOAD_METHOD | 属性 / 方法(LOAD_METHOD 是免建 bound method 的优化) |
BINARY_OP / COMPARE_OP | 二元运算 / 比较 |
CALL / CALL_FUNCTION | 调用(3.11 起统一为 CALL) |
RETURN_VALUE | 返回栈顶 |
import dis
# ① 最基础:看一个函数
def add(a, b):
return a + b
dis.dis(add)
# 2 0 LOAD_FAST 0 (a) ← 压入局部变量 a
# 2 LOAD_FAST 1 (b) ← 压入局部变量 b
# 4 BINARY_OP 0 (+) ← 弹出两个、相加、压回结果
# 8 RETURN_VALUE ← 弹出栈顶作为返回值
# 列含义:源码行号 / 指令偏移 / 指令名 / 参数 / (人类可读的参数)
# ② 常量折叠:编译期就算好了
def const():
return 60 * 60 * 24
dis.dis(const)
# LOAD_CONST 1 (86400) ← ★运行时没有乘法★,编译器直接算出 86400
# RETURN_VALUE
# ③ 局部 vs 全局:为什么局部变量快
G = 1
def use_global(): return G # LOAD_GLOBAL (查字典)
def use_local(): g = 1; return g # LOAD_FAST (按数组下标)
# ④ += 和 + 在字节码上就不同
def inplace(xs): xs += [1] # BINARY_OP 13 (+=) → 调用 __iadd__,★原地修改★
def rebind(xs): xs = xs + [1] # BINARY_OP 0 (+) → 调用 __add__,★新建对象★
# ⑤ 推导式有自己的作用域(编译出独立的 code object)
def comp(n): return [x * 2 for x in range(n)]
dis.dis(comp)
# 3.11 及以前会看到 MAKE_FUNCTION + 一个 <listcomp> 的 code object
# 3.12 起推导式被"内联"进外层函数(PEP 709),指令形态完全变了 ← ★版本差异的活例子★
# ⑥ 看常量表和名字表(有时比读指令更直观)
print(const.__code__.co_consts) # (None, 86400)
print(add.__code__.co_varnames) # ('a', 'b')
# ⑦ 用 get_instructions 做程序化分析(比如统计某类指令)
from collections import Counter
print(Counter(i.opname for i in dis.get_instructions(comp)))
# ⑧ 直接反汇编一段源码字符串
dis.dis("x = [i for i in range(3)]")
⚠️ 最重要的一条:字节码不是稳定 API,不同 Python 版本的指令集会大改。 3.11 引入了「专用化自适应解释器」(PEP 659),会在运行时把通用指令替换成针对具体类型的快速版本(
dis加adaptive=True才看得到LOAD_ATTR_INSTANCE_VALUE这类专用指令);3.11 还把BINARY_ADD/BINARY_SUBTRACT等合并成了带参数的BINARY_OP、把函数调用统一成CALL;3.12 又把推导式内联进外层函数(PEP 709),连「推导式有独立 code object」这个流传多年的知识点都变了。所以看字节码是为了理解语义和建立直觉,不是为了照着它做优化:不要写「因为这样少一条指令所以更快」的结论(指令数和实际耗时不成正比,一条CALL可能比十条LOAD_FAST贵几十倍),更不要在生产代码里依赖某条具体指令的存在。要判断快慢,请用timeit实测。
完整版教学
一、dis 能回答什么问题
它擅长回答"从源码看不出来"的问题:
① 这行代码到底做了几件事?
obj.method(x) → LOAD_FAST obj / LOAD_METHOD method / LOAD_FAST x / CALL
(四步:取对象、查方法、取参数、调用——所以属性查找是有成本的)
② 这个计算是运行时算的还是编译期算的?
60*60*24 → LOAD_CONST 86400(★编译期★)
60*60*n → 有 BINARY_OP(运行时)
③ 两种写法的语义差别在哪?
xs += [1](原地改)vs xs = xs + [1](新建对象)
④ 变量到底是局部、全局还是闭包?
LOAD_FAST / LOAD_GLOBAL / LOAD_DEREF 一目了然
⑤ 语法糖背后发生了什么?
with / for / async / 装饰器 / f-string 展开成了什么指令
它★不擅长★回答的问题:
✗ 哪段代码慢(→ 用 cProfile / py-spy)
✗ A 写法比 B 写法快多少(→ 用 timeit 实测;指令数 ≠ 耗时)
✗ 内存占用(→ 用 tracemalloc)
★ 为什么"指令数"不能代表速度:
一条 CALL 触发一次完整的函数调用(建栈帧、传参、返回),
可能相当于几十条 LOAD_FAST 的开销;
而 3.11+ 的专用化解释器还会在运行时把热点指令替换成更快的变体
→ 只能实测
学习价值(这才是主要用途):
把"Python 的语义规则"从背诵变成可验证:
想知道 LEGB 作用域怎么实现的?看 LOAD_FAST/GLOBAL/DEREF
想知道装饰器什么时候执行?看 MAKE_FUNCTION 之后的 CALL
想知道 with 保证了什么?看 BEFORE_WITH / 异常处理表
dis 的定位要摆正:它是理解语义的工具,不是性能分析的工具。它擅长回答「这行代码到底做了几件事」(obj.method(x) 展开成取对象、查方法、取参数、调用四步,所以属性查找是有成本的)、「这个值是编译期算好的还是运行时算的」、「两种写法的语义差在哪」、「语法糖背后发生了什么」。它不能回答「哪段代码慢」——因为指令数和耗时完全不成正比:一条 CALL 要建栈帧、传参、返回,开销可能相当于几十条 LOAD_FAST;而 3.11+ 的解释器还会在运行时把热点指令替换成专用的快速版本。所以 dis 的主要价值是把语义规则从背诵变成可验证:想搞懂 LEGB 作用域怎么实现的就看 LOAD_FAST/LOAD_GLOBAL/LOAD_DEREF 的分布,想搞懂装饰器何时执行就看 MAKE_FUNCTION 之后的 CALL。
二、怎么读:栈式虚拟机的执行模型
CPython 是"栈式虚拟机":所有运算都通过一个"求值栈"完成
LOAD_* 把值★压栈★
BINARY_* 从栈上★弹出★操作数、计算、把结果★压回★
STORE_* 弹出栈顶,存进变量
CALL 弹出函数和参数,调用,把返回值压栈
RETURN_VALUE 弹出栈顶作为返回值
完整走一遍 return (a + b) * 2:
指令 执行后的栈(右边是栈顶)
LOAD_FAST a [a]
LOAD_FAST b [a, b]
BINARY_OP + [a+b] ← 弹两个,压一个
LOAD_CONST 2 [a+b, 2]
BINARY_OP * [(a+b)*2]
RETURN_VALUE [] ← 弹出并返回
输出的五列怎么看:
2 0 LOAD_FAST 0 (a)
↑ ↑ ↑ ↑ ↑
源码行号 │ 指令名 │ 人类可读的参数(变量名/常量值)
指令偏移(字节) 原始参数(在名字表/常量表里的下标)
★ 偏移量的用途:跳转指令(POP_JUMP_IF_FALSE 等)的目标就是偏移量
带 >> 标记的行表示"这里是某条跳转的落点"
看条件分支(if a > b: return a; return b):
LOAD_FAST a
LOAD_FAST b
COMPARE_OP >
POP_JUMP_IF_FALSE → 14 ← 条件为假就跳到偏移 14
LOAD_FAST a
RETURN_VALUE
>> 14 LOAD_FAST b ← >> 表示跳转落点
RETURN_VALUE
看循环(for x in xs: ...):
LOAD_FAST xs
GET_ITER ← 相当于 iter(xs)
>> FOR_ITER → 结束偏移 ← 相当于 next(),耗尽就跳到结束
STORE_FAST x
...循环体...
JUMP_BACKWARD → FOR_ITER ← 跳回去继续
★ 这就是"for 循环 = iter() + 反复 next() + 捕获 StopIteration"的字节码证据
读字节码的前提是理解 CPython 是栈式虚拟机:没有寄存器,所有运算都在一个「求值栈」上完成——LOAD_* 压栈、BINARY_* 弹出操作数算完压回、STORE_* 弹出保存、RETURN_VALUE 弹出返回。把 return (a + b) * 2 的五条指令配上「执行后的栈内容」走一遍,这套模型就通了。输出的五列分别是源码行号、指令偏移(字节)、指令名、原始参数(名字表/常量表下标)、人类可读的参数;偏移量的用处是跳转——POP_JUMP_IF_FALSE 之类的目标就是偏移,带 >> 标记的行表示「这里是某条跳转的落点」。看懂跳转之后,if 和 for 的实现就一目了然了:for 循环编译成 GET_ITER + 反复 FOR_ITER + 末尾 JUMP_BACKWARD,这正是「for 循环 = iter() 加反复 next() 加捕获 StopIteration」这句话的字节码证据。
三、变量访问指令族:作用域的真相
四条 LOAD 指令,对应四种作用域,成本递增:
LOAD_FAST 局部变量 → ★按数组下标直接取★(编译期就分配好了槽位),最快
LOAD_DEREF 闭包变量 → 通过 cell 对象间接取,略慢
LOAD_GLOBAL 全局/内置 → 查 globals 字典;没有再查 builtins 字典(两次哈希)
LOAD_NAME 模块层/类体 → 依次查 local→global→builtins,最慢、最动态
验证代码:
G = 10
def outer():
c = 1 # 被内层引用 → 变成 cell
def inner(p): # p 是参数 → 局部
x = p # LOAD_FAST p
y = c # LOAD_DEREF c ← 闭包
z = G # LOAD_GLOBAL G ← 全局
w = len # LOAD_GLOBAL len ← ★内置也走 LOAD_GLOBAL★
return x, y, z, w
return inner
在模块最外层写 a = 1; print(a) → LOAD_NAME(因为模块层的命名空间是动态的)
★ 为什么局部变量快(面试常问的底层解释):
函数的局部变量在★编译期★就被数出来了(存在 co_varnames 里),
每个变量分配一个固定槽位 → 运行时 LOAD_FAST i 就是"取数组第 i 个元素",O(1) 且无哈希
而 LOAD_GLOBAL 要在 globals 字典里做哈希查找,miss 了还要再查 builtins
→ 这就是"把 self.method 或 len 提到循环外存成局部变量"这一经典优化的原理
for i in range(n): append(i) # append = lst.append 提前存成局部
比 lst.append(i) 少了每次的 LOAD_ATTR
★ 3.11 起 LOAD_GLOBAL 带了内联缓存(inline cache),差距比以前小了
→ 又一次说明"照着字节码猜性能"不靠谱,要实测
一个能被字节码解释的经典疑惑:
def f():
print(x) # LOAD_FAST x ← 编译器发现下面有赋值,判定 x 是局部变量
x = 1
f() → UnboundLocalError(不是 NameError!)
★ 因为"是不是局部变量"是★编译期★根据"函数里有没有对它赋值"决定的,
和运行时的执行顺序无关 —— dis 一看 LOAD_FAST 就明白了
变量访问的四条指令,是理解 Python 作用域最直接的证据:LOAD_FAST(局部,按数组下标取)→ LOAD_DEREF(闭包,通过 cell)→ LOAD_GLOBAL(全局/内置,查字典)→ LOAD_NAME(模块层/类体,逐层查找),成本依次递增。局部变量快的根本原因是:函数的局部变量在编译期就被数清楚并分配了固定槽位(存在 co_varnames 里),运行时 LOAD_FAST i 就是取数组第 i 个元素,O(1) 且无需哈希;而 LOAD_GLOBAL 要在 globals 字典里做哈希查找,未命中还要再查 builtins——这正是「把 len 或 lst.append 提到循环外存成局部变量」这个经典优化的原理(3.11 起 LOAD_GLOBAL 加了内联缓存,差距已经小了不少,又一次说明不能照着字节码猜性能)。这套机制还能解释一个经典疑惑:函数里 print(x) 写在 x = 1 之前会抛 UnboundLocalError 而不是 NameError——因为「x 是不是局部变量」是编译期根据「函数里有没有对它赋值」决定的,dis 一看是 LOAD_FAST 就全明白了。
四、用 dis 解释真实的语义差异
① 常量折叠(编译期计算)
return 60 * 60 * 24 → LOAD_CONST 86400 ★运行时零成本★
return "ab" * 3 → LOAD_CONST 'ababab'
return [1, 2, 3] → BUILD_LIST(★列表不折叠★,因为可变对象每次都要新建)
return (1, 2, 3) → LOAD_CONST (1,2,3) (元组不可变,可以折叠)
★ 由此可知:把常量表达式写成可读的形式(60*60*24)没有运行时代价
② += 和 + 的语义差别
def a(xs): xs += [1] BINARY_OP 13 (+=) → 调 list.__iadd__ → ★原地修改,调用方能看到★
def b(xs): xs = xs + [1] BINARY_OP 0 (+) → 调 list.__add__ → 新建列表,调用方看不到
→ 这就是"函数里 += 会改到实参、= x + y 不会"的字节码级解释
(对不可变的 str/tuple,+= 也只能新建,因为没有 __iadd__)
③ 字符串拼接
s = s + t 或 s += t 在循环里 → 每次都 BINARY_OP,且 str 不可变 → 每次新建 O(n²)
"".join(parts) → 一次 CALL,内部一次性算总长度分配 O(n)
★ 注意 CPython 对 "s += t" 有一个特殊优化(引用计数为 1 时原地扩展),
所以实测有时没那么慢——但这是实现细节,别依赖,写 join 才是对的
④ f-string vs % vs .format
f"{a}-{b}" → FORMAT_VALUE / BUILD_STRING(★字节码级支持,无函数调用★)
"%s-%s" % (a,b) → BINARY_OP %(走 str.__mod__)
"{}-{}".format(a,b) → LOAD_METHOD format + CALL(★多一次方法查找和调用★)
→ f-string 通常最快,字节码解释了原因
⑤ 属性访问 vs 局部变量(循环优化的依据)
for i in r: lst.append(i) 每次 LOAD_FAST lst + LOAD_METHOD append + CALL
ap = lst.append
for i in r: ap(i) 每次 LOAD_FAST ap + CALL ← 省掉方法查找
→ 只在超高频循环里才值得这么写(可读性代价不小)
⑥ 推导式的作用域(★版本差异的活例子★)
3.11 及以前:[x*2 for x in xs] → MAKE_FUNCTION + CALL,有独立的 <listcomp> code object
(这就是"推导式有自己的作用域、循环变量不泄漏"的实现方式)
3.12 起(PEP 709):推导式被★内联★进外层函数,不再创建函数对象 → 更快
但★语义不变★(循环变量依然不泄漏,编译器用隐藏的名字实现)
→ 完美说明:"字节码会变,但语言语义有保证"
⑦ 装饰器什么时候执行
@deco
def f(): ...
→ MAKE_FUNCTION f / LOAD_NAME deco / CALL / STORE_NAME f
→ 一眼看出:装饰器在★定义时★就执行了(不是调用时)
这一节是 dis 最有价值的用途——把「口口相传的规则」变成可验证的事实。常量折叠告诉你 60*60*24 这种可读写法零运行时代价(但可变的列表不会折叠,元组会)。+= 和 + 在字节码上就是两条不同指令(INPLACE/__iadd__ 原地修改 vs __add__ 新建对象),这正是「函数里 xs += [1] 会改到调用方的列表、xs = xs + [1] 不会」的底层解释。f-string 有字节码级支持(FORMAT_VALUE/BUILD_STRING,无函数调用),而 .format() 要 LOAD_METHOD + CALL,快慢有了解释。装饰器展开成 MAKE_FUNCTION + CALL + STORE_NAME,一眼看出它在定义时就执行了。最有教育意义的是推导式:3.11 及以前它编译成一个独立的 code object(这就是「推导式有自己的作用域」的实现方式),3.12 起被内联进外层函数(PEP 709)、不再创建函数对象,但语义完全不变——完美诠释了「字节码会变,语言语义有保证」。
五、字节码不是稳定 API:版本差异有多大
近几个版本的重大变化(说明"别依赖具体指令"):
3.10 → 3.11(★变化最大★,PEP 659 专用化自适应解释器)
- BINARY_ADD / BINARY_SUBTRACT / … 合并成带参数的 BINARY_OP
- CALL_FUNCTION / CALL_METHOD / … 统一成 CALL + PRECALL
- 引入"内联缓存"(inline cache):指令后面跟着缓存槽位
- 引入"专用指令":运行时把 LOAD_ATTR 替换成 LOAD_ATTR_INSTANCE_VALUE 等
dis.dis(f, adaptive=True) 才能看到这些专用化后的指令
- 零成本异常处理:try 块不再有运行时开销(改用异常处理表)
- 异常处理表(exception table)取代 SETUP_FINALLY 等指令
3.11 → 3.12
- PEP 709:推导式内联(不再生成 <listcomp> code object)
- 更多专用指令、CALL 家族继续调整
3.12 → 3.13
- 继续专用化;解释器循环重构(为 JIT 铺路)
结论:
★ 同一段 Python 代码在 3.9 / 3.11 / 3.13 上 dis 出来可能完全不同
★ 任何"因为少一条指令所以更快"的推理都不可靠:
- 指令的成本差异巨大(CALL >> LOAD_FAST)
- 专用化会在运行时改变实际执行的指令
- 内联缓存让"看起来一样的指令"性能不同
哪些东西是★稳定的★(可以放心依赖):
✓ 语言语义(推导式作用域、+= 的原地语义、装饰器定义时执行)
✓ dis 模块本身的 API(dis.dis / get_instructions 一直可用)
✓ code object 的主要属性(co_consts / co_varnames / co_names)
✗ 具体的指令名、指令数量、指令顺序 ← ★都不稳定★
实际影响:
- 写字节码分析工具/覆盖率工具/调试器的人必须按版本适配(这也是它们升级慢的原因)
- 面试答题:说"3.11 起 BINARY_ADD 合并成了 BINARY_OP"能体现你真读过
这是关于 dis 最重要的一条认知:字节码不是稳定 API。近几年变化尤其剧烈——3.11 的 PEP 659「专用化自适应解释器」把 BINARY_ADD 等合并成带参数的 BINARY_OP、把各种 CALL_* 统一成 CALL、引入内联缓存和运行时专用指令(dis.dis(f, adaptive=True) 才看得到),还用异常处理表实现了零成本异常处理(try 块在不抛异常时不再有运行时开销);3.12 又把推导式内联了。所以同一段代码在 3.9 和 3.13 上反汇编出来可能完全不同,任何「少一条指令所以更快」的推理都站不住脚——指令成本差异巨大,而且专用化会在运行时改变实际执行的指令。要区分清楚什么稳定:语言语义稳定(推导式作用域、+= 的原地语义、装饰器定义时执行)、dis 的 API 和 code object 的主要属性稳定,而具体指令名、数量、顺序完全不稳定。
六、什么时候真的该看字节码
值得看的场景:
① 学习/教学:验证作用域、闭包、装饰器、with、for 的实现机制
② 排查诡异语义:UnboundLocalError、闭包延迟绑定、可变默认参数
(看一眼 LOAD_FAST vs LOAD_DEREF 就知道变量被编译成了什么)
③ 理解某个语言特性的代价:属性查找、方法调用、异常处理块
④ 写工具:覆盖率统计(coverage.py 用 sys.settrace/monitoring)、
字节码改写(如 pytest 的断言重写就是在 AST/字节码层做的)
⑤ 面试/讲解:用字节码作为"证据"比空口解释有力得多
不值得看的场景(用别的工具):
✗ 找性能瓶颈 → cProfile / py-spy(字节码看不出热点在哪)
✗ 比较两种写法快慢 → timeit 实测(指令数 ≠ 耗时)
✗ 优化生产代码 → 先 profile 定位,再改算法;99% 的情况轮不到字节码
✗ 内存问题 → tracemalloc
配套的"证据类"工具:
dis.dis(f) 指令序列
dis.code_info(f) 栈深度、常量表、变量表、flags
f.__code__.co_consts 常量表(看看有没有被折叠)
f.__code__.co_varnames 局部变量名(编译期确定)
f.__code__.co_freevars/cellvars 闭包相关(谁被 cell 化了)
ast.dump(ast.parse(src)) 更高层:看语法树(比字节码更稳定)
compile(src, "<s>", "exec") 自己编译一段源码再 dis
★ 心法:
dis 是"显微镜"——用来看清机制、验证理解;
profiler 是"体检仪"——用来找问题、指导优化。
拿显微镜找病灶,方向就错了。
最后收敛成选型。值得看字节码的场景是:学习和验证语言机制(作用域、闭包、装饰器、with、for 的实现)、排查诡异语义问题(UnboundLocalError、闭包延迟绑定——看一眼变量被编译成 LOAD_FAST 还是 LOAD_DEREF 就明白了)、理解某个特性的代价、以及写工具(覆盖率、字节码改写,pytest 的断言重写就属于这一类)。不值得看的场景是一切与性能优化相关的:找瓶颈用 cProfile/py-spy,比快慢用 timeit 实测,指令数不等于耗时。配套的「证据类」工具还有 dis.code_info()(栈深度、常量表、flags)、co_consts/co_varnames/co_freevars 这些 code object 属性,以及比字节码更稳定的 ast 模块(想做代码分析优先用 AST)。一句心法:dis 是显微镜,用来看清机制;profiler 是体检仪,用来找问题——拿显微镜找病灶,方向就错了。
记忆钩子:「
dis.dis(f)把 Python 代码反汇编成 CPython 虚拟机的指令序列,它的定位是『显微镜』——用来看清机制、验证理解,不是用来找性能瓶颈(那是 cProfile/py-spy 和 timeit 的活,★指令数 ≠ 耗时★,一条 CALL 可能顶几十条 LOAD_FAST)。★读法:CPython 是栈式虚拟机,LOAD_* 压栈、BINARY_/CALL 弹出算完压回、STORE_ 弹出保存、RETURN_VALUE 弹出返回;输出五列是『源码行号 / 指令偏移 / 指令名 / 参数下标 / 可读参数』,>>标记跳转落点。★最有价值的四条 LOAD 指令对应四种作用域、成本递增:LOAD_FAST(局部,按数组下标取,编译期分配槽位所以最快)< LOAD_DEREF(闭包,走 cell)< LOAD_GLOBAL(查 globals 再查 builtins 两次哈希)< LOAD_NAME(模块层/类体,逐层动态查找)——这既解释了『把 len 提到循环外存成局部变量』的经典优化,也解释了为什么函数里先 print(x) 后 x=1 抛的是 UnboundLocalError(是不是局部变量在编译期就定了)。★它能把口口相传的规则变成证据:606024 被常量折叠成 LOAD_CONST 86400(列表不折叠、元组折叠)、xs += [1]是 iadd 原地改而xs = xs + [1]是 add 新建(所以前者会改到调用方)、f-string 有字节码级支持而 .format() 多一次方法查找和调用、装饰器展开成 MAKE_FUNCTION+CALL+STORE 所以在定义时就执行。★最重要的心态:字节码不是稳定 API——3.11 的 PEP 659 把 BINARY_ADD 合并成 BINARY_OP、CALL_* 统一成 CALL、引入内联缓存和运行时专用指令(要 adaptive=True 才看得到)、零成本异常处理;3.12 又把推导式内联了(语义却不变)。稳定的是语言语义和 dis 的 API,不稳定的是具体指令名、数量、顺序。」
七、常见误区与追问
- 误区:字节码指令少的写法就更快。 指令数和耗时完全不成正比。不同指令的成本差着数量级:一条
CALL要创建栈帧、传参、执行函数体、返回,开销可能相当于几十条LOAD_FAST;一条BINARY_OP作用在 int 上和作用在自定义类(触发__add__的 Python 代码)上,耗时更是天壤之别。更关键的是,3.11 起的专用化自适应解释器会在运行时替换指令——dis里看到的LOAD_ATTR实际执行时可能已经变成了带内联缓存的LOAD_ATTR_INSTANCE_VALUE,静态看到的指令序列根本不代表实际执行的东西。想知道哪个写法快,唯一可靠的办法是timeit实测(并且用接近真实的数据规模)。 - 误区:可以依赖某条具体的字节码指令来写工具或做优化。 字节码不是稳定 API,每个小版本都可能变,而且近年变化极其剧烈:3.11(PEP 659)把
BINARY_ADD/BINARY_SUBTRACT等合并成带参数的BINARY_OP、把CALL_FUNCTION/CALL_METHOD统一成CALL、引入内联缓存和异常处理表(实现零成本异常处理);3.12(PEP 709)把推导式内联进外层函数、不再生成<listcomp>code object。所以同一段代码在 3.9 和 3.13 上dis出来可能完全不同——这也是字节码改写类工具(覆盖率、性能分析器、调试器)每次 Python 大版本发布后都要适配一轮的原因。稳定的是语言语义(推导式作用域、+=的原地语义)和dis模块本身的 API,做代码分析优先考虑更稳定的ast模块。 - 误区:
a = 60 * 60 * 24这种写法比直接写86400慢。 不慢,两者的字节码完全一样——编译器会做常量折叠,60 * 60 * 24在编译期就被算成了86400存进常量表,运行时只有一条LOAD_CONST,没有任何乘法。所以为了可读性把常量写成表达式(24 * 60 * 60、1024 ** 3)是零成本的,应该鼓励。要注意折叠的边界:只对不可变对象和字面量生效——(1, 2, 3)会被折叠成常量,而[1, 2, 3]不会(它是可变对象,每次执行都必须新建一个,所以是BUILD_LIST);表达式里只要含变量(60 * 60 * n)也无法折叠。 - 误区:函数里
print(x)写在x = 1前面,应该报NameError(找不到 x)。 报的是UnboundLocalError,字节码解释了原因:编译器在编译函数时会扫描整个函数体,只要发现有对x的赋值,就把x判定为局部变量并分配槽位,于是print(x)编译成的是LOAD_FAST x(取局部变量槽),而不是LOAD_GLOBAL x。运行时那个槽还没被赋值,于是抛UnboundLocalError(它是NameError的子类,但含义更精确:「这个名字是局部变量,但还没绑定值」)。关键点是**「是不是局部变量」在编译期就定死了,和运行时的执行顺序无关**——这也是为什么在函数里读写全局变量必须写global,读写外层函数变量必须写nonlocal。 - 误区:推导式有独立作用域是靠「生成一个隐藏函数」实现的,这是语言规定。 那只是 3.11 及以前的实现方式:编译器为推导式生成一个独立的 code object(
<listcomp>),用MAKE_FUNCTION+CALL调用它,作用域隔离是函数调用的自然结果。Python 3.12 起(PEP 709)推导式被内联进外层函数,不再创建函数对象和栈帧(性能提升约两倍),但语义完全不变——循环变量依然不泄漏到外层,编译器改用隐藏的内部名字来实现。这个例子最能说明「实现」和「语义」的区别:语言保证的是语义(循环变量不泄漏),怎么实现是 CPython 的自由。顺带一提,:=在推导式里的绑定则是有意泄漏到外层作用域的,这一点在两个版本里都一样。 - 追问:为什么局部变量访问比全局变量快?字节码上体现在哪? 体现在
LOAD_FASTvsLOAD_GLOBAL这两条不同的指令上。函数的局部变量在编译期就被完全确定了(数量和名字存在co_varnames里),每个变量分到一个固定的数组槽位,运行时LOAD_FAST 0的语义就是「取局部变量数组的第 0 个元素」——O(1)、无哈希、无字典查找。而LOAD_GLOBAL必须在模块的globals()字典里做哈希查找,找不到还要再去builtins字典里找一次(len、print这些内置函数走的就是这条路,所以是两次字典查找)。这解释了「把len或lst.append提到循环外存成局部变量」这个经典优化。不过要补充两点:3.11 起LOAD_GLOBAL加了内联缓存,差距比过去小了很多;而且这种优化牺牲可读性,只在被 profile 证实是热点的超高频循环里才值得做。 - 追问:
dis输出里的>>和偏移量是什么意思?怎么看懂控制流? 第二列的数字是指令在字节码中的偏移量(字节数),它的作用是充当跳转目标:POP_JUMP_IF_FALSE、JUMP_FORWARD、JUMP_BACKWARD、FOR_ITER这些跳转指令的参数就是目标偏移;而行首带>>标记表示「这条指令是某个跳转的落点」。看控制流就靠这两者配合:if a > b:编译成COMPARE_OP >后跟POP_JUMP_IF_FALSE → 目标,条件为假就跳到 else 分支的落点(>>处);for x in xs:编译成GET_ITER(相当于iter(xs))、>> FOR_ITER(相当于next(),迭代器耗尽就跳到循环结束处)、循环体、最后JUMP_BACKWARD跳回FOR_ITER——这正是「for 循环 =iter()+ 反复next()+ 捕获StopIteration」的字节码证据。while、try、with都可以用同样的方法读出来(不过 3.11 起异常处理改用了异常处理表,不再有SETUP_FINALLY这类指令)。 - 追问:既然字节码不稳定,写代码分析工具应该用什么? 优先用
ast模块(抽象语法树)。AST 更贴近源码结构、版本间稳定得多(新增语法才会加节点类型,已有节点很少改),而且语义清晰——ast.parse(src)得到语法树,配合ast.NodeVisitor遍历就能做静态检查(flake8、ruff、bandit、mypy都工作在 AST 或更高的层次上)。只有当你的需求必须触及运行时行为时才下到字节码层:覆盖率统计(哪些指令真的执行了)、字节码改写(pytest的断言重写会在导入时改写字节码以打印详细的断言失败信息)、性能剖析器和调试器(3.12 起推荐用新的sys.monitoringAPI 而不是自己解析字节码)。总之:能用 AST 就别用字节码,能用标准 API(sys.settrace/sys.monitoring)就别自己解析指令。
八、加强记忆
dis 是标准库的字节码反汇编器(dis.dis(f)),它的定位是「显微镜」——用来看清机制、验证对语义的理解,而不是找性能瓶颈(那是 cProfile/py-spy 和 timeit 的职责,指令数不等于耗时:一条 CALL 可能顶几十条 LOAD_FAST)。读法:CPython 是栈式虚拟机——LOAD_* 压栈、BINARY_*/CALL 弹出操作数算完压回、STORE_* 弹出保存、RETURN_VALUE 弹出返回;输出五列是「源码行号 / 指令偏移 / 指令名 / 参数下标 / 可读参数」,偏移量是跳转目标,行首 >> 表示跳转落点(看懂它就能读出 if/for 的控制流——for 编译成 GET_ITER + 反复 FOR_ITER + JUMP_BACKWARD)。最有价值的是四条 LOAD 指令对应四种作用域、成本递增:LOAD_FAST(局部,编译期分配数组槽位,O(1) 无哈希,最快)< LOAD_DEREF(闭包,走 cell)< LOAD_GLOBAL(查 globals 再查 builtins,两次哈希)< LOAD_NAME(模块层/类体,逐层动态查找)——这既是「把 len 提到循环外」这类优化的原理,也解释了为什么函数里先 print(x) 后 x = 1 抛的是 UnboundLocalError(「是不是局部变量」在编译期根据有无赋值就定死了)。它能把口口相传的规则变成证据:60*60*24 被常量折叠成 LOAD_CONST 86400(元组会折叠、列表不会)、xs += [1] 走 __iadd__ 原地修改而 xs = xs + [1] 走 __add__ 新建对象(这就是前者会改到调用方列表的原因)、f-string 有字节码级支持而 .format() 多一次 LOAD_METHOD + CALL、装饰器展开成 MAKE_FUNCTION + CALL + STORE 所以在定义时就执行。最重要的心态:字节码不是稳定 API——3.11 的 PEP 659 把 BINARY_ADD 合并进 BINARY_OP、CALL_* 统一成 CALL、引入内联缓存与运行时专用指令(dis.dis(f, adaptive=True) 才看得到)、用异常处理表实现零成本异常;3.12 的 PEP 709 又把推导式内联进外层函数(语义不变)。稳定的是语言语义和 dis 的 API,不稳定的是具体指令名、数量与顺序;做代码分析优先用更稳定的 ast,需要运行时信息就用 sys.monitoring 这类标准接口。