到第 07 篇为止,所有 Sprout 程序都只有一个 main 函数。表达式、变量、类型检查和参考解释器都工作正常,但程序无法把逻辑拆分成多个独立部分。本篇加入函数声明、函数调用、递归和一个内置的运行时打印函数,让 Sprout 开始像一门真正的编程语言。

签名收集:先扫描,再检查

函数之间可以互相调用,调用可能发生在被调用函数的定义之前。如果类型检查器从上到下逐个处理函数体,遇到一个尚未见过的函数名就只能报错。这不是我们想要的行为。

解决办法是分两趟处理。第一趟只扫描所有 fn 声明的签名——函数名、参数列表和返回类型——把它们记录到一张全局签名表里。第二趟才进入每个函数体做类型检查。这时签名表已经完整,任何函数都可以调用任何其他函数,包括自身。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 第一趟:收集签名
for decl in &program.functions {
let sig = Signature {
params: decl.params.iter().map(|p| (p.name, p.ty)).collect(),
return_ty: decl.return_ty,
};
signatures.insert(decl.name, sig);
}

// 第二趟:检查函数体
for decl in &program.functions {
check_function_body(decl, &signatures)?;
}

如果两个函数同名,第一趟就报重复定义。签名收集只读取声明头部,不解析函数体内的表达式,所以它很快,也不会被函数体中的错误干扰。

函数体的类型检查

每个函数拥有独立的作用域帧。参数在进入函数体之前就被预先声明到这个作用域里,类型来自签名。函数体中的 let 声明和第 05 篇一样进入作用域,遮蔽规则不变。

返回类型检查要求函数体的每条执行路径都有 return 语句,并且返回的表达式类型必须与签名声明的返回类型一致。如果某条路径缺少 return,类型检查器拒绝这个程序:

1
2
error[E0401]: function 'abs' may not return a value on all paths
--> example.spr:3:1

本篇的检查是保守的——它要求所有路径显式返回。第 09 篇引入 if/else 后会细化可达性分析。

调用表达式

调用表达式的形式是 f(a, b, c)。类型检查器在签名表中查找函数名 f,检查实参数目是否匹配形参数目,逐个检查实参类型是否与对应形参类型兼容。调用表达式的结果类型就是被调用函数的返回类型。

实参数目不匹配时,诊断信息直接指出期望和实际数目:

1
2
3
4
5
error[E0402]: function 'add' expects 2 arguments, found 3
--> example.spr:7:5
|
7 | add(1, 2, 3)
| ^^^^^^^^^^^^ expected 2 arguments

递归

签名收集使递归成为自然结果。当类型检查器处理 factorial 的函数体时,factorial 自身的签名已经在表中,调用自身和调用其他函数走同一条路径。

1
2
3
4
5
6
7
8
9
10
11
fn factorial(n: i64) -> i64 {
if n <= 1 {
return 1;
}
return n * factorial(n - 1);
}

fn main() -> i64 {
print_i64(factorial(5));
return 0;
}

执行结果:输出 120。前向调用(调用源码中后面才定义的函数)同样不需要特殊处理。

运行时函数:print_i64

print_i64(n: i64) 是内置函数。它的签名在签名收集阶段被硬编码注入,不需要源码中存在对应的 fn 声明。实现在 C 运行时包装中——一个简单的 printf("%lld\n", n)

编译器在 LLVM IR 中为它生成 declare 而非 define

1
declare void @print_i64(i64)

链接时,C 运行时提供的目标文件包含 print_i64 的定义。这样 Sprout 程序可以在不引入格式化字符串和可变参数的情况下把整数打印到标准输出。

代码生成:每个函数一个 LLVM 函数

每个 Sprout 函数生成一个 LLVM 函数定义。参数直接成为 LLVM 函数参数;局部变量使用 alloca 在栈上分配空间。这是临时方案——第 10–12 篇构建自制 SSA 后,局部变量将由 SSA 值和 phi 节点表达,alloca 只留给真正需要地址的情况。

1
2
3
4
5
6
define i64 @factorial(i64 %n) {
entry:
%n.addr = alloca i64
store i64 %n, ptr %n.addr
; ... 函数体 ...
}

调用生成 LLVM call 指令,返回生成 ret 指令:

1
2
%result = call i64 @factorial(i64 %dec)
ret i64 %val

类型检查器已经保证每条路径都有 return,所以代码生成不需要担心 LLVM 基本块缺少终结指令。

三个验收案例

递归阶乘factorial(5) 输出 120。调用链为 main → factorial(5) → factorial(4) → … → factorial(1),回溯时逐层乘回来。

循环求和sum_to(10) 输出 55。这个函数用 while 循环累加(第 09 篇完成控制流后可运行),不依赖递归。

互递归奇偶判断is_even 调用 is_oddis_odd 调用 is_even。两个函数的签名在第一趟收集后互相可见,不需要前向声明语法。is_even(4) 经过四次互相调用返回 1

两种错误

实参数目不匹配是最常见的调用错误。add(1, 2, 3)add 只接受两个参数时被拒绝,诊断精确到调用位置。

返回路径缺失更微妙。如果一个函数在 if 分支里返回了值,但 else 分支或函数末尾没有 return,类型检查器必须拒绝。放过这种程序会导致 LLVM IR 中出现没有终结指令的基本块,后果是未定义行为。类型检查器在前端就堵住这条路。

练习与资料

  1. 实现互递归的 is_even(n)is_odd(n)is_even(0) 返回 1is_even(n) 调用 is_odd(n-1)is_odd(0) 返回 0is_odd(n) 调用 is_even(n-1)。手动画出 is_even(3) 的完整调用栈。
  2. 故意把 factorial 的基线条件删掉,观察程序行为。思考:编译器能检测到无限递归吗?应该检测吗?

LLVM 函数定义与调用指令见 LangRef: Functionscall instruction。签名收集的两趟策略在多数编译器教材中称为"前向声明"或"多遍处理"。

上一篇:07 - 用参考解释器固定语言语义。下一篇:09 - 条件、循环和可执行程序