1.4 课程项目:设计一门小语言和编译器
本课程会反复回到一个项目:一门小语言的编译器。这个项目不是为了和 C、Java、Rust 或 Python 竞争。它的目的,是把编译理论变成具体工程。很小的语言仍然可以包含所有核心编译器思想:词法单元(token)、文法(grammar)、抽象语法树(AST)、作用域、类型、中间表示(IR)、控制流、优化、运行时表示、代码生成、测试和诊断。
编译器项目的危险在于野心失控。很容易说“我们加函数、闭包、对象、泛型、模块、宏、模式匹配、异常、async、所有权和 native code 吧”。每个特性单独听起来都不大。实际上,每个特性都会触碰多个阶段。闭包影响解析、名字解析、类型检查、中间表示、运行时分配、调用约定、优化、调试,以及垃圾回收或所有权。对象影响布局、分派、方法查找、访问控制、初始化和运行时元数据。泛型影响类型表示、重载解析、单态化(monomorphization)或具体化(reification)、诊断和代码体积。高级的一课很简单:语言特性不是语法装饰,而是全系统承诺。
第一版语言
第一版语言应该刻意保持很小。一个有用起点是:
fn main() -> int {
let x: int = 40 + 2;
return x;
}这个小例子已经需要:
- 识别关键字、标识符、标点、运算符、整数字面量和文件结束符(EOF)的词法分析器。
- 解析函数、块、变量声明、return 和表达式的语法分析器。
- 带源码范围的抽象语法树。
- 管理局部变量的符号表。
- 检查
int、return 语句和算术的类型检查器。 - 能表示常量、加法、局部值和 return 的中间表示。
- 能执行中间表示、生成字节码或最终产生目标代码的后端。
- 能比较词法单元、抽象语法树快照、诊断、中间表示和最终输出的测试框架(test harness)。
第一个项目目标不是性能,而是端到端纵向切片。一个能接受一个小程序并产生可验证结果的编译器,比一个巨大但不能运行任何程序的语法分析器更有价值。
里程碑与接口
编译器阶段应该既能独立测试,也能组合测试。词法分析器可以有黄金词法单元测试。语法分析器可以有抽象语法树快照测试。类型检查器可以有正例和反例程序。IR 生成器可以比较文本 IR。优化器可以比较优化前后 IR 并做等价性测试。后端可以执行编译后的程序并比较输出或退出码。
阶段接口和阶段算法一样重要。如果语法分析器节点不携带源码范围,诊断会受伤。如果符号绑定只存字符串而不是稳定符号 ID,遮蔽会变得脆弱。如果带类型抽象语法树没有纪律地原地修改未带类型抽象语法树,测试会很难推理。如果 IR 指令没有清楚建模控制流,优化会不安全。请把每个阶段边界都当作接口(API)。
在第 1 章,项目仍然是架构层面的。你应该能回答:
- 编译器能运行的最小程序是什么?
- 第一批词法单元种类有哪些?
- 第一个里程碑需要哪些抽象语法树节点?
- 第一版有哪些类型规则?
- 第一个后端会解释 IR、生成字节码、生成 C、生成 WebAssembly,还是生成本地汇编?
- 什么证据能证明第一个里程碑成功?
实用特性预算
一门好的第一版语言可以包含:
int和bool- 算术和比较
letif/elsewhile- 带类型参数的函数
return- 注释
第一版通常不应该包含:
- 类(class)
- 闭包(closure)
- 泛型(generic)
- 异常(exception)
- 模块(module)
- 运算符重载
- 宏(macro)
- 隐式转换
- 借用检查
- 并发
这不是因为这些特性无聊。它们非常有趣。问题是顺序。编译器课程应该让你感受到每个机制的重量。如果在流水线存在前加入太多特性,项目会变成雾。如果先让纵向切片跑起来,再一次加入一个特性,每个特性都会变成可控实验。
仓库里应该保留什么
从第一天开始,就应该版本化这些产物:
examples/
ok/
errors/
tests/
lexer/
parser/
typecheck/
ir/
execution/
docs/
language.md
diagnostics.mdexamples/ok 保存应该编译成功的程序。examples/errors 保存应该失败并产生特定诊断的程序。词法分析器测试锁定词法切分。语法分析器测试锁定树形。类型检查测试锁定语义规则。IR 测试锁定降低决策。执行测试锁定运行时行为。文档防止语言变成“当前代码刚好接受的东西”。
本课程会逐渐把这个骨架变成可工作的编译器。
做纵向切片,而不是一堆 stub
先完成可端到端运行的极小片段,例如整数、加法和 print:能词法分析、解析、建抽象语法树、求值与降低,并给出可观察结果。随后每次只加一个特性并贯穿所有层:名字需要声明、符号表和诊断;if 需要布尔规则、控制流与代码生成;函数需要作用域、调用、栈帧和 ABI。纵向切片比先写完所有词法规则更早暴露契约缺口。
保留一个简单的树遍历解释器作为参考语义。对小型随机程序,将它的输出与生成代码比较,无法证明完全正确,却能抓住大量降低或优化缺陷。
项目纪律
- 每个里程碑冻结小规格:文法、类型规则、可观察错误、示例。
- 尽早设计 ErrorNode/ErrorType,避免一个坏程序制造几十个崩溃。
- 每个里程碑都应能展示输入、阶段产物和结果。