卷 III · 树CH 10深度 10/20

语法错误与恢复

扫描阶段的错误好对付 —— 跳过一个坏字符就行。解析阶段的错误棘手得多:文法是有结构的,一个缺失的右括号,可能让它后面的一切都对不上号,一个错误引发一连串假错误,像雪崩。这一章讲两件事:解析器怎么把语法错误报清楚,以及一个叫「恐慌模式 + 同步」的巧招 —— 出错后怎么重新站稳、继续往下,而不是当场瘫痪。

语法错误恐慌模式同步错误恢复

语法错误长什么样

上一章那个 consume,就是语法错误的主要出生地。回忆一下:文法要求某个位置非有某个记号不可,但它没出现。几个典型场景:

print (1 + 2;      // ( 后面该有配对的 ),却先遇到了 ;
var = 10;          // var 后面该有变量名,却是个 =
1 + ;              // + 后面该有一个表达式,却是 ;

每一种,都是解析器走到某一步,「按文法这里必须是 X,实际却是 Y」。它抛出的错误,理想情况下要说清三件事:在(行号,从记号一路带下来的)、期望什么实际遇到了什么

✗ 第 1 行:括号没有收口:缺一个 ),却遇到「;」

难点:一个错,别引发一片假错

问题来了。假设你漏了一个右括号。解析器在那个位置发现「这里该是 )」,报了错 —— 然后呢?如果它傻乎乎地继续往下解析,此刻它的「当前位置」是错乱的:它以为自己还在括号里,后面每一个正常的记号,在它看来都莫名其妙。于是它会接二连三地报出一堆根本不存在的错误,全是被第一个错带偏的。

你肯定见过这种雪崩 —— 改一个真 bug,编译器吐给你三十条报错,你修好那一个,另外二十九条自己就消失了。那二十九条,就是没做好错误恢复的解析器制造的幻觉。

⚠ 目标:报真错,别报被带偏的假错

好的解析器追求:报出的每一个错,尽量都是用户真的犯了的错,而不是「被上一个错搞乱之后看什么都不顺眼」的连锁反应。做不到完美(编译器猜错用户意图是常事),但要尽量往这个方向靠。核心手段就一个 —— 出错后主动丢掉一小段、重新找一个可靠的落脚点站稳,再往下解析。

恐慌模式 + 同步

这个巧招有个戏剧化的名字:恐慌模式(panic mode)。一旦撞上语法错误,解析器就「恐慌」—— 它不试图理解出错点附近那团乱麻,而是一路把记号丢弃,直到看见一个「肯定是新语句开头」的地标,在那里重新站稳。这个「重新站稳」的动作叫同步(synchronize)。

对 Lox 来说,最可靠的地标是分号(一条语句的结束)和几个语句起始关键字varfunifwhileforreturn …)。逻辑是:

function synchronize() {
  advance();
  while (!atEnd()) {
    if (previous().type === "SEMI") return;   // 刚跨过一个分号 → 新语句要开始了,站稳
    switch (peek().type) {                     // 或者,下一个记号是语句关键字 → 也站稳
      case "FUN": case "VAR": case "FOR":
      case "IF":  case "WHILE": case "RETURN":
        return;
    }
    advance();                                 // 否则,这个记号是乱麻的一部分,丢掉
  }
}

翻译成人话:「出错了,别纠缠,往前狂丢记号,丢到下一个分号后面、或者下一条语句的开头,从那儿当无事发生过重新开始。」这样,前一条语句里的错误,就被隔离在了它自己那条语句里,不会溢出去污染后面的语句。你一次能看到多条语句各自的真错,而不是一条错的三十个回声。

配合它,解析的主循环这样收错 —— 每解析一条声明,就用 try/catch 兜住,出错就同步、记下、继续:

function declaration() {
  try {
    // ……正常解析一条声明 / 语句……
  } catch (e) {
    errors.push(e);      // 记下这个真错
    synchronize();       // 丢到下一条语句开头,站稳
    return null;         // 这条废了,但下一条还能好好解析
  }
}
◆ 「丢一小段、重新站稳」是个通用策略

恐慌模式的精髓,不是什么高深算法,是一个态度:面对一段无法理解的输入,与其硬啃到崩,不如认怂、跳过、找个干净的地方重新开始。它牺牲了「出错点附近」的精确性,换来了「整体不雪崩」和「能一次多报几个真错」。

这个「找同步点、重新站稳」的思路,你在很多健壮的流式处理里都会再遇到 —— 处理一条坏记录时跳到下一条记录的边界继续,而不是让整个批处理挂掉。

回流

⟲ 回流 · 你的 IDE 为什么能一边报错一边补全

错误恢复,是现代编辑器体验的隐形功臣。你写代码写到一半,代码在语法上是残缺的(括号还没配、语句写了一半),可你的 IDE 依然能给你自动补全、依然能高亮出别处的真错、依然知道你在哪个函数里。

凭什么?因为它的解析器有强悍的错误恢复能力 —— 遇到你没写完的那一小块,它用类似恐慌模式的手段跳过去、把残缺处「脑补」成一棵尽量完整的树,好让语义分析、补全这些后续功能继续工作。你敲代码时看到的那些红色波浪线,底下就是这一章的机制在实时运转:一个错误被框在它自己的范围内,没有拖垮对整份文件的理解。

下次你半句代码还没写完,IDE 就精准提示了后面的东西 —— 那份「残而不崩」的从容,正是错误恢复给的。

这一章的一句话

解析错误最怕雪崩:一个真错引出一片假错。对策是恐慌模式 —— 出错就丢掉一小段、跳到下一条语句站稳,把每个错隔离在自己的语句里。

卷 III 完结。你现在有了一棵干净的语法树 —— 一份把「代码的结构」显式表达出来的中间产物。下一卷是整台机器的心脏:让这棵树跑起来