第 08 篇让编译器支持了函数定义和调用,递归与前向引用都已经工作。但每个函数体仍然是一条直线——从第一条语句顺序执行到最后一条,中间没有任何分支或重复。写不出 if,写不出 while,程序能做的事极其有限。

本篇加入条件分支和循环,让 Sprout 成为一门图灵完备的语言。完成后,sum_to(10) 将编译成本机程序并输出 55。

if/else 进入 AST 和 HIR

if 的语法结构包含三部分:条件表达式、then 分支、可选的 else 分支。条件必须是 bool 类型,类型检查器直接拒绝整数条件。

1
2
3
4
5
6
7
fn abs_diff(a: i64, b: i64) -> i64 {
if a > b {
return a - b;
} else {
return b - a;
}
}

AST 节点记录条件、then 块和 else 块。降低到 Typed HIR 时,条件必须为 Bool,两个分支按语句处理。当前 if 只作为语句;将来作为表达式时,两个分支的类型必须一致。

错误示例:while 42 { ... } 触发类型错误——条件期望 Bool,收到 i64。类型检查统一拦截,不合格的程序不进入代码生成。

while 循环

while 由条件和循环体组成。每次迭代先检查条件,为真执行循环体,为假退出。条件一开始就为假时,循环体一次也不执行。

1
2
3
4
while i <= n {
sum = sum + i;
i = i + 1;
}

零次执行是必须覆盖的边界:while false { print_i64(999); } 不产生任何输出。把条件检查错放在循环体之后,这个测试立刻暴露问题。

LLVM IR 中的控制流

LLVM IR 是 SSA 形式,控制流通过基本块和跳转指令表达。

if/else 生成三个基本块:thenelsemerge。入口块用 br i1 %cond, label %then, label %else 做条件跳转,两个分支完成后无条件跳到 merge。没有 else 时,条件为假直接跳 merge

while 同样三个块:loop.headerloop.bodyloop.exit。header 计算条件,为真跳 body,为假跳 exit。body 完成后跳回 header,形成回边。

1
2
3
4
5
6
7
8
9
10
11
12
13
entry:
br label %loop.header

loop.header:
%cond = icmp sle i64 %i.load, %n
br i1 %cond, label %loop.body, label %loop.exit

loop.body:
; sum = sum + i; i = i + 1;
br label %loop.header

loop.exit:
; 继续执行

这个结构保证条件检查在循环体之前,零次迭代自然正确。

临时方案:alloca + load/store

循环体内 sumi 在每次迭代中被修改。真正的 SSA 需要 phi 节点来合并回边带来的不同值,但我们还没有构建自己的 SSA。

本篇采用临时方案:每个局部变量用 alloca 在栈上分配一个槽位,读变量生成 load,写变量生成 store。这样每个变量只有一个地址,不需要 phi 节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
%sum.addr = alloca i64
store i64 0, ptr %sum.addr
%i.addr = alloca i64
store i64 1, ptr %i.addr

loop.header:
%i.load = load i64, ptr %i.addr
%cond = icmp sle i64 %i.load, %n
br i1 %cond, label %loop.body, label %loop.exit

loop.body:
%sum.cur = load i64, ptr %sum.addr
%i.cur = load i64, ptr %i.addr
%sum.new = add i64 %sum.cur, %i.cur
store i64 %sum.new, ptr %sum.addr
%i.new = add i64 %i.cur, 1
store i64 %i.new, ptr %i.addr
br label %loop.header

代价是 LLVM 的 SSA 优化无法直接看穿内存操作。LLVM 的 mem2reg pass 能把简单 alloca 提升回寄存器,但不应长期依赖它。第 10–12 篇将构建自己的 CFG 和 SSA,用 phi 节点替换这些 alloca。

提前返回

return 可以出现在循环内部。sum_to 的写法把 return 放在循环之后,但另一种写法可能在循环中间提前返回:

1
2
3
4
5
6
7
8
9
10
fn find_first_ge(threshold: i64) -> i64 {
let mut i: i64 = 0;
while i < 1000 {
if i >= threshold {
return i;
}
i = i + 1;
}
return 1000;
}

在 LLVM IR 中,return 生成一条到函数返回块的无条件跳转,返回块执行 retreturn 之后当前基本块已终结,不能再追加指令。代码生成器遇到 return 后必须开始新的基本块。

里程碑:sum_to(10) = 55

以下是本篇的标志性程序,完整经过所有编译阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn sum_to(n: i64) -> i64 {
let mut sum: i64 = 0;
let mut i: i64 = 1;
while i <= n {
sum = sum + i;
i = i + 1;
}
return sum;
}

fn main() -> i64 {
print_i64(sum_to(10));
return 0;
}

Token 流(节选):fn sum_to ( n : i64 ) -> i64 { let mut sumwhile i <= n {

AST 中 while 节点持有条件子树 i <= n 和循环体块。HIR 确认 i <= n 结果为 Bool。LLVM IR 中 sum_to 包含 entry、loop.header、loop.body、loop.exit 四个基本块。编译为本机程序:

1
2
3
cargo run --locked -- build programs/sum.spr -o target/sum
./target/sum
55

参考解释器执行同一程序同样输出 55。两条路径结果一致,是差分测试的基础。

测试覆盖

零次循环while false { print_i64(999); } 编译运行后无输出,解释器同样无输出。

提前返回find_first_ge(5) 在循环第六次迭代时命中 return,返回 5。编译结果和解释器一致。

类型错误while 42 { ... } 被类型检查拒绝,报告条件期望 Bool 却收到 i64,附源码位置。

练习与资料

  1. whileif 实现欧几里得 GCD 算法。对 gcd(48, 18) 验证解释器和编译器都输出 6。
  2. 在 LLVM IR 输出中找到 sum_to 的回边(从 loop.body 跳回 loop.header),解释把它改成跳到 loop.exit 会发生什么。
  3. while 的条件检查移到循环体之后(变成 do-while 语义),用 while false { print_i64(999); } 验证行为差异。

上一篇:08 - 函数调用、递归和返回值。下一篇:10 - 基本块、跳转与 CFG