卷 V · 令CH 18深度 18/20

从树到字节码:一个迷你编译器

上一章那串字节码,是我手写喂给虚拟机的。现在补上最后一环:让程序自动把语法树翻译成字节码。做这件事的东西叫编译器。惊喜在于:它和第 11 章那个求值器,几乎是同一段代码 —— 一样后序遍历语法树,唯一的区别是,求值器当场把值算出来,编译器则把「该压什么、该做什么运算」写成指令。这一章造出它,卷 V 合龙。

编译器代码生成后序遍历编译一次

编译器 = 一个「不算,只吐指令」的求值器

把两段代码并排看,是这一章最开眼的一刻。第 11 章的求值器,遇到二元节点是这样:

// 求值器:后序遍历,当场算出值
function eval(node) {
  case "binary": {
    const a = eval(node.left);     // 先算左孩子 → 得到一个值
    const b = eval(node.right);    // 再算右孩子 → 得到一个值
    return a + b;                  // 合并:直接算出来
  }
}

这一章的编译器,遇到同一个二元节点:

// 编译器:一样后序遍历,但不算,只「吐指令」
function compile(node) {
  case "binary": {
    compile(node.left);     // 先编译左孩子 → 吐出「把左值弄到栈上」的指令
    compile(node.right);    // 再编译右孩子 → 吐出「把右值弄到栈上」的指令
    emit("ADD");            // 合并:吐一条 ADD 指令(而不是当场加)
  }
}

结构一模一样,都是「先处理左孩子、再处理右孩子、最后处理自己」的后序遍历。差别只有最后一步:求值器把 a + b 算出来,编译器 emit("ADD") 写下一条指令,让虚拟机将来去加。字面量也一样 —— 求值器返回它的值,编译器吐一条 CONST 把它压栈:

case "lit":
  emit("CONST", node.value);   // 吐一条:运行时把这个常量压栈
  return;
◆ 后序遍历,自动生成了正确的指令顺序

为什么「先编译左、再编译右、最后 emit 运算符」就能生成第 16 章那串正好正确的字节码?因为后序遍历的顺序,天然就是栈机器需要的顺序。

编译 1 + 2 * 3:先编译左孩子 1 → 吐 CONST 1;再编译右孩子 2 * 3(它内部又先吐 CONST 2CONST 3、再吐 MUL);最后吐 ADD。连起来正好是 CONST 1 / CONST 2 / CONST 3 / MUL / ADD —— 操作数总是在它的运算符之前被压上栈,因为遍历总是先走完孩子才处理父节点。树的嵌套结构,被后序遍历自动「熨平」成了一条顺序正确的指令流。你不用操心指令排序,遍历顺序帮你排好了。

合龙:源码 → 字节码 → 运行

编译器造好,卷 V 的整条链就通了。下面这台把三段连起来给你看:你选一个程序,它①显示源码,②显示编译出的完整字节码,③在虚拟机上跑出结果:

这台 Demo 想让你亲眼确认一件事:编译,是一次性的。那串字节码,编译器只生成一次;之后无论你运行这段程序多少遍,跑的都是同一串现成的指令,再不用碰语法树。这就是「编译一次,运行多次」在你眼前发生。对照第 11 章那台树遍历求值器 —— 它每次运行都要重新爬树;而这台,把爬树(编译)和执行(虚拟机)彻底分了家,爬树只爬一遍。

我们尝到这儿为止

诚实地说:我们这台迷你编译器,只覆盖了表达式、print 和全局变量 —— 足够让你尝到「编译 → 虚拟机」的完整味道,看清它和树遍历的本质区别。但它离一台真正的字节码虚拟机,还差很多:

  • 控制流(if / while)在字节码里怎么做?靠跳转指令 —— 把「条件不成立就跳过一段」翻译成「跳到第几条指令」。这是字节码的一个精彩话题,我们没展开。
  • 函数和闭包在栈机器上怎么调用?要在虚拟机里管理调用帧、把闭包捕获的变量放进特殊的地方 —— 这是 clox 里最硬核、也最漂亮的部分。
  • 局部变量可以不用哈希表,直接用栈上的槽位(slot)按编号访问,比第 12 章那条环境链快得多。

这三样,恰恰是《Crafting Interpreters》后半本 clox 的主线。你在这本书里已经把「压平成字节码、栈机器执行」的内核吃透了,那三样对你而言,是往这个内核上加零件,而不是从零理解一个陌生的东西。下一卷(也是最后一卷),我们就来正式交接:你造完了什么,接下来该去 CI 补什么。

回流

⟲ 回流 · 「编译时」和「运行时」的那条线,你天天在跨

这一章最该带走的,是「编译时」和「运行时」是两个不同的时刻这个体感 —— 而你作为工程师,一半的功力体现在分得清一件事该发生在哪个时刻。

javac / kotlinc:做的就是这一章的事(规模大得多)—— 遍历语法树,吐出 JVM 字节码。你的类型检查、常量折叠(2 * 3 直接编译成 6)、内联,全发生在编译时,一次;之后 JVM 运行多次。你能感受到「编译慢、运行快」,正是这笔预付。

「编译时能做的,别拖到运行时」是一条通用的性能与安全信条:类型检查放编译时(而不是运行时才崩)、正则 Pattern.compile 一次而不是每次匹配都重编、SQL 预编译语句、模板引擎预编译、注解处理器(KSP)在编译时生成代码而不是运行时反射 —— 这些「把反复要做的工作提前做完一次」的优化,全是这一章「编译一次、运行多次」思想的分身。你现在从造编译器的角度,看清了它们共同的根。

这一章的一句话

编译器就是一个「不算、只吐指令」的求值器 —— 一样后序遍历语法树,只是把「当场算出值」换成「写下一条指令」;后序遍历自动排好了栈机器要的指令顺序,于是编译一次、运行多次。

卷 V 完结。你造了一台解释器,又尝了一口编译器和虚拟机。最后一卷,我们把这趟旅程接回你每天的代码,并给你一张去读《Crafting Interpreters》的地图。