Skip to main content

Chapter 72: Parser and Compiler Pipeline Evolution

Python 源码从文本进入 CPython runtime,需要经过 tokenizer、parser、AST、symbol table、compiler、bytecode 和 code object。这个链路在 Python 3.x 中持续演进,读者阅读相关资料时会遇到一个直接问题:同一段 Python 代码,在不同版本中可能拥有相同语言语义,却对应不同 parser 结构、不同源码位置信息、不同 opcode 和不同异常处理表示。

本章要建立的能力是:看到一个版本相关的编译现象时,能够定位变化发生在哪一层,判断它改变的是语法表达能力、编译器内部表示、调试可见信息,还是 CPython 字节码实现。这个判断比记住某几个 opcode 名称更稳定,因为字节码和 compiler 内部结构会继续变化。

贯穿材料使用下面这段函数。它包含 assignment expression、局部变量绑定、循环、异常处理和返回路径,足够把 parser、symbol table、bytecode、exception table 与源码位置元数据串起来。

def collect_positive(items):
total = 0
try:
for item in items:
if (value := int(item)) > 0:
total += value
except ValueError as exc:
return f"bad item: {exc}"
return total

阅读这段代码时,稳定顺序是:先确认当前 Python 版本支持哪些语法,再确认 parser 生成怎样的 AST,再看 symbol table 如何分类名字,接着看 compiler 怎样选择 bytecode 和辅助表,最后再解释 dis、traceback、coverage、debugger 看到的结果。这个顺序会贯穿本章。

下面的图只描述 CPython 的主链路,不展开 optimizer、specialization 和执行器细节。后续各节会围绕图中的节点说明版本演进的观察点。

这条链路的核心判断是:语言规范关心源码表达和语义结果,CPython compiler 负责把语义压成可执行表示,dis、traceback 和调试工具读取的是 compiler 留下的实现痕迹。版本迁移时,要把这三层分开看。

72.1 PEG Parser and Grammar Evolution

Parser 演进首先影响“源码能否被语法层直接表达”。Python 3.9 在 CPython 中引入 PEG parser,官方设计见 PEP 617。PEG parser 的工作定义是:按照有序选择规则解析 token stream,并在成功匹配时直接形成后续 AST 所需的结构。它解决的是旧 LL(1) parser 对语法形状的限制,让 grammar 更接近语言设计者想表达的结构。

在贯穿函数中,(value := int(item)) > 0 需要 parser 识别 assignment expression 作为表达式的一部分。旧式 LL(1) 约束下,某些语法需要通过更宽泛的规则先接收,再在 AST 生成或后续检查中拒绝非法形状。PEG parser 让 grammar 可以更直接描述这些结构,减少“语法文件看起来允许、后续阶段再筛掉”的维护负担。

PEG 的关键差异在有序选择。一个 grammar rule 中多个备选分支按顺序尝试;前面的分支成功后,后面的分支就没有机会参与当前匹配。这个特性使 grammar 作者需要把规则顺序当成语义的一部分阅读。阅读 CPython grammar 时,不能把 A | B 当成数学集合意义上的可交换并列项;在 PEG 中,顺序本身就是 parser 行为。

PEG parser 还把左递归支持和 grammar action 带入 parser 生成流程。左递归让表达式这类左结合结构能更自然地写进 grammar;grammar action 让某些 AST 生成动作靠近 grammar rule。对源码阅读者而言,这意味着 parser 层和 AST 层之间的边界更紧:某个语法结构怎样落成 AST,常常要同时看 grammar rule、action 和 AST 节点构造。

贯穿函数中的 tryforif 和 assignment expression 会被 parser 组织成嵌套语法结构,再进入 AST 表示。parser 的输出还没有决定 totalitemvalueexc 属于哪类变量;名字分类由 symbol table 完成。这里要分清两个问题:parser 关心源码形状是否合法,symbol table 关心合法源码里的名字如何绑定。

