从零编写现代编译器 07 - 用参考解释器固定语言语义
第 06 篇完成了类型检查,1 + 2 能通过检查,true + 1 被拒绝。但类型检查只回答"这个程序有没有类型错误",不回答"这个程序执行后结果是什么"。编译器目前还没有任何部分说 1 + 2 的结果是 3。
本篇在 Typed HIR 上直接建一个参考解释器。它递归遍历节点,维护变量环境,按确定的规则求值,把结果打印出来。这个解释器将成为整个系列的语义基准:后续编译器生成本机代码后,任何测试用例只要和解释器的输出不一致,就说明降低、优化或代码生成出了错。
值的表示
解释器执行 HIR 节点后产生值。当前语言只有三种类型,值的表示对应地简单:
1 | |
I64 携带 64 位有符号整数,Bool 携带布尔值,Unit 对应没有有意义返回值的表达式(赋值、打印调用等)。后续加入数组和结构体时,这个枚举会扩展,但三种基础值从本篇开始就固定下来。
环境:栈帧与变量绑定
解释器需要知道每个变量当前的值。环境用一组栈帧表示,每个栈帧是一张从 DefId 到 Val 的映射。函数调用时压入新帧,函数返回时弹出。
1 | |
变量查找从当前帧开始。因为 Sprout 没有闭包,不存在跨帧捕获,每个变量一定在当前帧中。名称解析阶段已经把变量引用绑定到 DefId,解释器不需要重复做作用域查找,直接用 ID 索引即可。
求值规则
解释器对每种 HIR 节点递归求值,规则逐条列出。
字面量 直接转换:整数字面量变成 I64,true 和 false 变成 Bool。
变量引用 用 DefId 在当前帧中查找,返回绑定的值。
赋值 对右侧表达式求值,把结果写入当前帧对应的 DefId,返回 Unit。
二元算术(加、减、乘)对左右操作数从左到右求值,取出两个 i64,用二进制补码回绕计算。实现上使用 wrapping_add、wrapping_sub、wrapping_mul,而不是 Rust 默认的溢出 panic:
1 | |
这是语言语义的选择。Sprout 的整数加减乘按补码回绕,不触发运行时错误。
除法和取余 同样先从左到右求值,但有两个必须拦截的情况:
- 除数为零 → 运行时错误,报告源码位置。
i64::MIN / -1→ 运行时错误。补码中最小负数除以 -1 的数学结果超出i64范围,既不回绕也不静默截断,直接报错。
两个条件都不触发时,执行 Rust 的普通整数除法(向零截断)。
比较运算(<、<=、>、>=、==、!=)对两个 i64 求值后返回 Bool。
短路逻辑 是本篇最值得注意的规则。a && b:先求值 a;如果结果是 false,整个表达式的值就是 false,b 根本不求值。a || b:先求值 a;如果是 true,整个表达式的值就是 true,b 不求值。
"不求值"意味着 b 中的一切副作用——函数调用、打印、除零——都不会发生。这不是优化,是语义规定。
函数调用 先从左到右对每个实参求值,然后压入新栈帧,把形参 DefId 绑定到实参值,执行函数体,弹出栈帧,返回结果。
return 立即终止当前函数体的执行,携带返回值。实现上可以用一个特殊的控制流信号(如 Rust 的 Result 或专用枚举)向上传播,在函数调用的入口处捕获。
求值顺序
操作数和函数实参一律从左到右求值。这意味着 f(a(), b()) 中 a() 先执行,b() 后执行。如果 a() 打印了 1,b() 打印了 2,输出一定是 1 然后 2。这个顺序写入语言规范,解释器和编译器都必须遵守。
运行第一个程序
用第 03 篇的语法写一个最小示例:
1 | |
解释器执行过程:进入 main,求值 1 + 2 得到 I64(3),调用内置的 print_i64 向 stdout 输出 3,返回 I64(0)。
1 | |
错误情况与边界用例
除零:
1 | |
解释器输出运行时错误,附带源码范围,指向 / 运算符所在的位置。程序不会产生值。
整数回绕:
1 | |
wrapping_add 的结果是 -9223372036854775808,即 i64::MIN。解释器正常输出这个值,不报错。
短路求值避免错误:
1 | |
false && divide_by_zero() 中,左操作数是 false,短路规则使 divide_by_zero() 不被调用,除零不发生。程序正常输出 0。把 false 换成 true,解释器就会进入右侧函数,触发除零错误。
参考解释器的定位
解释器不是编译器的替代品。它执行速度远不如本机代码,不做任何优化,功能上也不处理链接、目标文件、调试信息。它的价值在于提供一份独立的、可读的语义定义。
从第 08 篇开始,编译器会生成可执行程序。到那时,每个测试用例都会同时用解释器执行和用编译产物执行,比较 stdout 和退出状态。如果两者不一致,就存在 bug——最可能出在降低、优化或代码生成中,因为解释器直接操作 Typed HIR,跳过了这些阶段。这种方法叫差分测试(differential testing),将在第 21 篇系统展开。
需要注意的是,解释器和编译器共享词法分析、语法分析和类型检查。共享前端中的 bug 不会被差分测试发现,因为两条路径在分叉之前就已经走错了。前端的正确性仍然依赖手工编写的正例和反例。
练习
- 手算
1 + 9223372036854775807(即1 + i64::MAX)的补码回绕结果。写出 64 位二进制加法过程,验证结果等于i64::MIN。然后用解释器确认。 - 构造一个程序,使
true || side_effect()中的side_effect不被执行,证明短路规则生效。再构造一个使它被执行的版本。 - 写一个嵌套算术表达式,手动按从左到右、递归下降的顺序推导每一步的
Val,和解释器输出比对。
上一篇:06 - 类型检查与 Typed HIR。下一篇:08 - 函数调用、递归和返回值。
