从零编写现代编译器 17 - 结构体、枚举与模式匹配
数组解决了同类元素的连续存储,但真实程序需要把不同类型的数据捆绑在一起——坐标由 x 和 y 两个整数组成,几何图形可能是圆也可能是矩形。本篇为 Sprout 加入结构体和枚举两种复合类型,再用模式匹配安全地拆开枚举值。类型检查器同时获得字段访问验证和穷尽性检查能力。
结构体:命名字段的值类型
声明一个 Point:
1 | |
结构体是值类型。把 Point 赋给另一个变量,得到一份完整的副本。字段按声明顺序排列在内存中:
1 | |
每个字段的偏移等于前面所有字段大小之和。i64 宽度 8 字节,Point 总大小 16 字节。如果以后引入更小的类型,偏移计算还需要加入对齐填充——按字段自然对齐向上取整,结构体末尾可能出现尾部填充以满足整体对齐。本篇只涉及 i64 和 bool 字段,暂时不会碰到填充。
构造和访问:
1 | |
类型检查器在遇到 p.x 时查找 Point 的字段列表,确认 x 存在且类型为 i64。访问不存在的字段名直接报类型错误。
代码生成:LLVM struct 与 GEP
结构体翻译成 LLVM 的 struct 类型:
1 | |
字段访问用 getelementptr(GEP)取地址,再用 load 读值:
1 | |
GEP 的第一个索引选择整个结构体实例(本系列的值类型通常为 0),第二个索引选择字段。GEP 本身只计算地址,不访问内存。
枚举:带标签的联合体
1 | |
枚举的每个变体(variant)可以携带不同数量和类型的数据。内存布局采用标签加联合体:
1 | |
tag 用 i64 存储变体编号。payload 大小取所有变体中最大的那个——Rect 携带两个 i64,占 16 字节,所以整个 Shape 为 8 + 16 = 24 字节。Circle 只用 payload 的前 8 字节,后 8 字节是未使用的空间。
构造枚举值时,先写 tag,再写对应变体的字段。翻译成 LLVM IR 时,payload 部分用字节数组表示,读取时按变体类型做 bitcast 和 GEP。
模式匹配与穷尽性检查
拆开枚举值的唯一方式是 match:
1 | |
编译器对 match 做两件事。第一,穷尽性检查:收集枚举的所有变体名,逐一比对 match 臂覆盖的变体。如果 Rect 分支被漏掉,编译器拒绝程序并报告缺少的变体名。第二,每条 match 臂的绑定变量数量必须与变体声明一致——Circle 声明了一个 i64,match 臂就必须绑定恰好一个变量。
穷尽性检查的实现很直接:把枚举定义中的变体名集合与 match 臂覆盖的变体名集合做差集。差集非空则报错,列出未覆盖的变体。本系列不支持通配符分支或嵌套模式,因此不需要更复杂的 pattern matrix 算法。
代码生成:tag 检查加分支
match 翻译成一组 tag 比较和条件分支:
1 | |
match_unreachable 理论上不可达——类型检查已经保证值只能是已知变体。但翻译阶段仍然生成这个块并插入 unreachable 指令,让 LLVM 的验证器不会因为缺少终结指令而报错。
带引用计数字段的结构体
第 16 篇为字符串引入了引用计数。如果结构体包含字符串字段:
1 | |
那么复制结构体时,必须对 name 字段调用 retain。丢弃结构体时,必须对 name 调用 release。整数字段 value 不需要任何操作。
编译器在代码生成阶段遍历结构体的字段列表。对每个引用计数类型的字段,在复制路径插入 retain,在销毁路径插入 release。这个遍历是递归的:如果结构体 A 包含结构体 B,而 B 又包含字符串,那么复制 A 时需要递归地复制 B,最终触达 B 内部的字符串字段。
赋值语义与第 16 篇一致:先 retain 新值的引用计数字段,再 release 旧值的引用计数字段,最后覆盖内存。提前返回和作用域退出时,当前作用域内的所有活跃结构体变量都必须执行销毁路径。
枚举的销毁更复杂。运行时必须先读取 tag,确定当前是哪个变体,再按该变体的字段列表执行 release。match 表达式本身不会销毁枚举——绑定变量只是对 payload 的读取。枚举值的生命周期仍由所在作用域控制。
类型检查扩展
本篇新增三类错误:
| 错误 | 触发条件 | 示例 |
|---|---|---|
| 字段不存在 | 访问结构体中未定义的字段名 | p.z,而 Point 只有 x 和 y |
| 穷尽性失败 | match 未覆盖所有变体 | 漏掉 Rect 分支 |
| 变体字段数不匹配 | match 臂绑定的变量数与变体声明不一致 | Circle(r, s) 绑定了两个变量,但 Circle 只有一个 |
这三类错误都在类型检查阶段捕获,不会进入代码生成。
练习与资料
-
设计一个单链表节点
struct Node { value: i64, next: ??? }。next的类型应该是什么?为什么 Sprout 主线禁止递归堆类型?提示:如果Node包含Node,编译器在计算大小时会遇到什么?如果next是堆上的指针且 A 指向 B、B 指向 A,引用计数能否回收它们? -
为
Shape增加一个Triangle(i64, i64, i64)变体,手动计算新的枚举大小,并写出更新后的 match 表达式。验证旧的双分支 match 被穷尽性检查拒绝。
LLVM LangRef 的 GEP 指令详细说明了多级索引的含义。Rust 枚举的内存布局展示了工业级语言如何处理标签联合体。穷尽性检查的经典方法见 Maranget 2008 的 pattern matrix 算法;本系列采用的简化版只需要集合差运算。
上一篇:16 - 字符串和引用计数。下一篇:18 - 泛型如何变成具体代码。
