前 18 篇的所有程序都住在一个文件里。泛型函数、结构体、枚举、引用计数——全部挤在同一份 .spr 源码中。程序再大一点,这种组织方式就不可维护了。

本篇把编译单元从单文件扩展到多文件:每个 .spr 文件是一个模块,模块之间通过 import 声明交换类型签名,编译器分别产出目标文件,最终由系统链接器合并成一个可执行程序。

模块就是文件

Sprout 采用最直接的模块方案:一个 .spr 文件就是一个模块,模块名从文件路径导出。math.spr 的模块名是 mathutil/sort.spr 的模块名是 util_sort。不存在与文件脱离的命名空间声明。

在模块顶部用 import 引入依赖:

1
2
3
4
5
6
import math;

fn main() -> i64 {
print_i64(math.gcd(48, 18));
return 0;
}

import math 使 math 模块中所有标记为 pub 的函数名在当前模块可见。被导入模块的私有函数不可访问——编译器在名称解析阶段就会报错,不需要等到链接。

可见性:默认私有

函数声明不加修饰时只对本模块可见:

1
2
3
4
5
6
7
8
9
10
// math.spr

pub fn gcd(a: i64, b: i64) -> i64 {
if b == 0 { return a; }
return gcd(b, a % b);
}

fn helper(x: i64) -> i64 { // 私有,外部不可见
return x + 1;
}

pub fn 把函数放入模块的公开签名集合。编译 math.spr 时,编译器导出 gcd 的签名供其他模块类型检查使用,而 helper 只在 math.spr 内部存在。

这与 Rust 的默认私有策略一致。在教学语言里,可见性只作用于函数;结构体和枚举的导出规则留给后续扩展。

导入图与循环检测

编译开始前,编译器扫描所有参与编译的源文件,收集每个模块的 import 列表,构建依赖有向图。

1
2
3
main.spr ──import──▶ math.spr
main.spr ──import──▶ util.spr
util.spr ──import──▶ math.spr

图中不允许出现环。如果 math.spr 同时 import util,而 util.sprimport math,编译器必须报错并给出环路路径,而不是无限递归地尝试解析签名。

检测方法是标准的拓扑排序:对模块依赖图做 DFS,遇到回边即报告循环。排序结果同时给出编译顺序——被依赖的模块先编译,依赖方后编译,保证类型检查时所需签名已就绪。

分离编译与类型检查

每个模块独立编译到一个 .o 文件。编译 main.spr 时,编译器只需要 mathutil 的公开签名,不需要它们的函数体。流程如下:

  1. 拓扑排序得到编译顺序:mathutilmain
  2. 编译 math.spr:类型检查、生成 IR、输出 math.o,同时缓存公开签名。
  3. 编译 util.spr:加载 math 的公开签名用于类型检查,生成 util.o
  4. 编译 main.spr:加载 mathutil 的签名,生成 main.o
  5. 调用系统链接器:cc math.o util.o main.o runtime.o -o program

每个模块的类型检查是完整的——调用外部函数时,参数类型和返回类型都通过导入签名验证。链接阶段只负责地址解析,不再做语义检查。

跨模块泛型是单独编译模型的主要挑战。Sprout 的策略是:泛型函数的 IR 以序列化形式嵌入模块的元数据段(类似 Rust 的 MIR 序列化)。当另一个模块需要单态化某个泛型函数时,从元数据中还原 IR 后在调用侧完成单态化。这意味着泛型函数的"编译"实际上推迟到了链接阶段——这是一种延迟单态化策略。

符号修饰

链接器在全局符号表中查找定义。如果 mathutil 都有一个内部函数叫 helper,两个 helper 符号就会冲突。

Sprout 用模块名和函数名的组合作为链接符号。修饰规则:

1
2
3
4
_Spr_{模块名长度}{模块名}_{函数名长度}{函数名}

math 模块的 gcd → _Spr_4math_3gcd
util 模块的 abs → _Spr_4util_3abs

