进阶路线:手写一个字节码虚拟机

目标

树遍历解释器慢在"每次求值都要递归进树"。本课作为进阶总览,手写一个极简字节码虚拟机(VM):先把表达式编译成指令序列,再用一个栈去执行——这是 Java、Python、Lua 真实采用的路线。本例独立实现,可单独运行。

运行环境:Node.js v18+。运行方式:保存为 vm.js,执行 node vm.js。

// vm.js —— 把 "1 + 2 * 3" 编译成字节码,再用栈执行
// 1) 编译器:AST -> 指令数组(这里直接手写 AST 演示)
function compile(node) {
  const code = []
  function walk(n) {
    if (n.type === 'num') { code.push(['PUSH', n.value]); return }
    if (n.type === 'binary') {
      walk(n.left); walk(n.right)        // 先算左右操作数,压栈
      code.push(['OP', n.op])            // 再发一条运算指令
      return
    }
    throw new Error('不支持的节点: ' + n.type)
  }
  walk(node); return code
}

// 2) 虚拟机:用栈执行指令
function execute(code) {
  const stack = []
  for (const [op, val] of code) {
    if (op === 'PUSH') stack.push(val)
    else if (op === 'OP') {
      const b = stack.pop(), a = stack.pop()
      switch (val) {
        case '+': stack.push(a + b); break
        case '-': stack.push(a - b); break
        case '*': stack.push(a * b); break
        case '/': stack.push(a / b); break
      }
    }
  }
  return stack.pop()
}

// ===== 调用示例 =====
const ast = { type: 'binary', op: '+', left: { type: 'num', value: 1 },
  right: { type: 'binary', op: '*', left: { type: 'num', value: 2 }, right: { type: 'num', value: 3 } } }
const code = compile(ast)
console.log('字节码 =', JSON.stringify(code))
console.log('结果   =', execute(code)) // 7

compile 把树"拍平"成 [PUSH 1, PUSH 2, PUSH 3, OP *, OP +];execute 用一个栈顺序执行,结果就是 7。注意优先级被编译顺序"固化"进了指令序列,运行时不再需要递归进树。

名词解释

字节码(Bytecode):介于源码和机器码之间的中间指令,比 AST 紧凑、比原生码可移植。Java 的 .class、Python 的 .pyc 都是字节码。
虚拟机 / VM(Virtual Machine):执行字节码的程序,常见的是栈式 VM(用操作数栈,如本例)或寄存器式 VM(如 Lua 5)。
编译 vs 解释:编译是"源码 -> 别的表示"(一次性,可缓存);解释是"边读边执行"。本课是"先编译成字节码,再由 VM 执行",即编译型解释器的典型形态。

课后练习

练习:给 VM 增加 PRINT 指令,让程序能直接输出栈顶值(而不只是 return)。

答案:

// 编译器里可加:code.push(['PRINT'])
// 虚拟机里加分支:
//   else if (op === 'PRINT') console.log('输出:', stack[stack.length - 1])

总结

走到第12节,你已经拥有两条执行路线:树遍历解释(第3节,易写难快)和字节码 VM(本课,稍难但快得多)。真实语言往往两者结合——开发期用树遍历快速验证语义,定型后用字节码 VM 或 JIT 编译器提速。本课只演示了算术,但同样的"编译 + 栈执行"框架,套上变量指令 STORE/LOAD、跳转指令 JMP/JIF 就能表达 if/while,套上 CALL/RET 就能表达函数。你写的 12 节,其实就是一门真实语言从 0 到 1 的缩影:Lexer 切词、Parser 建树、Interpreter/VM 执行、Environment 管状态、标准库供能力、错误保体验、REPL 给入口。下一步,你可以挑一个方向深挖——加对象、加模块、或把它编译成真正的机器码。