7.3 作用域与符号表
语法分析器(parser)能识别 x,却不知道它指向哪一个声明(declaration)。名字解析(name resolution)把声明和使用映射为语义标识。作用域(scope)是绑定(binding)可见的区域;符号表(symbol table)存放这些区域中可用的绑定。
可以从映射栈开始。进入代码块时压入一个映射,离开时弹出。声明 let x 时插入当前映射;解析一个名称使用时,从最内层映射向外查找:
global: print -> Builtin#1
function: x -> Symbol#12
block: x -> Symbol#19, y -> Symbol#20
use x -> Symbol#19
use print -> Builtin#1这会自然实现词法作用域和遮蔽。它不是动态作用域:函数从它定义的位置解析名字,而不是从调用者的位置解析。
两种作用域的差别值得列表对照:
| 维度 | 词法作用域(lexical) | 动态作用域(dynamic) |
|---|---|---|
| 名字按何处解析 | 代码书写的位置 | 运行时的调用链 |
| 能否静态确定 | 能,编译期可解析 | 不能,依赖运行时 |
| 结果稳定性 | 与调用者无关 | 随调用者变化 |
| 主流语言 | 绝大多数现代语言 | 少数(早期 Lisp、某些 shell) |
本章及绝大多数语言默认采用词法作用域:映射栈的“向外查找”恰好对应“向外层代码查找”。
有意识地安排声明顺序
对每个作用域,都要决定哪些声明会提前可见。函数可以预先声明以支持递归。变量初始化式通常应在新变量进入作用域前解析,因此 let x = x; 会报告自引用错误。另一门语言也许允许它,但名字解析器必须把这种策略说清楚。
每个符号都应有 ID、名称、声明范围、种类、可见性,以及后续加入的类型。只记录字符串并不等于完成名字解析:两个同样拼写为 x 的变量可能是不同符号。
作用域错误需要上下文
重复声明表示当前作用域已存在冲突名称;遮蔽表示外层作用域中存在同名绑定。一门语言可能允许后者、拒绝前者。诊断应同时指向两处声明。
对于未定义名称,创建错误绑定或保留失败查找的结果,使后续趋不会反复产生同一条消息。离开作用域也要有结构化保证:异常或解析失败不得让一个代码块的绑定泄漏到下一个代码块。
提示
排查有问题的程序时,记录作用域深度、声明 ID 和查找路径。x: block -> function -> global 往往比一大段映射转储更快暴露名字解析器的缺陷。
逐步解析嵌套示例
let x = 1;
fun f(x) {
{ let y = x; print(y); }
print(x);
}创建模块作用域并绑定外层 x,然后在其中预声明 f。进入 f 的作用域,将参数 x 绑定到一个新符号,它遮蔽外层 x。进入代码块,先解析初始化式中的 x,找到参数;再绑定 y。print(y) 找到代码块中的 y;最后的 print(x) 找到参数 x。名字解析器应记录这些符号 ID,而不只是得出每种拼写都合法的结论。
闭包带来一个后果:嵌套函数使用外层符号时,即使外层函数已经返回,它仍需保有到该符号的绑定。解析阶段识别这种捕获;之后的运行时设计决定如何表示它。
应测试的查找规则
- 声明之前的使用遵循语言声明的策略。
- 同一作用域的重复声明会指向第一次声明。
- 内层被遮蔽的使用向内解析;退出作用域后的使用向外解析。
- 被捕获的使用记录其定义处的声明,而不是调用者的局部变量。