语法演进带来的工程后果集中在三处。第一,错误定位可能更靠近真实语法冲突点,因为 parser 拥有更强的表达能力和错误规则组织空间。第二,语言新增语法时,grammar 维护成本下降,新增语法可以少依赖后续阶段的补充判错。第三,源码工具读取 grammar 时,需要知道目标 CPython 版本,因为 grammar 文件、parser generator 和 AST action 可能随版本调整。

判断一个 parser 相关变化时,先问三个问题:当前版本是否支持这段源码语法;失败信息来自 parser、AST validation 还是 compiler;如果源码语法不变,AST 节点形状是否发生版本变化。这个顺序能把“语法不可接受”和“可接受但后续编译失败”分开。

72.2 Symbol Analysis and Bytecode Generation Changes

Symbol table 的职责是把 AST 中出现的名字分类。它会判断一个名字在当前 code block 中是 local、global、free、cell,还是来自其他命名空间。这个阶段解决的是“同一个名字在执行时应该从哪里读写”的问题,后面的 bytecode generation 会根据这个分类选择 LOAD_FASTSTORE_FASTLOAD_GLOBALLOAD_DEREF 等访问形式。

贯穿函数中,totalitemvalueexc 都是函数局部绑定。items 是参数,也属于 fast locals。ValueError 没有在函数体内绑定,普通情况下会走 global/builtins 查找路径。int 同理。这个分类在执行前已经完成,所以 value := int(item) 的 runtime 行为依赖静态分类结果;compiler 已经知道 value 是当前函数的 local,并能生成对应的 local 存取指令。

可以用标准库 symtable 观察 symbol analysis 的结果。下面代码用于说明观察对象;实际输出会随 Python 版本和展示方式略有差异。

import symtable

source = '''
def collect_positive(items):
total = 0
try:
for item in items:
if (value := int(item)) > 0:
total += value
except ValueError as exc:
return f"bad item: {exc}"
return total
'''

table = symtable.symtable(source, "example.py", "exec")
function_table = next(child for child in table.get_children()
if child.get_name() == "collect_positive")
for name in sorted(function_table.get_identifiers()):
symbol = function_table.lookup(name)
print(name, symbol.is_local(), symbol.is_global(), symbol.is_free())

这段观察代码回答的是名字分类问题,不回答 opcode 最终长什么样。symtable 能告诉你 value 是 local,不能保证某个 Python 版本一定使用某个具体 opcode 序列。把 symbol table 和 bytecode 分开,是阅读 compiler 演进时最容易复用的边界。

Bytecode generation 把 AST 和 symbol information 进一步压成 instruction stream、常量表、名字表、局部变量表、cell/free variable 信息和辅助元数据。这个阶段受版本影响更明显。Python 3.11 引入大量 CPython bytecode 调整,官方 What’s New 中的 CPython bytecode changes 明确提到 inline cache 通过 CACHE instruction 表示,并提示读取 raw adaptive bytecode 时要处理 quickened data。

对贯穿函数执行 dis.dis(collect_positive) 时,不同版本可能出现不同 opcode 名称、跳转方向、cache entry、异常处理结构和局部变量访问细节。这里的稳定判断是:bytecode 是 CPython 执行策略的表示,语言语义仍由 Python 规则决定。函数依然按同样规则循环、转换整数、累加正数、捕获 ValueError,但 compiler 可以改变实现这个语义的 instruction layout。

import dis

dis.dis(collect_positive)

这条命令回答的是“当前解释器怎样把 code object 展示为指令序列”。它适合排查性能、控制流和局部变量访问,但不适合跨版本断言某个语义必须对应固定 opcode。官方 dis 文档 把 CPython bytecode 标为实现细节,并说明不同 Python 版本和不同 Python VM 之间不提供稳定兼容保证。

Symbol analysis 与 bytecode generation 的版本演进要按层判断。名字分类规则相对靠近语言语义,opcode layout 更靠近 CPython 实现。value 是 local 这个判断比“它由哪条 STORE_* 指令写入”更稳定;try 捕获 ValueError 这个控制流判断比“异常 handler 在字节码中出现在哪个 offset”更稳定。

