本页目录
符号表与作用域
编译器的"联系人":把每个标识符关联到它的定义——从词法作用域的栈式查找到闭包捕获标记、从模块导入到泛型单态化,所有语义分析都从这张表开始。
概述
语义分析的第一件事不是类型检查,是名字解析(name resolution)——把源码里的每个标识符(变量名、函数名、类型名)关联到它的定义。符号表(symbol table)是做这件事的数据结构。它看着简单(存名字→信息的映射),但作用域规则(scope rules)加进来后,符号表的设计直接影响编译器的正确性和效率——闭包捕获、模块导入、泛型特化、重载解析,每一项都在符号表上施加不同的操作要求。
符号表的基本操作
任何符号表必须高效支持两个核心操作:
| 操作 | 语义 |
|---|---|
insert(name, info) | 在当前作用域注册一个名字 |
lookup(name) | 从当前作用域向外查找该名字的定义 |
此外还有:
enter_scope()/exit_scope():进入/离开一个词法作用域lookup_current(name):只在当前作用域查(用于检测重复定义)
复杂度极其关键:insert 和 lookup 在编译器的名字解析阶段被调用海量次,必须是 O(1) 或近 O(1)。
栈式符号表:词法作用域的原生映射
源码的作用域天然是嵌套的(大括号套大括号),用栈式符号表直接对应:
struct Scope {
symbols: HashMap<String, SymbolInfo>, ← 本作用域的名字
parent: u32, ← 外层作用域索引
}
symbol_table: Vec<Scope> ← 栈, 当前作用域在栈顶
enter_scope(): push 新 Scope {parent: 当前栈顶索引}
exit_scope(): pop
lookup(name):
scope = 栈顶
while scope != nil:
if name in scope.symbols:
return scope.symbols[name]
scope = scope.parent ← 沿 parent 链向外找
return NOT_FOUND
insert:只往当前作用域(栈顶)写。lookup:从栈顶沿parent链往外,第一次命中就是最近的定义(符合"内层覆盖外层"的词法作用域规则)。- 复杂度:
insertO(1);lookupO(depth), depth 通常是常数(嵌套不会太深)。
这就是绝大多数编译器的实现方式——Clang、Rust 的部分 pass、C/C++ 编译器都在用这个模式。
为什么不用单个全局哈希表
单个哈希表 HashMap<String, Vec<SymbolInfo>> 也可以,但 exit_scope() 时要遍历删除本作用域的所有插入——不如栈式直接 pop 一个 Scope 干净。
模块与命名空间:二级查找
当语言有模块/命名空间时("引入另一个文件里的名字"),符号表需要跨越"当前文件→当前模块→导入的模块"做查找:
lookup(name, current_module):
result = lookup_local(name) ← 1. 当前作用域链
if result: return result
result = lookup_imported(name, current_module) ← 2. 导入的其他模块的公开符号
if result: return result
return lookup_global(name) ← 3. 全局(如内置类型/函数)
模块系统在符号表上引入了可见性(visibility):
public:该名字对导入方可见private:只在当前模块内可见
符号表在处理模块导入时,只把被导入模块的 public 符号插入当前作用域链(或单独维护一个"导入符号"列表)。Rust 编译器在 resolve 阶段就是把 use 语句的导入符号展开到符号表里。
闭包捕获:从作用域链到"逃逸"的名字
当语言有闭包(closure)时,闭包体可能引用了外部作用域的变量——这些变量在外部作用域 exit_scope() 后仍然需要被访问:
fn outer():
x = 10
return fn inner(): ← inner 捕获了 x
return x + 1 ← x 在 outer 的作用域里, 但 inner 作为返回值逃逸
符号表处理闭包捕获的过程:
- 进入
inner作用域时,lookup("x")沿作用域链找到了outer里的x。 - 标记
x为"被闭包捕获"——这是一个关键语义标记,后续代码生成时x不能分配在栈上(否则outer返回后栈被回收),必须堆分配或用 upvalue 机制。 - 在闭包的符号信息里记录捕获列表:
[(x, by_ref_or_by_value)]。
Rust 编译器在符号解析阶段区分 Fn/FnMut/FnOnce 的捕获方式(共享引用/可变引用/转移所有权)——这些决策全部来源于符号表的捕获分析。
重载解析:lookup 不再是返回单个值
有函数重载的语言(C++、Java),同一名字在一个作用域里可能有多个定义(不同参数类型)。此时 lookup 返回的不是单个符号,而是一个 overload set:
lookup("foo") → [
foo(int, int) → int,
foo(float, float) → float,
foo(string, string) → string,
]
选择哪个,取决于调用时的参数类型——这已经是类型检查的范畴(在 类型系统 里讲)。符号表的职责是维护 overload set,而不是选择。
泛型与单态化:名字的多重"化身"
泛型函数 fn identity<T>(x: T) -> T 在 Rust/C++ 里会被单态化(monomorphization)——每种类型参数 T 的实际值(如 i32、String)生成一份独立的机器码。这对符号表意味着:
- 泛型定义本身是一条符号,附带类型参数列表。
- 每次实例化(
identity::<i32>)产生一个新的符号表条目——但它的引用信息(捕获、依赖)从泛型模板复制。 - 符号表需要支持"实例化模板"——复制泛型定义的符号信息,替换其中的类型参数为实际类型。
Rust 编译器在 MIR 层做单态化(不是 AST 层),所以符号表在 AST→HIR→MIR 的不同阶段承载不同的粒度。
权衡与失败模式
- 单层全局表:不区分作用域 → 内层名字覆盖外层无效,
lookup总返回第一个插入的同名符号 → 必须栈式。 lookup不走完整链:某些实现为性能跳过parent链,直接查全局缓存 → 作用域遮蔽(shadowing)失效。- 闭包捕获标记遗漏:符号表发现捕获但没标记 → 代码生成给捕获变量分配栈空间 → 闭包调用时 use-after-free。
- 循环模块导入:A 导入 B,B 导入 A → 符号表解析时死循环 → 解析阶段需要维护"正在解析的模块"集合,检测循环并报错。
- 同名不同类型:函数和变量在有些语言里可以同名(不同名字空间,如 C 的
struct foovsfoo变量) → 符号表需要分名字空间(tag namespace、object namespace、label namespace),每个名字空间独立栈式查找。
参考
- Dragon Book: Chapter 2 (symbol table) & Chapter 6 (type checking via symbol table)
- Cooper/Torczon: "Engineering a Compiler", Chapter 4 (symbol tables & scoping)
- Rust compiler source:
compiler/rustc_resolve/src/— 名字解析的工业级实现,含模块/闭包捕获/宏展开
Keywords: symbol table, stack-based symbol table, scope, name resolution, lexical scope, shadowing, module, namespace, visibility, public/private, closure capture, upvalue, overload set, overload resolution, monomorphization, generic instantiation, cyclic import, name space