卷 I · 源CH 03深度 03/20

搭台:一台机器的四个工位

造复杂东西的诀窍,是先搭一个能跑的空架子,再往每个格子里填肉。这一章我们把整台解释器的骨架搭出来 —— 它短得惊人,核心就三行。看懂这三行,你就有了整本书的地图:接下来每一卷,都只是在把其中一个函数写实。

骨架数据流错误处理REPL

整台机器,核心就三行

把上一章那条流水线写成代码,它长这样(这是实现解释器的代码,所以用 JavaScript 写 —— 但思路和语言无关,你用 Kotlin / Python 照抄都成立):

function run(source) {
  const tokens = scan(source);      // ① 字符 → 记号
  const ast    = parse(tokens);     // ② 记号 → 语法树
  interpret(ast);                   // ③ 走一遍树,产生效果
}

就这些。整本书剩下的十七章,做的都是把 scanparseinterpret 这三个函数一个一个写实。骨架不会变 —— 变的只是每个盒子里的内容。

注意数据的形状在一路变化,这是整条流水线的关键:

阶段数据是什么形状
你写的source一个字符串(一维、扁平)
扫描后tokens一个数组(一维、但已分好词)
解析后ast一棵(二维、有了层次)
求值后值 / 效果算出的结果,或打印出的东西

整条流水线,可以概括成一句话:把扁平的文本,还原成它本来就有的树形结构,然后沿着这棵树把意义兑现出来。「文本是扁平的,意义是有层次的」——这个落差,就是解析器存在的全部理由。

为什么是三个独立的工位,而不是一锅烩

你可能会想:能不能一边读字符一边就把结果算出来,省掉中间那些步骤?

理论上能,实践中没人这么干,因为会一团糟。把流水线切成独立工位,换来三个大好处:

  • 每个工位只想一件事。扫描器完全不用管优先级,它只管切词;解析器完全不用管字符长什么样,它拿到的已经是干净的记号。关注点分离让每一段都简单到能一次写对。
  • 工位之间靠明确的数据交接。扫描器交出记号数组,解析器交出语法树 —— 交接物是什么,一清二楚。这让你能单独测试、单独调试每一段(本书的每个 Demo,就是在单独展示某一个工位的产物)。
  • 能换掉最后一段。因为语法树是个中立的中间产物,你可以给它接一个「走一遍就算」的求值器(解释器),也可以接一个「翻译成字节码」的编译器 —— 前面两段原样复用。卷 V 我们就会这么干。
◆ 中间表示(IR):整条流水线的枢纽

那棵语法树,有个更正式的名字叫中间表示(Intermediate Representation, IR)。它是「前端」(怎么读进来)和「后端」(怎么跑 / 怎么翻译)之间的合同

这个设计的威力在于解耦M 门语言 × N 个目标平台,本来要写 M×N 个编译器;有了统一的 IR,只需要 M 个前端 + N 个后端,变成 M+N。LLVM 靠这一招吃遍天下,你项目里的每一个「先转成通用格式再处理」的设计,都是同一个思想。

错误:让流水线在正确的地方停下

真实的代码经常是错的。骨架里还漏了一件事:如果某一站出了错,后面的站就别跑了 —— 一段扫描都没通过的文本,去解析它毫无意义,只会冒出一堆更莫名其妙的二次错误。

所以更真实的骨架是这样:

function run(source) {
  const { tokens, scanErrors } = scan(source);
  if (scanErrors.length) return report(scanErrors);   // 词法错,止步

  const { ast, parseErrors } = parse(tokens);
  if (parseErrors.length) return report(parseErrors); // 语法错,止步

  interpret(ast);   // 运行期还可能出错,但那要等真的跑起来才知道
}

这就引出了一个贯穿全书的分类,它非常有用:

错误发生在叫什么例子
扫描词法错误字符串没收口、认不出的字符 @
解析语法错误括号不配对、该有分号没有、表达式缺一半
求值运行时错误用了没定义的变量、1 + "a"、除以某个非数字

前两类,在代码「跑」之前就能发现(因为它们只关乎结构,不关乎数据)。第三类必须真的运行到那一步才暴露 —— 这也正是动态类型语言的软肋:很多本可以早点发现的错,被拖到了运行时。「把运行时错误提前到编译期」,就是类型系统在做的事。记住这个分类,第 6 章、第 10 章、第 13 章我们会分别把这三类错误做扎实。

REPL:把 run 放进一个循环

你可能见过 Python 或 Node 的交互式命令行 —— 敲一行、立刻出结果。那个东西叫 REPL(Read–Eval–Print Loop,读取–求值–打印–循环),它一点都不神秘,就是把 run 套进一个 while

while (true) {
  const line = prompt("lox> ");   // Read  读一行
  run(line);                       // Eval  跑(内部会 Print)
}                                  // Loop  再来

本书第 15 章那个能跑 Lox 程序的 Demo,本质就是这个循环 —— 只不过「读一行」换成了「读一个文本框」。你每天用的 node、python、irb,掀开盖子都是这四个字母。

接下来的地图

骨架搭好了,剩下的就是照着地图填肉

把哪个函数写实产物
II · 词scan记号数组
III · 树parse语法树
IV · 义interpret一个能跑的解释器 ✓
V · 令interpret 换成 compile + VM一台字节码虚拟机

读完卷 IV,你就有一个完整、能跑的解释器了 —— 卷 V 是加餐,是为了让你尝到「真语言为什么要编译」。这个次序很重要:先要一个能跑的东西,再谈怎么让它更快。

回流

⟲ 回流 · 这个骨架,是每一门语言的 main()

scan → parse → interpret/compile 这个三段式,不是 Lox 特有的,是所有语言实现的通用形状。你去翻任何一个真实项目的源码,主流程都能对上号:

CPython:tokenizer.c(扫描)→ parser(解析成 AST)→ compile.c(编译成字节码)→ ceval.c(一台栈式 VM 执行)。你会发现它和本书卷 II→V 的结构一一对应 —— 只是每一段都工业级地厚。

你的 Kotlin 编译器(K2):词法 → PSI / FIR(语法树的两种形态)→ 后端生成 JVM 字节码。KSP、编译器插件,就是嵌在这条流水线的中段,在语法树上做手脚。你之所以觉得写 KSP 难,一部分原因就是你没造过这条流水线 —— 造完这本书,那些「访问 AST 节点」的 API 会突然变得亲切。

下次你 import 一门语言的解释器源码,先找它的 main,你多半能在几分钟内认出这个三段骨架。地图在手,读大项目就不慌了。

这一章的一句话

整台解释器的骨架就三行 —— 扫描、解析、求值 —— 剩下的十七章,只是把这三个函数一个一个写实。

卷 I 到此结束,你已经有了地图。下一卷,我们走进第一个工位:扫描器。