72.3 Exception Table and Position Metadata

异常处理和源码位置元数据是 Python 3.11 前后观察差异很大的区域。贯穿函数中的 try block 需要表达一个范围:哪些指令属于受保护区域,异常发生后跳向哪个 handler,handler 如何匹配 ValueErrorexc 的生命周期怎样收束。早期阅读经验常从 bytecode 中寻找显式 setup 指令;Python 3.11 的 zero-cost exception 路径把正常执行路径和异常跳转信息拆得更开。

Python 3.11 What’s New 说明 CPython 实现了 “zero-cost” exceptions,使没有异常发生时的 try 语句不再承担旧式设置成本。对源码阅读者来说,这个结论应转换成更可执行的判断:正常路径中的 instruction stream 更接近普通控制流,异常 handler 的范围和目标更多依赖 code object 中的异常表。阅读 try 相关 dis 输出时,要同时看 instruction 和 exception table 展示。

位置元数据解决另一个问题:工具怎样把 runtime 位置映射回源码。Python 3.10 的 PEP 626 要求 tracing 时对实际执行的源码行产生更精确的 line event,并引入 code.co_lines() 作为 bytecode offset 到源码行的映射接口。这个变化服务 debugger、coverage 和 profiler,因为这些工具关心“某条源码行是否真实执行”。

Python 3.11 的 PEP 657 进一步加入细粒度错误位置。它把 start line、end line、start column、end column 这类信息通过 code.co_positions() 和 C API 暴露出来,让 traceback 能指向更具体的表达式区域。贯穿函数如果在 int(item) 或 f-string 表达式中触发错误,traceback 可以给出比整行更细的定位信息。

下面的观察代码展示 code object 层面的两个接口。它们读取的是 compiler 写入 code object 的位置元数据,不会改变函数执行语义。

code = collect_positive.__code__

print(list(code.co_lines())[:5])
print(list(code.co_positions())[:5])

co_lines() 更适合回答“哪些 bytecode offset 对应哪一行”;co_positions() 更适合回答“某条指令对应源码中的哪一段表达式”。二者的存在说明调试体验的提升来自 compiler 产物中携带了更丰富的位置表。traceback 看起来更聪明,底层原因是 code object 多保存了可供工具回溯的源码范围。

异常表和位置表都属于辅助元数据。它们会影响工具观察方式、错误定位精度和 coverage 结果,但它们不改变贯穿函数的语言语义。try 仍然捕获 ValueErrorexcept ValueError as exc 中的 exc 仍然是 handler 内绑定,handler 结束后仍需要按异常变量清理规则处理引用。

版本迁移时,异常和位置元数据要按用途阅读。排查控制流时,看 exception table 的 protected range、target 和 stack depth。排查 traceback 精度时,看 co_positions() 是否存在、是否被 -X no_debug_ranges 或环境变量关闭。排查 coverage 差异时,看 co_lines() 与工具版本是否匹配当前解释器。

72.4 Version-Sensitive Bytecode Reading

跨版本阅读 bytecode 的第一条规则是锁定解释器版本。dis 输出是当前 CPython 对 code object 的解释结果,同一段源码在 Python 3.10、3.11、3.12、3.13、3.14 中都可能出现不同 instruction 名称、jump 表达、cache 展示和 stack effect。官方 dis 文档持续记录 “Added in version” 与 “Changed in version”,这正说明 bytecode 缺少稳定 ABI 承诺。

贯穿函数适合展示这种版本敏感性。循环会涉及 iterator 协议和跳转;value := int(item) 会涉及调用、比较、局部变量写入;total += value 会涉及二元或原地运算;try/except 会涉及异常表和 handler 指令。每一处都可能因 compiler 优化、opcode 合并、inline cache 或异常表示调整而改变展示形态。

阅读 dis 输出时,先把问题归类到四个层次。第一层是语义层,例如“ValueError 被捕获后返回字符串”。第二层是名字层,例如“value 是 local”。第三层是控制流层,例如“循环正常结束后执行末尾 return total”。第四层是指令层,例如“当前版本用哪些 opcode 和 offset 表示这条路径”。只有第四层高度版本敏感,前三层通常更稳定。

