搭台:一台机器的四个工位
造复杂东西的诀窍,是先搭一个能跑的空架子,再往每个格子里填肉。这一章我们把整台解释器的骨架搭出来 —— 它短得惊人,核心就三行。看懂这三行,你就有了整本书的地图:接下来每一卷,都只是在把其中一个函数写实。
整台机器,核心就三行
把上一章那条流水线写成代码,它长这样(这是实现解释器的代码,所以用 JavaScript 写 —— 但思路和语言无关,你用 Kotlin / Python 照抄都成立):
function run(source) {
const tokens = scan(source); // ① 字符 → 记号
const ast = parse(tokens); // ② 记号 → 语法树
interpret(ast); // ③ 走一遍树,产生效果
}
就这些。整本书剩下的十七章,做的都是把 scan、parse、interpret 这三个函数一个一个写实。骨架不会变 —— 变的只是每个盒子里的内容。
注意数据的形状在一路变化,这是整条流水线的关键:
| 阶段 | 数据是什么 | 形状 |
|---|---|---|
| 你写的 | source | 一个字符串(一维、扁平) |
| 扫描后 | tokens | 一个数组(一维、但已分好词) |
| 解析后 | ast | 一棵树(二维、有了层次) |
| 求值后 | 值 / 效果 | 算出的结果,或打印出的东西 |
整条流水线,可以概括成一句话:把扁平的文本,还原成它本来就有的树形结构,然后沿着这棵树把意义兑现出来。「文本是扁平的,意义是有层次的」——这个落差,就是解析器存在的全部理由。
为什么是三个独立的工位,而不是一锅烩
你可能会想:能不能一边读字符一边就把结果算出来,省掉中间那些步骤?
理论上能,实践中没人这么干,因为会一团糟。把流水线切成独立工位,换来三个大好处:
- 每个工位只想一件事。扫描器完全不用管优先级,它只管切词;解析器完全不用管字符长什么样,它拿到的已经是干净的记号。关注点分离让每一段都简单到能一次写对。
- 工位之间靠明确的数据交接。扫描器交出记号数组,解析器交出语法树 —— 交接物是什么,一清二楚。这让你能单独测试、单独调试每一段(本书的每个 Demo,就是在单独展示某一个工位的产物)。
- 能换掉最后一段。因为语法树是个中立的中间产物,你可以给它接一个「走一遍就算」的求值器(解释器),也可以接一个「翻译成字节码」的编译器 —— 前面两段原样复用。卷 V 我们就会这么干。
那棵语法树,有个更正式的名字叫中间表示(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 是加餐,是为了让你尝到「真语言为什么要编译」。这个次序很重要:先要一个能跑的东西,再谈怎么让它更快。
回流
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 到此结束,你已经有了地图。下一卷,我们走进第一个工位:扫描器。