dis查看字节码
从 print(‘hello’) 开始:用 dis 验证你的 Python 直觉
你在终端敲下 python -m dis "print('hello')",看到几行 LOAD_GLOBAL、PUSH_NULL、CALL 之类的输出,大概会点头:“哦,原来 print 是这样执行的。”然后呢?
这串字节码真正的价值,不在于告诉你 print 怎么跑,而在于它提供了一把可重复验证的尺子。比如你可以立刻追问:如果把 print(‘hello’) 换成 sys.stdout.write(‘hello\n’),哪条指令会消失?字符串 ‘hello’ 是每次调用都重新加载,还是被缓存了?函数名 print 的查找开销到底占多少?这些问题靠读文档或猜是得不到确定答案的,但 dis 能在毫秒级给出可复现的证据。
本文不打算系统讲解 CPython 虚拟机,那是另一个话题。我们只把 dis 当作诊断工具,用一系列“直觉猜测 → dis 验证 → 修正认知”的小实验,解决那些代码看起来没问题、行为却让人困惑的瞬间。所有示例基于 CPython 3.12+,字节码格式在此版本后有较大调整,若你使用更早版本,部分输出可能不一致,建议升级后再跟随操作。
字符串拼接的字节码真相
“f-string 比 % 和 + 拼接都快。”这句话你大概听过无数遍,甚至已经当作编码规范写进了团队文档。但如果你被问到“快在哪里、快多少、有没有例外”,能给出字节码级别的证据吗?
我们先用三段功能等价的代码作为实验对象:
name = "world"
a = f"Hello {name}"
b = "Hello %s" % name
c = "Hello " + name
不看 dis 输出,凭直觉猜一下:哪段指令更少?哪段隐藏开销更大?很多人会认为 f-string 完全在编译期处理,应该最简洁;% 格式化涉及类型检查和格式解析,应该最重;+ 拼接居中。这个直觉对了一半。
用 dis.dis() 分别查看三者,关键差异如下。f-string 的核心指令序列为:
LOAD_CONST 'Hello '
LOAD_FAST name
FORMAT_VALUE 0
BUILD_STRING 2
‘Hello ‘ 作为常量通过 LOAD_CONST 加载,与 FORMAT_VALUE 的结果一起由 BUILD_STRING 2 拼接,运行时不需额外运算。+ 拼接的指令序列为:
LOAD_CONST 'Hello '
LOAD_FAST name
BINARY_OP +
% 格式化的指令序列为:
LOAD_CONST 'Hello %s'
LOAD_FAST name
BINARY_OP %
此处 dis 对比的不是指令条数,而是每条指令背后的运行时开销:BINARY_OP + 对字符串触发内存分配与拷贝,而 BUILD_STRING 是专为拼接优化的单次操作;% 格式化虽指令少,但 BINARY_OP % 内部包含格式解析与类型检查,开销远高于 BUILD_STRING。当拼接项超过两项时,CPython 不会自动优化 + 为单次分配,每条 + 都是一次独立操作,而 BUILD_STRING 可一次性处理多个片段。
到这里,似乎印证了“f-string 最优”的说法。但 f-string 的优势高度依赖编译期可确定的常量部分。如果表达式变成 f”Hello {get_name().upper()}”,FORMAT_VALUE 之前会插入函数调用和方法查找指令,BUILD_STRING 的栈深度增加,此时与 “Hello “ + get_name().upper() 的字节码长度差距显著缩小。更隐蔽的边界是:当拼接结果立即传给 print 时,f-string 对非字符串表达式已执行格式化生成字符串对象,传给 print 后 str() 调用虽存在但代价极低(字符串的 str() 返回自身引用),实际性能影响可忽略;真正需关注的是 f-string 内部复杂表达式本身的求值开销,而非 print 的二次转换。因此,f-string 的优势是有条件的,生产环境中的性能决策应辅以 timeit 在真实负载下交叉验证。
顺便提一个工具使用细节:命令行 python -m dis 适合快速查看单条语句,但要对比多段代码或嵌入自动化测试,dis.dis() 函数更实用。它接受函数对象、代码对象或字符串,返回结构化输出,方便过滤和断言。后续案例我们会频繁用到这种形式。
with 语句的异常处理契约
用 with 打开文件时,我们默认 exit 一定会被调用,哪怕块内提前 return、break 或抛出异常。但如果你被问到“编译器是怎么保证这一点的”,或者遇到“异常在 with 块里消失了”的诡异 bug,仅靠协议文档很难定位问题。
先看两段语义等价的代码:
# 版本A:with语句
with open("test.txt") as f:
data = f.read()
# 版本B:手写try/finally
f = open("test.txt")
try:
data = f.read()
finally:
f.close()
两者的 dis 输出差别比想象中大。3.12+ 采用零成本异常处理机制,以下为 with 块内抛出异常时的处理路径片段(正常执行时不走此分支):
PUSH_EXC_INFO
WITH_EXCEPT_START
CALL
RERAISE
运行时不再建立显式异常帧。PUSHEXCINFO 负责保存异常状态。WITH_EXCEPT_START 从栈上取出异常三元组并压入 __exit 的调用参数。后续 CALL 指令完成实际调用。RERAISE 则恢复未被处理的异常。异常处理路径的入口与范围记录在代码对象的 co_exceptiontable 字段(专门存储异常处理表的元数据)中,实际清理动作仍由上述字节码指令执行。而手写 try/finally 虽然也有类似结构,但缺少编译器自动插入的栈清理和异常上下文保存逻辑,这正是手写版本容易在复杂控制流中遗漏清理的原因。
with 的真正价值在于编译器强制保证清理路径完整,而非语法简洁。更实用的诊断场景是排查“异常神秘消失”:当 exit 返回 True 时,字节码中会出现条件跳转绕过 RERAISE,直接跳到正常执行路径。如果你在调试时发现异常被吞掉却找不到显式的 except 子句,检查 with 块的 exit 返回值和对应字节码跳转,往往能快速定位问题。注意 with 的异常传播路径与 except 子句无关,不涉及 CHECK_EXC_MATCH 等异常匹配指令。
工具层面,除了肉眼查看 dis 输出,还可以用 dis.get_instructions() 获取指令序列对象,程序化检测某个函数是否包含 with 相关的异常处理模式。在 3.12+ 中,必须同时满足两个条件:co_exceptiontable 非空(仅表示存在异常处理路径,不特指 with)且字节码中包含 WITH_EXCEPT_START 指令;若使用更早版本,可回退到查找 SETUP_FINALLY 等旧版指令。
常量折叠的边界在哪里
“60 60 24 这种纯常量表达式,Python 肯定在编译期就算好了。”这个假设写在无数性能优化指南里,但常量折叠其实有明确的边界。
先看两个赋值语句:
A = 60 * 60 * 24
B = 60 * 60 * get_multiplier()
直觉上,只要没有运行时变量参与,编译器就该激进折叠。dis 验证显示,A 的字节码确实只有一条指令:
LOAD_CONST 86400
计算完全消失。但 B 即使 get_multiplier() 在实际运行中永远返回 24,字节码里依然保留完整序列,且严格遵循从左到右的求值顺序:
LOAD_CONST 60
LOAD_CONST 60
BINARY_OP *
LOAD_GLOBAL get_multiplier
CALL
BINARY_OP *
CPython 不会跨函数调用边界做常量推断,哪怕该函数是纯函数且返回值恒定。另一个隐蔽边界是字符串重复:”x” 10000 会被折叠为单个常量,但 “x” n(n 为局部变量)绝不会折叠,即便 n 在当前作用域中赋值为字面量。这是因为 CPython 的常量折叠仅作用于 AST 中的字面量节点,不进行变量绑定追踪或数据流分析——这是设计选择而非能力限制。
CPython 的常量折叠只是特定实现的契约,不是语言规范,验证时必须以 dis 输出为准。同时要注意,过度追求折叠可能牺牲可读性,而 PyPy、GraalPy 等实现可能有更激进的优化策略。
工具层面,除了逐行查看 dis 输出,还可以直接检查代码对象的 co_consts 元组(存储函数中所有编译期确定的常量值)。如果折叠成功,计算结果会作为常量出现在其中;若失败,则只有原始操作数和中间值。这比解析指令流更高效,尤其在批量验证或分析混淆代码时,能快速定位哪些“看似可折叠”的表达式被刻意保留了计算痕迹。
识别混淆代码的指纹
拿到一个 pyc 文件,反编译后发现变量名全是 _a、_b,逻辑里塞满无意义的跳转和赋值——这时候最容易把编译器优化误判为混淆痕迹。dis 能帮你避开这个坑。
你可以自行构造一个教学级混淆来验证:将 x = 10 手动改写为 a=5; b=2; x=a*b,并在其后添加 if False: pass 来模拟不可达分支。原始代码 x = 10 的 dis 输出为:
LOAD_CONST 10
STORE_FAST x
混淆后的 dis 输出则为:
LOAD_CONST 5
STORE_FAST a
LOAD_CONST 2
STORE_FAST b
LOAD_FAST a
LOAD_FAST b
BINARY_OP *
STORE_FAST x
NOP
JUMP_FORWARD 0
可见冗余 STORE/LOAD 对、无效 NOP 及指向自身的 JUMP_FORWARD,均为手工混淆指纹。这些是教学级混淆的典型特征。
但要注意,3.12+ 引入的自适应优化是运行时行为:dis.dis() 默认显示编译产物中的原始字节码,不包含特化指令。若需观察运行时特化后的指令(如 LOAD_ATTR_INSTANCE_VALUE),需先确保函数已被实际执行且满足特化条件,再使用 dis.dis(func, adaptive=True) 参数;对未执行过的函数使用该参数,看到的仍是原始字节码。因此,常规 dis 输出中出现的“奇怪”结构更可能是混淆或编译器固定优化,而非自适应特化结果。区分时需分层验证:先查 co_consts 有无拆分中间值,再看 co_names 是否充斥临时变量,最后确认控制流图是否存在不可达节点。单一维度的异常不足以定性。
识别混淆的前提是熟悉正常字节码,否则容易把优化当异常、把异常当混淆。注意:以上分析仅适用于合法场景(如自有代码审计或开源项目调试),请勿用于规避保护措施。
下次再看到 print(‘hello’) 的 dis 输出,不妨试着改写成 sys.stdout.write,看看哪条指令消失了——这才是 dis 作为尺子的真正用法。
注:本文示例基于 CPython 3.12+,若使用更早版本,部分指令名称不同,但验证思路一致,可参考对应版本的 dis 文档做映射。