下面是一个稳定的阅读框架:

  • 先记录 sys.version_info 和实现名称,确认自己读的是 CPython 的哪个版本。
  • 再用源码语义画出控制流,标出 normal path、loop path、exception path 和 return path。
  • 接着看 code object 的 co_varnamesco_namesco_consts,确认名字和常量表。
  • 然后阅读 dis 输出中的 jump target、handler target、call sequence 和 stack effect。
  • 最后查看版本文档,确认某个 opcode 是新增、替换、改义,还是展示格式发生变化。

这个框架的收益在于它把稳定信息放在前面,把版本敏感信息放在后面。比如 LOAD_GLOBAL 的参数编码、BINARY_OP 是否覆盖某类二元操作、jump oparg 是绝对目标还是相对 delta,这些都应该跟着具体版本文档走。把它们写成跨版本结论会让后续阅读失真。

另一个常见边界是 adaptive interpreter。Python 3.11 后,解释器会使用运行反馈和 inline cache 优化某些路径。dis 可以用不同参数展示 cache 或 adaptive 形式。这里要区分 compiler 生成的 baseline bytecode、运行时 quickening 后的内部表示,以及 dis 当前选择展示的视图。一个性能问题可能与 adaptive cache 有关,一个语义问题通常先回到 AST、symbol table 和 baseline control flow。

对工具作者来说,跨版本读取 bytecode 还要处理 pseudo-instruction、cache entry、exception table、source position 和 stack effect API 的变化。稳定做法是把版本差异封装在一层 adapter 里:外部逻辑只消费“读 global 名字”“写 local 名字”“条件跳转”“异常 handler 范围”这类抽象事件,adapter 根据 Python 版本解释具体 opcode。

72.5 Compiler Evolution Checklist

Compiler evolution checklist 的目标是把版本变化定位到正确层级。它适用于三类场景:某段源码在新版本能解析或报错更准;dis 输出和旧资料对不上;debugger、coverage 或 traceback 在新版本出现位置差异。检查顺序要从源码入口走到 code object,再根据当前版本解释 opcode 名称。

第一步,固定版本和实现。记录 sys.versionplatform.python_implementation(),并确认资料指向的版本。CPython、PyPy、MicroPython 的 parser、compiler、bytecode 和优化策略都有边界;本章只以 CPython 为主。涉及 Python 3.9 PEG parser、Python 3.10 line metadata、Python 3.11 exception table 与 bytecode 改动时,应明确这些变化属于具体版本区间。

第二步,确认语法入口。把源码放进目标版本的 parser 语境里看,先判断 token 和 grammar 是否接受这段源码。贯穿函数中的 assignment expression 要求 Python 3.8+ 语法支持;PEG parser 是 Python 3.9+ 的 CPython parser 架构变化。语法接受之后,才进入 AST 和 symbol table 分析。

第三步,确认 AST 形状。AST 是 parser 之后、symbol table 之前的结构化语义表示。这里要看节点类型、字段、位置信息和版本新增字段。表达式位置元数据会影响后续 traceback 精度;语义节点形状会影响 compiler 怎样生成控制流和访问路径。

第四步,确认 symbol table 分类。对每个关键名字标出 local、global、free、cell 和 parameter。贯穿函数中,items 是 parameter,totalitemvalueexc 是 local,intValueError 走 global/builtins 查找。名字分类错误会直接改变 bytecode 读取结论,因为 local、global、closure 的访问指令和运行时失败方式不同。

第五步,阅读 compiler 输出。先看 code object 表:co_varnamesco_namesco_constsco_freevarsco_cellvars。再看 dis 中的 instruction、jump、handler 和 cache 展示。最后再看 exception table、co_lines()co_positions()。这个顺序能把“执行语义”“调试位置”和“实现展示”拆成三个独立证据。