泛型函数的单态化实例还需要附加类型参数。第 18 篇的 identity<i64>math 模块中变成:

1
_Spr_4math_8identity_I3i64

_Spr_ 前缀与 C 符号和系统库分隔,长度前缀让反修饰工具可以无歧义地恢复原始名称。这比 C++ 的 Itanium ABI 简单得多,但原理相同。

运行时 ABI

第 01–02 篇建立的入口包装仍然有效:源语言 fn main() -> i64 被编译为修饰后的 _Spr_4main_4main,链接时由一段 C 包装调用它,把 i64 返回值截断为进程退出码。

运行时提供的函数使用固定的 C ABI:

运行时函数 C 签名 作用
print_i64 void print_i64(int64_t) 打印整数到 stdout
spr_alloc void* spr_alloc(int64_t) 分配堆内存
spr_free void spr_free(void*) 释放堆内存
spr_rc_retain void spr_rc_retain(void*) 引用计数加一
spr_rc_release void spr_rc_release(void*) 引用计数减一,归零时释放

这些是 Sprout 程序与外部世界的唯一接口。编译器生成的代码只调用上述函数和用户自定义的 Sprout 函数。没有通用 FFI 机制可以调用任意 C 函数——这是有意为之。受控接口避免了类型系统被绕过、内存管理失去追踪的问题。

本篇处理三层约定中的前三层:语言内部函数调用约定(Sprout 函数之间),C 运行时接口(Sprout 到运行时),对象文件格式(.o 中的符号与重定位)。第四层——系统调用——留给运行时内部处理,用户代码不直接触及。

入口约定

多文件程序需要指定入口。Sprout 要求命令行指定的主模块中必须存在 pub fn main() -> i64。编译器检查这个签名存在且类型正确,然后在链接时将 C 包装的 main 指向它。

退出码只取返回值的低 8 位。sum_to(10) 的结果 55 可以通过 echo $? 观察到,但超过 255 的值会被截断。数值验证应通过 print_i64 输出,不应依赖退出码传递任意计算结果。

多文件测试

准备两个文件:

1
2
3
4
5
// math.spr
pub fn gcd(a: i64, b: i64) -> i64 {
if b == 0 { return a; }
return gcd(b, a % b);
}
1
2
3
4
5
6
7
// main.spr
import math;

fn main() -> i64 {
print_i64(math.gcd(48, 18));
return 0;
}

编译并运行:

1
2
3
sproutc build math.spr main.spr -o gcd_test
./gcd_test
# 输出:6

编译器按拓扑序处理 math.sprmain.spr,生成各自的 .o 文件,链接运行时,产出可执行文件。输出 6 是 48 和 18 的最大公约数。

错误案例

编译器必须在链接之前拦截以下错误:

导入不存在的模块:

1
import physics;  // 没有 physics.spr
1
2
error[E2001]: module 'physics' not found
--> main.spr:1:8

访问私有函数:

1
math.helper(5)  // helper 不是 pub
1
2
error[E2002]: function 'helper' is private in module 'math'
--> main.spr:4:5

循环导入:

1
error[E2003]: circular import detected: math → util → math

重复的公开符号: 如果两个模块导出同名的 pub fn,名称解析阶段不会冲突(它们在不同命名空间),但如果调用方同时导入两个模块并使用了歧义名称,编译器要求显式的模块前缀限定。

练习

  1. 把第 09 篇的 sum_to 程序拆成两个文件:calc.spr 导出 pub fn sum_tomain.spr 导入并调用它。写出编译命令,验证输出仍为 55
  2. calc.spr 增加一个私有辅助函数,在 main.spr 中尝试调用它,观察错误信息。
  3. 手动写出 sum_tocalc 模块中的修饰符号名,用 nm calc.o 验证。

上一篇:18 - 泛型如何变成具体代码。下一篇:20 - 让错误回到源程序