从零编写现代编译器 18 - 泛型如何变成具体代码
第 17 篇给 Sprout 加上了结构体和枚举。字段类型在定义时写死:一个存放 i64 对的结构体和一个存放 bool 对的结构体需要分别声明,尽管它们的布局逻辑完全一样。泛型允许把类型当作参数,写一次定义,给不同类型复用。本篇实现显式类型参数和单态化(monomorphization)——编译器在编译期为每组具体类型生成一份专用代码,运行时不存在泛型。
泛型函数与泛型结构体
Sprout 的泛型语法把类型参数放在尖括号里。函数签名可以带一个或多个类型参数:
1 | |
调用时用 turbofish 语法显式指定类型:
1 | |
结构体同理:
1 | |
使用时写出全部类型参数:Pair<i64, bool>。Sprout 主线不做类型参数推断,每个泛型使用处都必须给出完整类型参数列表。这让编译器实现保持简单,也让读者在阅读源码时不必猜测被推断出的类型。
单态化:一份定义变成多份代码
编译器看到 identity::<i64>(42) 时,需要一个参数为 i64、返回 i64 的具体函数。看到 identity::<bool>(true) 时,又需要另一个参数为 bool、返回 bool 的版本。单态化的做法是:遍历程序中所有泛型调用点,收集实际使用的类型参数组合,为每个组合生成一份完整的具体函数。
1 | |
生成的函数和手写的没有区别。类型参数被替换成具体类型后,函数体里的每条语句都可以走正常的类型检查和代码生成流程。泛型定义本身不出现在最终输出中——它是一个模板,用完即弃。
对泛型结构体也是一样。Pair<i64, bool> 生成一个 first 为 i64、second 为 bool 的具体结构体,布局计算与第 17 篇的规则完全相同。Pair<bool, bool> 生成另一个,两个字段都是 bool,大小和对齐可能与前者不同。
实例缓存
同一个泛型定义加上同一组类型参数,只需要生成一次。如果程序中有十处调用 identity::<i64>,编译器不应该产出十份相同的函数。
实现方式是维护一个缓存,键为 (泛型定义 ID, 类型参数列表)。第一次遇到某个组合时,执行类型替换并生成具体定义,把结果存入缓存。后续遇到相同组合时,直接查表返回已有的定义。
1 | |
这个缓存还有第二个作用:它记录了程序实际使用的全部泛型实例,代码生成阶段只需要遍历缓存就能输出所有需要的具体函数和结构体。
泛型体的类型检查
泛型函数体在定义时就进行类型检查,不是等到实例化才检查。类型参数 T 被当作一个抽象类型:编译器知道它是某种类型,但不知道具体是什么。在这个抽象类型上能做的事非常有限——赋值、作为参数传递给其他泛型函数、作为泛型结构体的字段。不能对它做算术、比较或字段访问,因为编译器无法确认所有可能的具体类型都支持这些操作。
1 | |
这意味着实例化时不需要重新检查函数体。类型替换是机械的:把所有 T 换成 i64,结果一定类型正确,因为 i64 可以做的事是 T 能做的事的超集。
Sprout 主线不实现 trait bounds。没有 trait 约束的泛型只能做结构性操作——复制、传递、存储。这限制了泛型的表达力,但避免了引入整个 trait 解析机制。
递归实例增长
考虑这个函数:
1 | |
调用 explode::<i64>(0) 时,编译器需要生成 explode::<i64>。这个函数内部调用了 explode::<Pair<i64, i64>>,于是需要生成这个实例。而这个实例又调用 explode::<Pair<Pair<i64, i64>, Pair<i64, i64>>>……类型参数每展开一层就翻倍,实例数量无限增长。
编译器必须检测这种情况。实现方式是在单态化过程中维护一个递归深度计数器。每次因为实例化某个泛型而触发了新的实例化请求,深度加一。超过阈值(例如 64 层)时,编译器报错并终止:
1 | |
深度限制同时保护了编译时间和产物大小。没有这个限制,编译器会在类型膨胀中耗尽内存。Rust 编译器也有类似的限制(默认 128 层),超过时给出类似的诊断。
代码生成
单态化完成后,实例缓存中的每个条目都是一个普通的、类型完全确定的定义。代码生成阶段把它们当作手写函数和结构体处理,走第 14 篇建立的 SSA 到 LLVM IR 翻译路径。
泛型定义本身不生成任何代码。如果一个泛型函数在整个程序中从未被使用,它不会出现在编译产物里。这和 C++ 模板的行为一致:未实例化的模板不产生目标代码。
当泛型函数被单态化时,如果具体类型是堆分配类型(字符串或数组),单态化后的函数需要包含正确的 retain/release 调用。具体做法是:在泛型 IR 中标记哪些操作需要 drop glue(析构逻辑),单态化时根据具体类型决定是否生成引用计数操作。对于 i64 和 bool 等栈类型,drop glue 为空操作。
输出中的函数名需要区分不同实例。一种方案是把类型参数编码进符号名:identity 加上 i64 变成 identity$i64,Pair<i64, bool> 变成 Pair$i64$bool。具体的修饰规则要与第 19 篇的符号修饰方案统一。
练习与资料
- 手动追踪
Pair<i64, Pair<bool, i64>>的单态化过程。列出编译器需要生成的所有具体类型。答案:需要两个具体结构体——Pair<bool, i64>和Pair<i64, Pair<bool, i64>>。内层先实例化,外层引用它。 - 设计一个测试:同一个泛型函数被三种不同类型参数调用,验证实例缓存产出恰好三份具体函数,且同类型参数的重复调用不产生额外实例。
- 把单态化深度限制设为 4,用
explode函数验证编译器在第 5 层报错。修改限制值,观察报错位置的变化。
Rust 的单态化策略见 Rust Reference 的 Monomorphization 章节。C++ 模板实例化的递归深度限制见各编译器的 -ftemplate-depth 选项文档。两者的核心思路相同:编译期展开,运行时无泛型开销。
上一篇:17 - 结构体、枚举与模式匹配。下一篇:19 - 多文件模块、ABI 与链接。
