Chapter 23: AST and Symbol Table
Python 源码经过 tokenizer 和 PEG parser 之后,编译器已经得到结构化语法信息。本章追踪下一段交接:AST 如何把源码组织成语句、表达式和名字节点,symbol table 如何在 AST 上计算每个名字的作用域属性,后续编译状态如何据此生成局部变量、闭包变量和全局名字访问。
读完本章后,读者应能拿到一段包含嵌套函数、global、nonlocal、赋值和读取的 Python 代码,判断每个名字在当前 block 中属于 local、global、free 还是 cell,并能解释这些分类如何进入 code object metadata。这里的判断目标是编译前半段的语义准备,后续 Chapter 24 再展开 CFG 和 bytecode generation。
本章以同一段代码贯穿 AST、symbol table 和 compiler state 边界。代码刻意包含模块变量、外层函数参数、外层局部变量、内层函数、nonlocal 声明和跨作用域读取:
VALUE = 10
def outer(step):
total = 0
def inner(item):
nonlocal total
total += item + step + VALUE
return total
return inner
这段代码在运行时会产生闭包,但闭包布局在执行前已经由编译阶段决定。AST 先表达“哪里有函数定义、赋值、名字读取和名字写入”,symbol table 再表达“每个 block 拥有哪些名字、哪些名字需要从外层捕获、哪些名字留给 globals/builtins 查找”。CPython 的 ast 文档、symtable 文档 和 InternalDocs compiler.md 都把这条链路放在 bytecode 生成之前;本文按这个顺序建立最小充分模型。
23.1 AST as structured syntax
AST(Abstract Syntax Tree)是编译器看到的结构化语法对象。它把源码里的文本顺序转换成节点关系:module 包含语句列表,function definition 包含参数、函数体和装饰器,assignment 包含 target 和 value,name expression 记录名字文本和读写上下文。AST 的直接输入来自 parser,直接输出交给 symbol table 分析和后续 code generation。
在贯穿代码中,模块层 AST 的主干可以按对象关系阅读:Module.body 中先出现一个 Assign,再出现一个 FunctionDef。Assign 的 target 是 Name(id="VALUE", ctx=Store()),value 是 Constant(value=10)。FunctionDef(name="outer") 的参数里有 step,函数体中又包含 Assign(total = 0)、内部 FunctionDef(name="inner") 和 Return(Name("inner"))。内部函数体里有 Nonlocal(names=["total"])、AugAssign(total += ...) 和 Return(total)。
这个结构说明 AST 已经丢掉一部分源码表层细节,同时保留编译需要的结构信息。括号、缩进文本、空行、注释等表层形式通常已经退场;语句边界、表达式嵌套、目标位置、读取位置、源码 location 仍然保留。AST 节点的 _fields 描述子节点字段,lineno、col_offset、end_lineno、end_col_offset 让错误报告、调试信息和 location table 有来源。Python 3.14 文档中的 AST 节点还暴露 _field_types,这类字段属于版本敏感接口,阅读时应以目标 Python 版本文档为准。
用 Python 层 API 可以观察这个结构:
import ast
source = """
VALUE = 10
def outer(step):
total = 0
def inner(item):
nonlocal total
total += item + step + VALUE
return total
return inner
"""
tree = ast.parse(source)
print(ast.dump(tree, indent=4))
这段代码展示的是 Python 层 ast.AST 对象视图。CPython 内部编译器使用的是 ASDL 生成的 C 结构,例如 stmt_ty、expr_ty 和相关序列结构;Python 层 ast 模块提供可检查、可变换的公开接口。两者在节点形状上对应同一套抽象语法定义,内存表示和 API 责任不同。
AST 的关键读法是先看节点类型,再看字段语义,最后看 ctx。Name(id="total", ctx=Store()) 表示这个名字处在写入位置,Name(id="total", ctx=Load()) 表示它处在读取位置。AugAssign 同时涉及读取旧值和写回新值,但 target 的 AST context 仍然是 store 位置;symbol table 会结合 nonlocal total 把这个写入归入外层绑定。
AST 本身表达 syntax structure,它尚未完成全部 scope 决策。Name("VALUE") 在 AST 上只说明这里读取了一个名字,后续分析才决定它走 local、free、global 还是 builtin 查找。这个边界能解释很多编译期现象:语法树已经有名字节点,名字的执行期访问方式仍要等待 symbol table 和 compiler state 决定。
本节的可复用判断顺序是:先找 AST root,例如 Module、Expression 或 Interactive;再按 statement 和 expression 节点展开子字段;接着检查 Name.ctx、Global、Nonlocal、FunctionDef、ClassDef 等会影响名字归属的节点;最后把这些结构交给 symbol table 解释作用域。
23.2 Symbol table and scope analysis
Symbol table 是编译器为每个 code block 建立的名字分类表。一个 block 可以是 module、function、class,以及较新版本中为 annotation scope、type alias、type parameter 等语法建立的特殊 scope。symbol table 的输入是 AST,输出是每个 block 的 symbol 集合、嵌套 block 关系和每个 symbol 的分类标记。
贯穿代码中至少有三个主要 block:模块 block、outer function block、inner function block。模块 block 绑定 VALUE 和 outer。outer block 绑定参数 step、局部变量 total、局部函数名 inner。inner block 绑定参数 item,声明 total 为 nonlocal,读取 step 和 VALUE。这些结论来自编译期静态分析,执行 outer() 前就已经确定。
可以把这段代码的 symbol table 结果压缩成同一组维度:
| Block | Name | 关键分类 | 判断依据 |
|---|---|---|---|
| module | VALUE | local + global | 模块级赋值会写入模块 namespace,同时对函数内解析而言属于 global 层 |
| module | outer | local + global + namespace | def outer 在模块 namespace 绑定函数名,并产生子 block |
outer | step | parameter + local,后续成为 cell | 参数属于当前函数 local;内层 inner 读取它 |
outer | total | local,后续成为 cell | 当前函数赋值;内层 inner 通过 nonlocal 写入它 |
outer | inner | local + namespace | 内部函数定义在 outer local namespace 绑定名字,并产生子 block |
inner | item | parameter + local | 内层函数参数由当前 block 自己绑定 |
inner | total | nonlocal + free | nonlocal total 指向外层函数绑定,当前 block 使用闭包访问 |
inner | step | free | 当前 block 读取,外层函数绑定,当前 block 通过闭包访问 |
inner | VALUE | global | 当前函数和外层函数均无绑定声明,解析留给 module globals 和 builtins |
这个表揭示了 symbol table 和 runtime namespace 的交接。module block 的 local namespace 就是模块字典;function block 的 local 名字多数进入 fast locals 布局;free 变量从外层 cell 读取;global 名字在运行时通过 globals/builtins 查找。编译器用这些分类选择后续 name operation 的生成方式。
global 和 nonlocal 的作用发生在 symbol table 分析阶段。global VALUE 会把当前 block 对 VALUE 的绑定或读取指向模块层名字;nonlocal total 要求外层非 global block 中存在对应绑定,并把当前 block 的访问归入 free variable。声明与赋值冲突、nonlocal 找不到外层绑定等错误也在这类分析中被发现。
CPython 源码阅读时,symbol table 相关主入口可以按固定顺序定位:Python/symtable.c 中 _PySymtable_Build() 建立符号表,访问 AST 时会进入不同 symtable_visit_* 分支,遇到函数、类、comprehension 或 annotation 相关结构时进入新 block,退出时收束当前 block 的名字分类。公开的 symtable 模块是观察这张表的 Python 层接口,适合验证分类,但它展示的是编译器分析结果的接口化视图。
判断一个名字的 scope 时,稳定顺序是:先确认它所在 block;再检查该 block 内是否有参数、赋值、for target、except target、with as target、import 或函数/class 定义绑定;接着检查 global 和 nonlocal 声明;然后检查是否被嵌套 block 引用;最后决定它在当前 block 中属于 local、global、free,或在外层 block 中升级为 cell。
23.3 Free variable analysis and compiler metadata
Free variable analysis 解决闭包布局问题。free variable 指当前 block 使用但由外层 function block 绑定的名字。cell variable 指当前 block 自己绑定,同时需要被嵌套 block 捕获的名字。二者共同决定 code object 中的 co_freevars、co_cellvars,也决定运行时函数对象如何保存 closure cells。
贯穿代码中,inner 读取 step,并通过 nonlocal total 读写 total。从 inner 的角度看,step 和 total 都是 free variables。换到 outer 的角度看,step 和 total 都需要成为 cell variables,因为 inner 的 code object 需要在 outer 返回后仍能通过 cell 访问它们。
这个关系可以画成一个编译期到运行期的交接图:
图中的核心边界是:symbol table 只分类名字,后续 code generation 才根据分类生成具体访问指令。inner 中读取 step 的表达式仍然是一个 Name 节点,compiler state 会根据 symbol table 把它当作 closure dereference 处理。inner 中写入 total 的 AugAssign 也会在 name operation 阶段结合 nonlocal 分类,生成对 closure cell 的读写。
闭包元数据进入 code object 后,runtime 才能构造函数对象。执行 outer(step) 时,frame 中的 step 和 total 对应 cell;执行到 def inner 时,CPython 创建 inner function object,并把这些 cell 放入它的 closure。outer 返回后,outer frame 可以结束,cells 仍由 inner function object 持有。这个结果看起来像运行期行为,前提布局来自编译期 symbol table 和 code object metadata。
class block 需要单独标注边界。函数内部的 nested function 会沿 enclosing function scope 捕获 free variables;class body 虽然也会产生 namespace,但方法体中的普通名字查找不会把 class namespace 当作 enclosing function locals 使用。Python 3.14 的 symtable.Symbol.is_free_class() 文档专门描述了 class scope 对 method 视角的特殊 free 行为。读源码时,class、function、comprehension 和 annotation scope 的差异应分开判断。
一个实用的闭包判断顺序是:先在最内层 block 找到读取或写入的 name;再向外寻找最近的 function block 绑定;如果当前 block 声明了 nonlocal,必须命中外层 function block;如果当前 block 只读取外层绑定,该名字在当前 block 中是 free;如果外层 block 的 local 被内层 free 引用,外层名字成为 cell。这个顺序能直接解释 UnboundLocalError、闭包共享状态和 nonlocal 修改外层变量的编译期基础。
23.4 AST transformation and validation
AST transformation 是在结构化语法层修改程序。它可以替换表达式、插入语句、重写函数体、添加 location 信息,也可以用于静态分析、代码生成和工具链处理。它的边界同样明确:转换后的树必须满足目标 Python 版本的 AST schema、字段完整性、上下文合法性和源码位置要求。
下面的例子把读取位置的 VALUE 替换成常量 10。它只修改 Load 场景下的 Name,保留赋值目标中的 VALUE。ast.copy_location() 把原节点位置复制到新节点,ast.fix_missing_locations() 补齐转换中新建节点缺少的位置字段。
import ast
class InlineValue(ast.NodeTransformer):
def visit_Name(self, node):
if node.id == "VALUE" and isinstance(node.ctx, ast.Load):
return ast.copy_location(ast.Constant(value=10), node)
return node
tree = ast.parse(source)
new_tree = InlineValue().visit(tree)
new_tree = ast.fix_missing_locations(new_tree)
code = compile(new_tree, "example.py", "exec")
这个转换能通过 validation 的前提有三点。第一,Constant 是表达式节点,可以放在读取表达式位置。第二,它只替换 Load 场景,没有把赋值 target 改成常量。第三,新节点带有可用 location。少了这些条件,compile() 可能在 AST validation、scope analysis 或 code generation 阶段报错。
AST validation 关注结构约束。表达式位置需要表达式节点,语句列表需要语句节点,赋值 target 需要合法 store context,del target 需要 delete context,必填字段需要存在,列表字段需要是列表,optional 字段允许为空。Python 3.13 起构造 AST 节点时一些省略字段有默认处理;Python 3.15 起缺少必需字段和任意额外字段的兼容行为会收紧。工程上处理 AST 时应把目标 Python 版本写清楚。
AST transformation 也会影响 symbol table。把 Name("VALUE", Load()) 改成 Constant(10) 后,inner block 对 VALUE 的 global reference 消失;把一个新的 Name("extra", Load()) 插入表达式后,symbol table 会把它归入 local/free/global 的某一类。转换工具如果只关注树形结构,生成的代码可能在名字解析阶段出现新的失败。因此 AST rewrite 的检查顺序应包含结构检查和名字分类复查。
CPython 源码中,Python/ast.c 负责 AST validation,Python/ast_preprocess.c 负责部分编译前预处理,Python/symtable.c 再基于 AST 建立 symbol table。公开 API 中,compile(ast_tree, filename, mode) 是把 Python 层 AST 交回编译器的入口。只要使用这个入口,转换后的树就会重新经历 validation、symbol table 和后续编译流程。
23.5 AST to compiler state boundary
AST 到 compiler state 的边界,是从“源码结构”进入“可生成指令的编译状态”。AST 告诉编译器当前节点是什么结构;symbol table 告诉编译器每个名字应该按哪类 scope 处理;compiler state 保存当前 unit、当前 block、常量表、名字表、变量布局、location 和待生成的 instruction sequence。后续 CFG 和 bytecode 在 Chapter 24 中继续展开。
这条边界可以压缩成一条稳定路径:
在 CPython InternalDocs 描述的链路中,_PyAST_Compile() 触发 AST 到 code object 的编译;第一步构造 symbol table;随后 compiler_codegen() 把 AST 转成 pseudo instructions;再通过 flowgraph 和 assemble 阶段形成 bytecode 与 PyCodeObject。源码入口可按 Python/compile.c、Python/symtable.c、Python/flowgraph.c、Python/assemble.c 这组文件阅读。
名字访问正好体现这个边界。AST 中的 Name(id="item", ctx=Load())、Name(id="step", ctx=Load())、Name(id="VALUE", ctx=Load()) 看起来结构相似,但 symbol table 分类不同。compiler state 在生成 name operation 时会结合 expr context 和 scope 分类:item 走当前函数 local,step 走 closure free variable,VALUE 走 global lookup。相同 AST 节点类型经过不同 scope metadata,会进入不同 runtime access path。
location 信息也在这个边界交接。AST 节点上的 lineno 和 column offset 让后续 instruction、exception table、traceback 和调试信息能回到源码位置。合成节点如果缺少 location,工具可以用 fix_missing_locations() 补齐;CPython 内部生成的合成指令也可能使用无源码位置的 location。阅读编译器源码时,应把“语义结构”和“源码定位”分成两个字段族看。
本章最终建立的判断模型是:AST 负责表达语法树,symbol table 负责计算名字的 scope,compiler state 负责把节点结构和 scope metadata 合并成可生成指令的状态。遇到编译期疑问时,先判断问题属于 AST 结构、scope classification、AST validation、compiler state 还是后续 CFG/bytecode。这个分层能防止把闭包、全局查找、语法形状和 opcode 选择混在同一个解释里。
最小自检任务
阅读下面代码,判断 factory、base、factor、value、LIMIT 在各自 block 中的分类,并说明哪些名字会进入 closure metadata。
LIMIT = 100
def factory(base):
factor = 2
def scale(value):
return min(value * factor + base, LIMIT)
return scale
要求按顺序回答:模块 block 中有哪些绑定;factory block 中哪些名字是 local;scale block 中哪些名字是 local、free、global;factory code object 中哪些名字应成为 cell variables;scale code object 中哪些名字应成为 free variables。
答案要点
模块 block 中,LIMIT 和 factory 都由模块级赋值或函数定义绑定,因此属于模块 namespace 中的 local/global 名字。factory 还产生一个 function block。
factory block 中,base 是 parameter/local,factor 是赋值产生的 local,scale 是内部函数定义绑定的 local 并产生子 block。因为 scale 读取 base 和 factor,factory 中的 base 与 factor 需要成为 cell variables。
scale block 中,value 是 parameter/local。factor 和 base 在当前 block 中被读取,由外层 function block 绑定,因此属于 free variables。LIMIT 在当前函数和外层函数中都没有绑定,名字解析留给 globals/builtins 路径,因此属于 global reference。min 也属于 global/builtin 方向的名字访问;它先查 globals,再查 builtins。
closure metadata 的核心结论是:factory.__code__.co_cellvars 应包含 base 和 factor;scale.__code__.co_freevars 应包含 base 和 factor。LIMIT 和 min 不进入 closure metadata,它们由 global/builtin 查找处理。这个判断来自 symbol table 的 scope classification,执行 factory() 前已经由编译阶段确定。
本章知识点总结
- AST 角色:AST 把源码转换成语句、表达式、名字和字段组成的结构化语法对象。
- 节点字段:AST 节点通过字段表达子节点关系,通过 location 字段保留源码定位信息。
- 名字上下文:
Name.ctx区分读取、写入和删除位置,是后续名字分类的重要输入。 - 结构边界:AST 表达源码结构,scope 决策由 symbol table 在 AST 之上完成。
- Block 分类:symbol table 为 module、function、class 和特殊 scope 建立独立名字表。
- Local 判断:参数、赋值 target、导入名、函数名和 class 名都会在当前 block 中形成绑定。
- Nonlocal 判断:
nonlocal把当前 block 的名字访问指向外层 function block 中已有绑定。 - Free 变量:当前 block 使用外层 function block 绑定的名字时,该名字在当前 block 中属于 free variable。
- Cell 变量:当前 block 的 local 被嵌套 block 捕获时,该名字在当前 block 中成为 cell variable。
- 闭包元数据:
co_cellvars和co_freevars来自编译期 scope classification,并支撑运行时 closure cells。 - AST 转换:AST rewrite 必须保持节点类型、字段完整性、context 和 location 的结构约束。
- 编译边界:compiler state 把 AST 结构和 symbol table metadata 合并成后续 pseudo instructions、CFG 和 bytecode 的输入。