ADCakeyuan's blog

手写一门编程语言(七):错误体验——「报错是脸面」的完整故事

Favicon 800x800.png
Published on
//
10 分钟读完
/
TL;DR

错误设计三原则:错误是数据不是异常(run() 永不抛出,返回 {output, error} 结构化对象,为嵌入 Playground 而生);位置记录起点(== 记在第一个 =,未闭合字符串指回开引号);文案给出路(说人话、给修法、不越界猜)。内建函数用 (0,0) 哨兵位由调用点改写归位。诚实的局限:没有调用栈,错误只指向最内层帧。

「报错是脸面」这句话在前几章出现了三次:加法类型检查时、内建函数兜底时、作用域解析时。每次都是配角,这一章让它当主角——把四个阶段的错误体验从头到尾讲完,这条暗线到此收束。

先看目标形态。四类错误的实际输出长一个模样——[line 行, col 列] 文案:

[line 1, col 5]   未知的转义序列 \x(支持 \n \t \r \\ \" \$)        ← 词法
[line 1, col 9]   var 后面应该是变量名                              ← 语法
[line 2, col 1]   break 只能出现在循环里                            ← 作用域
[line 3, col 15]  数组下标越界:长度 3,下标 5                       ← 运行时

阶段信息不在消息文本里,而在结构化对象的 phase 字段上——消息给人读,字段给程序用。用户不需要知道「词法分析」是什么——他只需要知道哪里错了、错的是什么、下一步怎么办。

原则一:错误是数据,不是异常

最容易做错的地方在边界上。run() 是语言的公共入口(浏览器 Playground 将来也从这进),如果它把异常直接抛给调用方,每种宿主语言都得重新翻译一遍错误。

所以 run() 永不抛出,所有错误在边界处折算成结构化对象:

export interface ChaErrorInfo {
  name: string                                          // LexError / ParseError / ...
  message: string                                       // 干净的文案,不带位置前缀
  line: number
  col: number
  phase: 'lex' | 'parse' | 'resolve' | 'runtime'
}
 
function run(source: string): RunResult {
  try {
    ...
    return { output }
  } catch (e) {
    return { output, error: toErrorInfo(e) }            // 全部折算,一个不漏
  }
}

这个形状是三个需求的交集:测试(100 个用例里几十个断言在 error.phase 和 error.message 上,比匹配异常文本稳)、嵌入(Playground 拿到对象就能画红色波浪线)、统一(四个错误类的字段签名完全一致)。

一个不起眼的小函数值得一提:内部错误类的 message 自带 [line 1, col 5] 前缀(方便日志里单条可读),边界处用一行正则把前缀剥掉、拆进结构化字段——message.replace(/^\[line \d+, col \d+\] /, '')。内部格式为人,边界格式为机器,两头都不得罪。

原则二:位置记录起点

第二章埋过一个细节:「每个 token 记录起始行列号,而不是结束位置」。这一章兑现它的价值——起点原则在三个地方把用户指对了地方:

多字符运算符。== 的位置记在第一个 = 上。用户写了 a === b(Cha 没有三等号),词法器切出 == 和 =,报错落在 === 的开头而不是尾巴——正是用户目光所在。

未闭合字符串。"abc 报「字符串缺少收尾的双引号」,位置指向开引号——因为修复动作发生的地方就是这里(要么补个收尾引号,要么你就是想删掉这个开头)。如果记的是扫描失败的位置(源码末尾),用户会盯着一个自己根本没写过字的坐标发呆。

AST 节点全程携带。每个语法节点都带 line/col,求值器抛错时从节点上取。运行时错误因此能精确到「出错的那次运算」,而不是「那一行」。

原则三:文案给出路

中文文案的分寸,我给自己定了三条规矩:

说人话,不说术语。不写 Unexpected token,写 var 后面应该是变量名;不写 Index out of bounds,写 数组下标越界:长度 3,下标 5——把两个事实都摆出来,用户自己就能算出该改哪。

给修法,不只是给判词。这是最值钱的一条:

1.                    → 数字后的小数点必须跟数字,比如 1.5 而不是 1.
"a" + 1               → 加法 '+' 需要两个数字或两个字符串,得到 string + number;混排请用 "${...}" 插值
m = {"a" 1}           → map 的键应是字符串、数字、标识符或 [表达式]
f(1)                  → 函数 f() 需要 2 个参数,收到 1 个

第一条直接替你写出了正确写法;第二条不但说了类型不匹配,还给了这门语言里「正确的混排姿势」;后两条把期望与现实的差距摆上桌面。报错的终点不是「你错了」,而是「这样改」。

不越界猜测。反过来也要克制:拿不准的别替用户做主。解析器不会猜「你是不是想写 ==」,作用域解析不会猜「你是不是想定义全局变量」——猜错了比不猜更伤信任。暗示留在文案之外的地方(比如错误信息里的支持清单:支持 \n \t \r \\ \" \$)。

内建函数的位置归位

第四章预告过的机制,这里讲透。内建函数(len、range、push…)住在全局环境里,不在用户的源码里——它们抛错时根本不知道用户在哪一行调用了自己。

两个解法:给每个内建函数的签名加上 line/col 参数(14 个函数的签名全部被污染,丑),或者用哨兵位:

// 内建函数内部:位置填 0,0,表示「等待归位」
throw new RuntimeError(`len() 只能用于字符串、数组、map,得到 ${typeName(v)}`, 0, 0)
 
// VM/解释器的调用点:接住后改挂到用户的调用语句上
if (e instanceof RuntimeError && e.line === 0) {
  throw new RuntimeError(e.message.replace(/^\[line 0, col 0\] /, ''), node.line, node.col)
}

line === 0 是物理上不可能的合法值(行列号从 1 开始),天然适合当哨兵。用户写 len(1),报错落在 len(1) 那一行的 len 上——该在的位置。签名零污染,模式可复制。

诚实的局限:没有调用栈

写这一章时我盘点了一遍做不到的事,最大的一个是:运行时错误没有调用栈。

fn deep() {
  return 1 / 0;
}
fn middle() { return deep(); }
fn outer() { return middle(); }
print(outer());   // 报错指向 deep 里的除法,仅此而已

错误信息精确到 deep 的那一行——但对排查「谁调进来的」毫无帮助。树遍历架构里调用链活在做捕获用的异常里,抛错那一刻只留了最内层的坐标。完整的方案是给 RuntimeError 挂一串帧快照(每次 CALL 压栈时顺手记录),实现不难,属于路线图而不是取舍——这一章把它如实写进文档的「已知局限」,而不是假装不存在。

报错是脸面,而脸面的底线是不化妆:做不到的写进路线图,做得到的做到位。

交付物

  • ChaErrorInfo 结构化契约:四阶段统一字段,run() 边界全量折算
  • 位置系统:起点原则贯穿 token / AST / 求值器 / VM 四层
  • 文案三规矩(说人话 / 给出路 / 不越界)覆盖全部 30 余条错误信息
  • 内建函数 (0,0) 哨兵归位模式
  • 「已知局限」文档化:无调用栈,进路线图

系列的下一章也是最后一章:字节码与虚拟机——树遍历求值器的性能天花板在哪、编译成字节码 + 栈式 VM 能快多少倍、差分测试怎么保证两个后端行为完全一致。那会是一次「同一个语言、两种引擎」的对照实验,也是这个系列从「会跑」走向「懂行」的收官。

本系列代码开源在 cha-lang,错误体系的核心在 src/index.ts 与各阶段的错误类。