第 06 篇完成了类型检查,1 + 2 能通过检查,true + 1 被拒绝。但类型检查只回答"这个程序有没有类型错误",不回答"这个程序执行后结果是什么"。编译器目前还没有任何部分说 1 + 2 的结果是 3。

本篇在 Typed HIR 上直接建一个参考解释器。它递归遍历节点,维护变量环境,按确定的规则求值,把结果打印出来。这个解释器将成为整个系列的语义基准:后续编译器生成本机代码后,任何测试用例只要和解释器的输出不一致,就说明降低、优化或代码生成出了错。

值的表示

解释器执行 HIR 节点后产生值。当前语言只有三种类型,值的表示对应地简单:

1
2
3
4
5
enum Val {
I64(i64),
Bool(bool),
Unit,
}

I64 携带 64 位有符号整数,Bool 携带布尔值,Unit 对应没有有意义返回值的表达式(赋值、打印调用等)。后续加入数组和结构体时,这个枚举会扩展,但三种基础值从本篇开始就固定下来。

环境:栈帧与变量绑定

解释器需要知道每个变量当前的值。环境用一组栈帧表示,每个栈帧是一张从 DefIdVal 的映射。函数调用时压入新帧,函数返回时弹出。

1
2
3
4
5
6
7
┌─────────────────────┐  ← 栈顶:sum_to 的帧
│ n → I64(10) │
│ sum → I64(0) │
│ i → I64(1) │
├─────────────────────┤
│ main 的帧(当前无变量)│ ← 栈底
└─────────────────────┘

变量查找从当前帧开始。因为 Sprout 没有闭包,不存在跨帧捕获,每个变量一定在当前帧中。名称解析阶段已经把变量引用绑定到 DefId,解释器不需要重复做作用域查找,直接用 ID 索引即可。

求值规则

解释器对每种 HIR 节点递归求值,规则逐条列出。

字面量 直接转换:整数字面量变成 I64truefalse 变成 Bool

变量引用DefId 在当前帧中查找,返回绑定的值。

赋值 对右侧表达式求值,把结果写入当前帧对应的 DefId,返回 Unit

二元算术(加、减、乘)对左右操作数从左到右求值,取出两个 i64,用二进制补码回绕计算。实现上使用 wrapping_addwrapping_subwrapping_mul,而不是 Rust 默认的溢出 panic:

1
2
3
BinOp::Add => Val::I64(l.wrapping_add(r)),
BinOp::Sub => Val::I64(l.wrapping_sub(r)),
BinOp::Mul => Val::I64(l.wrapping_mul(r)),

这是语言语义的选择。Sprout 的整数加减乘按补码回绕,不触发运行时错误。

除法和取余 同样先从左到右求值,但有两个必须拦截的情况:

  1. 除数为零 → 运行时错误,报告源码位置。
  2. i64::MIN / -1 → 运行时错误。补码中最小负数除以 -1 的数学结果超出 i64 范围,既不回绕也不静默截断,直接报错。

两个条件都不触发时,执行 Rust 的普通整数除法(向零截断)。

比较运算<<=>>===!=)对两个 i64 求值后返回 Bool

短路逻辑 是本篇最值得注意的规则。a && b:先求值 a;如果结果是 false,整个表达式的值就是 falseb 根本不求值。a || b:先求值 a;如果是 true,整个表达式的值就是 trueb 不求值。

"不求值"意味着 b 中的一切副作用——函数调用、打印、除零——都不会发生。这不是优化,是语义规定。

函数调用 先从左到右对每个实参求值,然后压入新栈帧,把形参 DefId 绑定到实参值,执行函数体,弹出栈帧,返回结果。

return 立即终止当前函数体的执行,携带返回值。实现上可以用一个特殊的控制流信号(如 Rust 的 Result 或专用枚举)向上传播,在函数调用的入口处捕获。

求值顺序

操作数和函数实参一律从左到右求值。这意味着 f(a(), b())a() 先执行,b() 后执行。如果 a() 打印了 1b() 打印了 2,输出一定是 1 然后 2。这个顺序写入语言规范,解释器和编译器都必须遵守。

运行第一个程序

用第 03 篇的语法写一个最小示例:

1
2
3
4
fn main() -> i64 {
print_i64(1 + 2);
return 0;
}

解释器执行过程:进入 main,求值 1 + 2 得到 I64(3),调用内置的 print_i64 向 stdout 输出 3,返回 I64(0)

1
2
$ sproutc interpret programs/add.spr
3

错误情况与边界用例

除零

1
2
3
4
fn main() -> i64 {
print_i64(10 / 0);
return 0;
}

解释器输出运行时错误,附带源码范围,指向 / 运算符所在的位置。程序不会产生值。

整数回绕

1
2
3
4
5
fn main() -> i64 {
// i64::MAX = 9223372036854775807
print_i64(9223372036854775807 + 1);
return 0;
}

wrapping_add 的结果是 -9223372036854775808,即 i64::MIN。解释器正常输出这个值,不报错。

短路求值避免错误

1
2
3
4
5
6
7
8
9
10
fn divide_by_zero() -> bool {
let x: i64 = 1 / 0;
return true;
}

fn main() -> i64 {
let r: bool = false && divide_by_zero();
print_i64(0);
return 0;
}

false && divide_by_zero() 中,左操作数是 false,短路规则使 divide_by_zero() 不被调用,除零不发生。程序正常输出 0。把 false 换成 true,解释器就会进入右侧函数,触发除零错误。

参考解释器的定位

解释器不是编译器的替代品。它执行速度远不如本机代码,不做任何优化,功能上也不处理链接、目标文件、调试信息。它的价值在于提供一份独立的、可读的语义定义。

从第 08 篇开始,编译器会生成可执行程序。到那时,每个测试用例都会同时用解释器执行和用编译产物执行,比较 stdout 和退出状态。如果两者不一致,就存在 bug——最可能出在降低、优化或代码生成中,因为解释器直接操作 Typed HIR,跳过了这些阶段。这种方法叫差分测试(differential testing),将在第 21 篇系统展开。

需要注意的是,解释器和编译器共享词法分析、语法分析和类型检查。共享前端中的 bug 不会被差分测试发现,因为两条路径在分叉之前就已经走错了。前端的正确性仍然依赖手工编写的正例和反例。

练习

  1. 手算 1 + 9223372036854775807(即 1 + i64::MAX)的补码回绕结果。写出 64 位二进制加法过程,验证结果等于 i64::MIN。然后用解释器确认。
  2. 构造一个程序,使 true || side_effect() 中的 side_effect 不被执行,证明短路规则生效。再构造一个使它被执行的版本。
  3. 写一个嵌套算术表达式,手动按从左到右、递归下降的顺序推导每一步的 Val,和解释器输出比对。

上一篇:06 - 类型检查与 Typed HIR。下一篇:08 - 函数调用、递归和返回值