卷 II · 词CH 06深度 06/20

错误从这里开始

前面几章都假设输入是对的。但真实世界里,代码经常是错的 —— 你少打个引号、多按个字符,是家常便饭。一个玩具解释器和一个真正好用的语言,差距的一大半不在「正确代码跑得多好」,而在「错误代码报得多清楚」。这一章我们从扫描阶段起,认真对待错误:报在哪、要不要停、话怎么说。

词法错误行号错误恢复报错质量

扫描器能遇到的两种错

扫描阶段的错误其实很少,就两类 —— 因为这一步只关心「字符能不能拼成词」,还没轮到语法:

  • 认不出的字符。Lox 里没有 @#$ 这些字符的位置,读到它们,扫描器不知道该切成什么。
  • 没收口的字符串。看到一个 " 就一路找配对的 ",结果找到源码末尾也没找到。

就这两样。但怎么处理它们,藏着一门语言的品味。

第一条原则:告诉我在第几行

还记得扫描器一路数着的那个 line 吗?它平时不起眼,出错的时候是救命的。一个只说「出错了」的报错,等于没说;一个说「第 7 行,字符串没收口」的报错,能让你三秒钟定位

✗ 第 7 行:字符串没有收口:缺一个 "

这就是为什么扫描器从第一天起就得老老实实数行号 —— 每碰到一个 \nline++。这个信息会一路跟着记号往下传,之后解析器、求值器报错时,报的也是这个行号。「在第几行」是错误信息里性价比最高的一条,务必带上。

第二条原则:别在第一个错误就崩

想象你的文件里有三个笔误。一个只会报第一个错的编译器,会让你陷入「修一个、重跑、再报下一个、再修」的酷刑。好的做法是:报告一个错误后,尽量继续往下扫,把这一趟能发现的错都收集起来,一次性给你。

function scan(source) {
  const tokens = [], errors = [];
  while (!atEnd()) {
    start = current;
    try { scanToken(); }
    catch (e) { errors.push(e); }   // 记下这个错,但别停,继续扫
  }
  return { tokens, errors };        // 一趟扫完,把所有错一起交出去
}

对扫描器来说,「继续」很容易 —— 遇到一个认不出的字符,报个错、跳过它、接着切下一个词就行。「一趟尽量多报」这个原则,越到流水线后面越难做(解析阶段一个错很容易带崩一片),但目标始终是它:尊重用户的时间。

◆ 报错,不是给机器看的,是给人看的

这是整章最重要的一句话。编译器 / 解释器有两类「用户」:写对了代码的人,和写错了代码的人。而后者其实占了你使用一门语言的绝大多数时间 —— 你盯着报错发呆的时间,远多于欣赏代码顺利跑通的时间。

所以:报错信息不是边角料,是产品的核心界面。它该指明在哪(行、列)、是什么(人话,不是内部术语)、最好还能提示怎么办。Rust 和 Elm 之所以口碑好,一半功劳在它们的报错写得像一个耐心的同事。你造语言时,请把报错当成一等公民来写,而不是随手 throw "error"

看它报错

回到那台真扫描器。在输入里搞点破坏,看它怎么反应 —— 打一个不该有的字符(比如 x = @;),或者写一个只有半边引号的字符串(print "没收口;):

注意报错出现的位置:它在下方的状态栏里,用红色告诉你出了什么事,而其余能切的记号照样切了出来 —— 一个错误没有让整台机器罢工。这就是「别在第一个错误就崩」在实际界面上的样子。

词法错误 vs 语法错误:一条重要的界线

有件事要现在就说清楚,免得以后混淆:扫描器只管「字符能不能拼成合法的词」,绝不管「这些词摆在一起合不合语法」。

比如这一行:

) ) ) + + ;

在扫描器眼里,这完全合法 —— 它是「右括号、右括号、右括号、加号、加号、分号」六个正经记号,每一个都拼得出来。扫描器眉头都不皱一下就切完了。至于「三个右括号配谁去、两个加号中间没有操作数」这种毛病,那是结构问题,得等下一站的解析器来管。

这条分工线非常干净:能不能成词,是扫描器的事;词摆得对不对,是解析器的事。你日后看到一个报错,先判断它属于哪一类,就能立刻缩小排查范围。

回流

⟲ 回流 · 报错质量,你天天在消费

这一章的原则,其实是你每天当「用户」在体验的东西。

回想哪门语言的报错让你舒服、哪门让你抓狂。老 C++ 模板报错、某些动态语言的 undefined is not a function(不告诉你哪个 undefined)—— 那是反面教材。而 Rust 会画出错误的下划线、告诉你「你是不是想写成这样」,TypeScript 会顺着类型链解释「这里期望 A,你给了 B」。你对一门语言「好不好用」的直觉,很大一部分是它的报错攒出来的。

你自己写工具时也一样。下次你写一个配置解析器、一个 CLI 参数校验、一个表单校验 —— 想想这一章:带上位置、别在第一个错就退、把话说成人话。「对错误温柔」是区分「能用」和「好用」的分水岭,而它跟你在做编译器还是做业务,没有关系。

这一章的一句话

好的语言,一半功夫花在错误上:报清楚在第几行、别在第一个错误就崩、把话说给人听 —— 报错是产品界面,不是边角料。

卷 II 到此结束,你手里有了一串干净的记号。下一卷是整本书的技术核心:把这串扁平的记号,搭成一棵有层次的树。