← 返回题目列表

怎么用 dis 模块看 Python 字节码?能看出什么门道?

困难 第 25 / 27 题 更新于 2026/07/31
dis字节码CPython虚拟机

简化版

dis 是标准库自带的「字节码反汇编器」——把函数、类、代码字符串翻译成 CPython 虚拟机实际执行的指令序列,让你看到「这行 Python 代码到底做了几件事」。用法极简:import dis; dis.dis(func)。它的价值不在于让你去写字节码,而在于回答那些从源码层面看不出来的问题:为什么局部变量比全局变量快(LOAD_FAST 直接按数组下标取,LOAD_GLOBAL 要查两次字典)、为什么 a = 1 + 2 运行时没有加法(常量折叠在编译期就算完了)、为什么 list += xlist = 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),会在运行时把通用指令替换成针对具体类型的快速版本(disadaptive=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 之类的目标就是偏移,带 >> 标记的行表示「这里是某条跳转的落点」。看懂跳转之后,iffor 的实现就一目了然了: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——这正是「把 lenlst.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 是"体检仪"——用来找问题、指导优化。
  拿显微镜找病灶,方向就错了。

最后收敛成选型。值得看字节码的场景是:学习和验证语言机制(作用域、闭包、装饰器、withfor 的实现)、排查诡异语义问题(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 * 601024 ** 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_FAST vs LOAD_GLOBAL 这两条不同的指令上。函数的局部变量在编译期就被完全确定了(数量和名字存在 co_varnames 里),每个变量分到一个固定的数组槽位,运行时 LOAD_FAST 0 的语义就是「取局部变量数组的第 0 个元素」——O(1)、无哈希、无字典查找。而 LOAD_GLOBAL 必须在模块的 globals() 字典里做哈希查找,找不到还要再去 builtins 字典里找一次(lenprint 这些内置函数走的就是这条路,所以是两次字典查找)。这解释了「把 lenlst.append 提到循环外存成局部变量」这个经典优化。不过要补充两点:3.11 起 LOAD_GLOBAL 加了内联缓存,差距比过去小了很多;而且这种优化牺牲可读性,只在被 profile 证实是热点的超高频循环里才值得做。
  • 追问:dis 输出里的 >> 和偏移量是什么意思?怎么看懂控制流? 第二列的数字是指令在字节码中的偏移量(字节数),它的作用是充当跳转目标POP_JUMP_IF_FALSEJUMP_FORWARDJUMP_BACKWARDFOR_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」的字节码证据。whiletrywith 都可以用同样的方法读出来(不过 3.11 起异常处理改用了异常处理表,不再有 SETUP_FINALLY 这类指令)。
  • 追问:既然字节码不稳定,写代码分析工具应该用什么? 优先用 ast 模块(抽象语法树)。AST 更贴近源码结构、版本间稳定得多(新增语法才会加节点类型,已有节点很少改),而且语义清晰——ast.parse(src) 得到语法树,配合 ast.NodeVisitor 遍历就能做静态检查(flake8ruffbanditmypy 都工作在 AST 或更高的层次上)。只有当你的需求必须触及运行时行为时才下到字节码层:覆盖率统计(哪些指令真的执行了)、字节码改写(pytest 的断言重写会在导入时改写字节码以打印详细的断言失败信息)、性能剖析器和调试器(3.12 起推荐用新的 sys.monitoring API 而不是自己解析字节码)。总之:能用 AST 就别用字节码,能用标准 API(sys.settrace/sys.monitoring)就别自己解析指令

八、加强记忆

dis 是标准库的字节码反汇编器(dis.dis(f)),它的定位是「显微镜」——用来看清机制、验证对语义的理解,而不是找性能瓶颈(那是 cProfile/py-spytimeit 的职责,指令数不等于耗时:一条 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_OPCALL_* 统一成 CALL、引入内联缓存与运行时专用指令(dis.dis(f, adaptive=True) 才看得到)、用异常处理表实现零成本异常;3.12 的 PEP 709 又把推导式内联进外层函数(语义不变)。稳定的是语言语义dis 的 API,不稳定的是具体指令名、数量与顺序;做代码分析优先用更稳定的 ast,需要运行时信息就用 sys.monitoring 这类标准接口。