第六步,把变化写成层级化结论。正确的结论通常长这样:某版本在 parser 层改变 grammar 表达能力;某版本在 code object 中增加或调整位置表;某版本在 bytecode 层替换 opcode 或加入 cache;某版本在异常处理表示中把 handler 范围移到异常表。这样的结论能迁移到同类问题。

下面是完整检查表,适合在阅读源码、PEP、What’s New 或工具输出时使用:

  • 版本:确认 Python 版本、CPython 实现和资料版本。
  • 语法:确认 grammar 是否接受源码,错误来自 parser 还是后续阶段。
  • AST:确认节点类型、字段和源码位置是否符合当前版本。
  • 名字:确认 symbol table 对 local、global、free、cell 的分类。
  • 控制流:确认 normal path、loop path、exception path 和 return path。
  • 字节码:确认 opcode 名称、参数、jump 表达、cache entry 和 stack effect。
  • 异常表:确认 protected range、handler target、异常匹配和 handler 清理路径。
  • 位置表:确认 co_lines()co_positions() 与工具显示结果之间的关系。
  • 结论层级:把结论标成语言语义、CPython compiler 策略、调试元数据或工具展示。

回到贯穿函数,本章最终建立的是一条版本敏感阅读路径:源码先被 grammar 接受,再被 AST 和 symbol table 结构化,接着由 compiler 写入 bytecode、异常表和位置表,最后由解释器和工具展示。版本演进经常改变后半段的表示方式,也可能改变 parser 和 metadata 的能力;稳定阅读方式是先固定层级,再判断变化。

最小自检任务

阅读下面代码,不运行 dis,判断版本敏感点分别落在哪一层。要求说明 limittotalnumberValueError 的名字分类,并说明 try/except 与 traceback 精准定位分别依赖哪些 compiler 产物。

def summarize(raw_values, limit):
total = 0
try:
for raw in raw_values:
if (number := int(raw)) <= limit:
total += number
except ValueError as error:
return error.args
return total

答案要点

limit 是参数,属于函数局部名字;totalrawnumbererror 都在函数体内绑定,属于 local;ValueErrorint 没有在函数内绑定,普通情况下从 global/builtins 查找。assignment expression 会让 number 成为当前函数作用域中的局部绑定,后续 total += number 读取的是同一个 local。

try/except 的语言语义是捕获 ValueError 并进入 handler;CPython 3.11+ 的阅读重点通常还包括 code object 中的 exception table,因为 handler 范围和跳转目标更多由异常表描述。traceback 精准定位依赖源码位置元数据,Python 3.10 的 co_lines() 支撑更精确的行映射,Python 3.11 的 co_positions() 支撑更细的表达式范围。具体 opcode 名称和 jump 参数需要按当前 CPython 版本解释。

本章知识点总结

  • 主链路:Python 源码在 CPython 中按 tokenizer、parser、AST、symbol table、compiler、code object 和 bytecode 的顺序进入执行表示。
  • PEG parser:Python 3.9 的 PEG parser 让 grammar 更直接表达语法结构,读取规则时要把有序选择当成 parser 行为。
  • 语法边界:parser 判断源码形状是否合法,名字绑定位置由后续 symbol table 决定。
  • 名字分类:symbol table 会在执行前把名字分成 local、global、free、cell 等类别,bytecode generation 据此选择访问形式。
  • 字节码边界:CPython bytecode 是实现细节,opcode 名称、参数、cache 和 jump 表达都可能随版本变化。
  • 异常表:Python 3.11 的 zero-cost exception 路径让异常 handler 范围更多依赖 code object 中的异常表。
  • 行号元数据co_lines() 用于把 bytecode offset 映射到源码行,服务 debugger、coverage 和 tracing。
  • 列号元数据co_positions() 携带更细的源码表达式范围,服务 Python 3.11+ 的精准 traceback。
  • 阅读顺序:跨版本阅读时先固定版本和实现,再看语法、AST、symbol table、code object、bytecode、异常表和位置表。
  • 结论分层:版本变化要标明属于语言语义、CPython compiler 策略、调试元数据还是工具展示。