从零编写现代编译器 05 - 名字、作用域与可变变量
第 04 篇构建了 AST,表达式和语句有了树形结构。但 AST 中的名字只是字符串。两个不同作用域里都叫 x 的变量,在树上看起来完全一样——解析器不关心它们指向谁。如果直接把字符串传给后续阶段,类型检查器和代码生成器就必须各自重复"这个 x 到底是哪个 x"的查找逻辑。
本篇在 AST 之上增加一趟名称解析。遍历语法树,维护一个作用域栈,给每个声明分配唯一的定义 ID,把每个名字引用绑定到对应的定义。完成后,后续阶段只需要查 ID,不再处理裸字符串。
DefId 与符号表
每个变量声明得到一个编译期唯一编号 DefId。它是符号表的索引,整数类型,可以复制、比较、打印,不携带字符串。
1 | |
DefInfo 中的 ty 字段留给第 06 篇的类型检查器。名称解析阶段只管名字到定义的绑定,不判断类型是否正确。mutable 记录声明时是否带有 mut 关键字。
SymbolTable 内部是一个 Vec,DefId 就是下标。追加新定义只做一次 push,查询是 O(1) 的数组访问。整个编译过程共用一张符号表;不同作用域的同名变量拥有不同的 DefId,不会冲突。
作用域栈
名称解析需要知道"当前可见哪些名字"。Sprout 采用词法作用域,每进入一个花括号块就打开一层新作用域,离开时关闭。用一个栈来模拟这个嵌套关系:
1 | |
define 只在栈顶作用域插入。如果栈顶已经存在同名绑定,返回 Err——同一层重复声明是错误。resolve 从栈顶向底部搜索,第一个匹配的就是当前可见的定义。这正是词法作用域的核心规则:内层遮蔽外层,外层在内层退出后恢复可见。
初始化时栈里有一个空作用域,用于函数参数和顶层声明。每个 { ... } 块在进入时 push_scope,离开时 pop_scope。
resolve 采用从顶到底的线性搜索。对于 Sprout 这种嵌套深度有限的语言,这足够高效。工业级编译器在极深嵌套或大量符号时可能采用持久化哈希表或分层索引,但核心语义不变:先查最近的作用域,逐层向外扩展。
名称解析遍历
解析器生成的 AST 节点携带字符串名字。名称解析遍历整棵树,产出带有 DefId 标注的节点。核心逻辑按语句类型分派:
let 声明:let x: i64 = 5; 或 let mut y: i64 = 0;。先解析初始化表达式(表达式中的名字引用必须已经可见),再向符号表注册新定义,最后插入当前作用域。顺序很重要——let x = x + 1; 中右侧的 x 应该引用外层的 x,而不是正在声明的这个。
名字引用:遇到表达式中的标识符时,调用 scope_stack.resolve(name)。找到就把 DefId 记录到 AST 节点上;找不到就报"未定义变量"错误。
赋值:x = expr;。先解析右侧表达式,再解析左侧名字。解析到 DefId 后,还要检查 symbol_table.get(id).mutable——如果声明时没有 mut,赋值就是错误。
块:进入时 push_scope,逐条处理内部语句,离开时 pop_scope。块本身不产生新的 DefId,它只控制内部声明的可见范围。块退出后,内部声明的名字从作用域栈中消失,但对应的 DefInfo 仍然留在符号表中——符号表只追加不删除,DefId 在整个编译过程中保持稳定。
函数:函数自身的名字注册到外层作用域,得到一个 DefId。随后推入新作用域,把每个参数注册进去,每个参数得到独立的 DefId。函数体的语句在这个作用域中处理。函数体结束时弹出作用域。如果函数体内部还有嵌套块,块的 push/pop 嵌套在函数作用域之内。
遍历顺序是深度优先。对于每条语句,先处理其中的表达式(解析引用),再处理声明(注册新名字)。这个顺序保证了声明的初始化表达式不会引用自身。
作用域嵌套图示
以下程序包含两层嵌套和一次遮蔽:
1 | |
名称解析过程中的作用域状态:
1 | |
内层块退出后,scope 1 被弹出,DefId(3) 对应的 x 不再可见。return 语句中的 x 回到 DefId(1)。两个 x 在符号表中是不同条目,后续阶段用 DefId 区分,不存在歧义。
sum 没有被遮蔽。内层块中对 sum 的赋值和读取都解析到 DefId(2)。赋值时检查 mutable 标记——sum 声明为 mut,通过检查。如果把 sum 的声明改成 let sum: i64 = 0;(去掉 mut),sum = sum + x; 就会在名称解析阶段报错:赋值目标不可变。
遮蔽与赋值是两种不同的操作。遮蔽创建新的 DefId,原来的定义并未被修改或销毁,只是暂时被遮挡。赋值改变已有 DefId 对应变量的运行时值,不创建新定义。混淆这两个概念会导致对变量生命周期的误判——后续做活跃变量分析(第 11 篇)时,遮蔽关系和赋值关系产生完全不同的数据流效果。
三类错误
名称解析阶段捕获三种错误,每种对应不同的检查位置。
同一作用域重复声明:
1 | |
ScopeStack::define 发现栈顶已有同名绑定,返回 Err。注意这与遮蔽不同——遮蔽发生在不同层级的作用域之间,同层重复声明是错误。这个规则防止同一个块里出现两个含义不同的同名变量。
使用未定义名字:
1 | |
ScopeStack::resolve 从顶到底搜索,全部未命中,返回 None。错误信息包含源码位置和变量名。
赋值给不可变变量:
1 | |
名字解析成功找到 DefId,但符号表中对应条目的 mutable 为 false。这个检查放在名称解析阶段而非类型检查阶段,因为可变性是名字绑定的属性,不涉及类型推导。
错误收集采用"继续扫描"策略。遇到错误后记录诊断信息,跳过当前节点,继续处理后续语句。一次编译能报出多个名称错误,不会因为第一个未定义变量就停止。但解析失败的名字不会得到 DefId,后续引用它的表达式可能产生级联错误;错误恢复的质量在第 23 篇再改进。
每个错误携带源码位置(Span)和错误类别。诊断输出的格式遵循第 03 篇建立的源码映射:文件名、行号、列号,指向出错的标识符。例如:
1 | |
错误码前缀 E03xx 留给名称解析阶段,E0301 表示未定义变量,E0302 表示同一作用域重复声明,E0303 表示赋值给不可变变量。编号约定从现在开始固定,后续阶段的错误码不与本阶段重叠。
可变性与 let 绑定
Sprout 的变量声明强制初始化:
1 | |
没有未初始化变量。声明即绑定值,类型显式标注。mut 关键字控制后续能否对该变量赋值——这个信息记录在 DefInfo.mutable 中,名称解析阶段就已确定。
不可变绑定不等于常量。let x: i64 = f(10); 中 x 的值在运行时才确定,但绑定后不可修改。常量折叠和传播是优化阶段(第 13 篇)的工作,名称解析不做值分析。
函数参数默认不可变。如果需要在函数体内修改参数值,声明时标记 mut:
1 | |
a 的 DefInfo.mutable 为 true,b 的为 false。对 b 赋值会被拒绝。
AST 标注方式
名称解析的输出是标注过的 AST。每个名字引用节点上附加了 DefId:
1 | |
def_id 用 Option 是因为解析器产生节点时还没有做名称解析,初始值为 None。名称解析遍历后填入 Some(DefId(...))。如果解析失败(未定义名字),保持 None,后续阶段看到 None 就知道这里有未解决的错误。
另一种设计是用两套 AST 类型——解析器产出不带 DefId 的版本,名称解析产出带 DefId 的版本。这样类型系统能在编译期保证"已解析"和"未解析"不会混用。Sprout 暂时采用 Option 方案,实现更简单;第 06 篇引入 Typed HIR 时会回顾这个选择。
完整流程
把名称解析嵌入编译流水线:
1 | |
名称解析完成后,符号表和标注 AST 一起传给下游。类型检查器用 DefId 查符号表,在 DefInfo.ty 中填入推断或检查后的类型。代码生成器用 DefId 给每个变量分配存储位置。整条链上不再出现裸字符串查找。
DefId 的稳定性是关键设计决策。一旦分配,DefId 不会因为后续声明的增减而改变。符号表只追加不重排。这意味着序列化、调试输出和错误信息中引用的 DefId 始终一致。第 23 篇实现增量分析时,稳定 ID 能避免无关修改触发大面积重新检查。
练习与资料
- 画出以下程序的作用域链,标注每个名字引用绑定到哪个
DefId:
1 | |
验证:return 中的 x 绑定到外层 DefId(值 10),y 绑定到外层可变定义。内层块中 x + 1 的 x 绑定到内层 DefId(值 20),所以 z 为 21,y 被赋值为 21。最终返回 31。
- 尝试在同一作用域写两个
let x声明,确认编译器拒绝第二个。再把第二个let x移进一个嵌套块,确认遮蔽被接受。解释两种情况下符号表的条目数量有何不同。
Writing a C Compiler 的第五章讨论了变量解析与存储分配的关系。Rust Reference 的 Name Resolution 章节展示了一个更复杂的名称解析系统如何处理模块、trait 和生命周期。本篇的作用域栈是这些工业级实现的最小子集。
上一篇:04 - 表达式解析与错误恢复。下一篇:06 - 类型检查与 Typed HIR。
