数组解决了同类元素的连续存储,但真实程序需要把不同类型的数据捆绑在一起——坐标由 x 和 y 两个整数组成,几何图形可能是圆也可能是矩形。本篇为 Sprout 加入结构体和枚举两种复合类型,再用模式匹配安全地拆开枚举值。类型检查器同时获得字段访问验证和穷尽性检查能力。

结构体:命名字段的值类型

声明一个 Point

1
struct Point { x: i64, y: i64 }

结构体是值类型。把 Point 赋给另一个变量,得到一份完整的副本。字段按声明顺序排列在内存中:

1
2
3
4
偏移 0      8      16
┌──────┬──────┐
│ x │ y │ Point:16 字节
└──────┴──────┘

每个字段的偏移等于前面所有字段大小之和。i64 宽度 8 字节,Point 总大小 16 字节。如果以后引入更小的类型,偏移计算还需要加入对齐填充——按字段自然对齐向上取整,结构体末尾可能出现尾部填充以满足整体对齐。本篇只涉及 i64bool 字段,暂时不会碰到填充。

构造和访问:

1
2
let p: Point = Point { x: 3, y: 4 };
print_i64(p.x); // 输出 3

类型检查器在遇到 p.x 时查找 Point 的字段列表,确认 x 存在且类型为 i64。访问不存在的字段名直接报类型错误。

代码生成:LLVM struct 与 GEP

结构体翻译成 LLVM 的 struct 类型:

1
%Point = type { i64, i64 }

字段访问用 getelementptr(GEP)取地址,再用 load 读值:

1
2
%px = getelementptr %Point, ptr %p, i32 0, i32 0
%vx = load i64, ptr %px

GEP 的第一个索引选择整个结构体实例(本系列的值类型通常为 0),第二个索引选择字段。GEP 本身只计算地址,不访问内存。

枚举:带标签的联合体

1
2
3
4
enum Shape {
Circle(i64),
Rect(i64, i64),
}

枚举的每个变体(variant)可以携带不同数量和类型的数据。内存布局采用标签加联合体:

1
2
3
4
5
6
偏移 0      8      16     24
┌──────┬──────┬──────┐
│ tag │ ---- payload ----│
└──────┴──────┴──────┘
tag=0: Circle → payload 使用 [0..8) 存 r
tag=1: Rect → payload 使用 [0..16) 存 w, h

tag 用 i64 存储变体编号。payload 大小取所有变体中最大的那个——Rect 携带两个 i64,占 16 字节,所以整个 Shape 为 8 + 16 = 24 字节。Circle 只用 payload 的前 8 字节,后 8 字节是未使用的空间。

构造枚举值时,先写 tag,再写对应变体的字段。翻译成 LLVM IR 时,payload 部分用字节数组表示,读取时按变体类型做 bitcast 和 GEP。

模式匹配与穷尽性检查

拆开枚举值的唯一方式是 match

1
2
3
4
5
6
fn area(s: Shape) -> i64 {
match s {
Circle(r) => r * r * 3,
Rect(w, h) => w * h,
}
}

编译器对 match 做两件事。第一,穷尽性检查:收集枚举的所有变体名,逐一比对 match 臂覆盖的变体。如果 Rect 分支被漏掉,编译器拒绝程序并报告缺少的变体名。第二,每条 match 臂的绑定变量数量必须与变体声明一致——Circle 声明了一个 i64,match 臂就必须绑定恰好一个变量。

穷尽性检查的实现很直接:把枚举定义中的变体名集合与 match 臂覆盖的变体名集合做差集。差集非空则报错,列出未覆盖的变体。本系列不支持通配符分支或嵌套模式,因此不需要更复杂的 pattern matrix 算法。

代码生成:tag 检查加分支

match 翻译成一组 tag 比较和条件分支:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  %tag = load i64, ptr %s
%is_circle = icmp eq i64 %tag, 0
br i1 %is_circle, label %arm_circle, label %check_rect

check_rect:
%is_rect = icmp eq i64 %tag, 1
br i1 %is_rect, label %arm_rect, label %match_unreachable

arm_circle:
; GEP 到 payload,load r
...

arm_rect:
; GEP 到 payload,load w 和 h
...

match_unreachable 理论上不可达——类型检查已经保证值只能是已知变体。但翻译阶段仍然生成这个块并插入 unreachable 指令,让 LLVM 的验证器不会因为缺少终结指令而报错。

带引用计数字段的结构体

第 16 篇为字符串引入了引用计数。如果结构体包含字符串字段:

1
struct Named { name: string, value: i64 }

那么复制结构体时,必须对 name 字段调用 retain。丢弃结构体时,必须对 name 调用 release。整数字段 value 不需要任何操作。

编译器在代码生成阶段遍历结构体的字段列表。对每个引用计数类型的字段,在复制路径插入 retain,在销毁路径插入 release。这个遍历是递归的:如果结构体 A 包含结构体 B,而 B 又包含字符串,那么复制 A 时需要递归地复制 B,最终触达 B 内部的字符串字段。

赋值语义与第 16 篇一致:先 retain 新值的引用计数字段,再 release 旧值的引用计数字段,最后覆盖内存。提前返回和作用域退出时,当前作用域内的所有活跃结构体变量都必须执行销毁路径。

枚举的销毁更复杂。运行时必须先读取 tag,确定当前是哪个变体,再按该变体的字段列表执行 release。match 表达式本身不会销毁枚举——绑定变量只是对 payload 的读取。枚举值的生命周期仍由所在作用域控制。

类型检查扩展

本篇新增三类错误:

错误 触发条件 示例
字段不存在 访问结构体中未定义的字段名 p.z,而 Point 只有 xy
穷尽性失败 match 未覆盖所有变体 漏掉 Rect 分支
变体字段数不匹配 match 臂绑定的变量数与变体声明不一致 Circle(r, s) 绑定了两个变量,但 Circle 只有一个

这三类错误都在类型检查阶段捕获,不会进入代码生成。

练习与资料

  1. 设计一个单链表节点 struct Node { value: i64, next: ??? }next 的类型应该是什么?为什么 Sprout 主线禁止递归堆类型?提示:如果 Node 包含 Node,编译器在计算大小时会遇到什么?如果 next 是堆上的指针且 A 指向 B、B 指向 A,引用计数能否回收它们?

  2. Shape 增加一个 Triangle(i64, i64, i64) 变体,手动计算新的枚举大小,并写出更新后的 match 表达式。验证旧的双分支 match 被穷尽性检查拒绝。

LLVM LangRef 的 GEP 指令详细说明了多级索引的含义。Rust 枚举的内存布局展示了工业级语言如何处理标签联合体。穷尽性检查的经典方法见 Maranget 2008 的 pattern matrix 算法;本系列采用的简化版只需要集合差运算。

上一篇:16 - 字符串和引用计数。下一篇:18 - 泛型如何变成具体代码