resolver 在运行前遍历 AST,做两件事:给每个变量引用算出「隔几层作用域」的距离表(解释器据此跳层取值),并把四类结构错误提前到编译期(return/break 越界、同层重复声明、自引用)。最大的坑:函数声明必须「声明即初始化」,否则函数体内的递归调用会被自引用检查当成 var x = x 误杀。
上一章结束时语言已经能跑了,但有一类体验问题没解决:
fn f(a, b) {}
f(1);这段代码能通过解析,要等运行到 f(1) 那一行才报「需要 2 个参数」。而下面这些,则要等到更糟的时刻:
return 1; // 运行时才炸,且不知道该归罪于谁
while (true) { x += 1; break; } // break 合法,但换个位置呢
var a = 1;
var a = 2; // 静默覆盖,还是报错?
var x = x; // x 的值是什么?这些错误的共同点:不需要运行就能判定。只要在执行前把语法树再过一遍,问清楚每个变量「住在哪一层作用域」,它们就全部现形。这就是本章的主角:静态作用域解析器(resolver)。
「报错是脸面」系列第三次出现——前两次分别在加法类型检查和内建函数报错里。这一次的升级是时机:错误不但要说得清楚,还要来得早。
距离表:一次遍历,两张答卷
resolver 的输入是 AST,输出是一张 Map<Expr, number>:从每个变量引用节点,指向「从使用处到声明处隔了几层作用域」。
export function resolve(program: Program): Map<Expr, number> {
const resolutions = new Map<Expr, number>()
const scopes: Scope[] = [] // 作用域栈:每个 Scope 是一张 name → 已初始化? 的表
...
}遍历规则直白得像走迷宫时在手上画地图:进入块语句 push 一层,退出 pop 一层;声明变量时记进当前层;读到变量时从栈顶往下找,找到就记下层数差。
解释器拿到这张表后,变量读取从「逐层回溯」变成「一步跳层」:
private lookup(expr: IdentifierExpr): Value {
const dist = this.resolutions.get(expr)
if (dist !== undefined && dist >= 0) return this.env.get(expr.name, dist)
// 没有距离记录 = 全局变量,按名字回溯
...
}性能收益是次要的(树遍历解释器的瓶颈根本不在这),真正的收益是语义被显式化了:变量属于哪层作用域,从「运行时的巧合」变成「编译期的事实」。
一个设计分工要说清楚:全局变量不出现在距离表里。resolver 只给嵌套作用域里的引用记距离,全局引用留给解释器按名字回溯。理由是全局环境在 REPL 场景下是跨行共享、动态生长的(下一章会讲),把全局也编译成固定位置反而碍事。
四类提前爆炸的错误
距离表是副产品,resolver 的正职是抓结构错误。四类,全部在编译期拦截:
1. return 越界。维护一个 fnDepth 计数器,进函数体加一,出函数体减一;遇到 return 时计数为零就报「return 只能出现在函数里」。
2. break/continue 越界。同款手法,loopDepth 计数器。「break 只能出现在循环里」。
3. 同层重复声明。var a = 1; var a = 2; 在同一层直接拒绝。层内隔离是局部变量价值的根基,静默覆盖会让「哪层声明的」变得无所谓。
4. 自引用。这是最有意思的一类:
var x = x;处理声明时 resolver 分两步:先在当前层登记名字(标记「未初始化」),等初始化表达式解析完再标记「已初始化」。读到一个「本层已登记但未初始化」的变量,就是自引用——错误信息直说:不能在本层作用域读取尚未初始化的变量 'x'(var x = x 是自引用)。
有个刻意的简化值得坦白:这个检查只看最内层。外层作用域里未初始化的变量,内层读取时不报——因为那需要跨函数的生命周期分析,误报率会失控。玩具语言的品味在于知道在哪收手。
真 bug 复盘:递归差点被自己人误杀
这个检查上线后,测试当场炸出一个我没想到的连锁反应。斐波那契:
fn fib(n) {
if (n < 2) return n;
return fib(n - 1) + fib(n - 2);
}
print(fib(10));报错:
ResolveError: 不能读取尚未初始化的全局变量 'fib'(var x = x 是自引用) [line 4, col 12]
fib(10) 的 fib,被当成了 var x = x。
原因藏在处理顺序里。我最初照抄 var 的两步走写函数声明:登记名字 → 解析函数体 → 标记已初始化。但函数体里那句 return fib(n - 1) 在第二步就执行了——此刻 fib 恰好处于「已登记、未初始化」状态,和 var x = x 里的 x 一模一样。
var 的两步走是对的(初始化表达式确实该在绑定生效前求值),函数声明不一样:它没有「求值初始化器」这个动作,名字从声明那一刻起就该可用。修复是给函数声明单独一条路径——声明即初始化,然后再解析函数体:
case 'fnDecl': {
// 先重名检查并立即生效,再解析函数体——递归因此可用
declareImmediately(stmt.fn.name!, stmt.fn.line, stmt.fn.col)
resolveFn(stmt.fn)
return
}这个 bug 的教育意义在于:两个各自正确的规则(「声明先于使用」与「两步走防止自引用」)组合起来会产生一个谁都没预期的假阳性。单测里 var x = x 通过、斐波那契通过、闭包通过——挂掉的是「顶层函数 + 递归」这个组合。差分测试的素材又多了一条。
交付物
resolve(program):距离表 + 四类结构错误,30 行核心逻辑- 解释器接入距离跳层取值,全局按名字回溯的分工
for的初始化变量被圈在循环头自己的作用域里(beginScope/endScope包住)- 四类错误各有测试;递归误杀作为回归用例永久入库
到这里,树遍历解释器的所有零件都齐了。下一章换个口味,讲讲 REPL——这个语言的第一印象是怎么被我改了三遍才像样的:变量为什么不能每行独立、表达式回显的试探法、以及「哪些解析错误代表用户还没写完」这个看似简单实则微妙的问题。
本系列代码开源在 cha-lang,resolver 实现在 src/resolver.ts,commit
9a23d88是本章完成时的状态。