章节目录

第六章:record 与堆上的数据

本节阅读量:

前五章的程序已经能计算整数、做条件选择,也能创建和调用函数。但一个值仍然很难携带“一组数据”:想让函数同时返回三个结果,或者把若干相关值作为一个整体传递,目前都做不到。

这一章加入不可变 record:

1
2
3
(record)
(record 40 2)
(record 10 20 30)

record 可以包含任意数量的字段,包括零个字段。字段本身仍是完整的语言值,因此可以继续放整数、函数或另一个 record:

1
(record 0 (record 40 2) (lambda x (+ x 1)))

我们同时加入三种观察 record 的操作:

1
2
3
(record? value)
(size value)
(get value index)

它们分别回答:一个值是不是 record、record 有多少个字段,以及指定位置保存了什么。

先运行几个程序

进入本章代码目录并构建:

1
2
cd code/06_records
make

创建并显示一个 record:

1
./mini run examples/record.lang

输出是:

1
(record 40 2)

读取字段:

1
./mini run examples/record_get.lang

输出是 2。再试嵌套对象:

1
./mini run examples/nested_record_get.lang

输出是 40。

这里出现了本书此前没有处理过的运行时问题:record 的字段数量由程序决定,整个对象不一定能放进一个 64 位机器字。解释器可以使用 C++ 对象表示它;编译器生成的程序则需要在堆上为它申请空间。

本章建立的语言接口

本章新增以下表达式:

1
2
3
4
(record expr ...)
(record? expr)
(size expr)
(get expr nonnegative-index)

例如:

1
2
3
4
5
(record? (record))
(record? 42)
(size (record))
(size (record 10 20 30))
(get (record 10 20 30) 2)

它们依次得到 1、0、0、3 和 30。

get 的 index 是源码中的非负整数字面量,不是运行时表达式。(get r 2) 是合法语法;执行时只有当 r 确实是 record,并且至少有三个字段,读取才会成功。

这使接口保持了清楚的分工:

  • parser 确认 index 是非负整数字面量;
  • 解释器和生成代码确认对象种类;
  • 解释器和生成代码根据对象的实际长度检查边界。

从一个机器字走向堆对象

第五章可以把整数或函数引用放进一个 64 位机器字。本章把所有值统一成 tagged value:

1
2
3
integer     8 * n + 1
function    aligned code address | 3
heap object aligned object address | 4

record 值仍然只占一个机器字,但这个机器字保存的是带 tag 的堆地址。真正的数据位于堆上:

1
2
3
4
5
offset 0       header
offset 8       field 0
offset 16      field 1
...
offset 8 * n   field n - 1

header 同时记录对象种类与 payload 数量:

1
header = (payload_count << 8) | object_kind

对于 record,payload_count 就是字段数。于是:

1
2
(record)          header = (0 << 8) | 1 = 1
(record 40 2)     header = (2 << 8) | 1 = 513

总分配大小也不再固定:

1
object_size = 8 * (1 + payload_count)

空 record 仍有一个 header,因此需要 8 字节;二字段 record 需要 24 字节;三字段 record 需要 32 字节。

这套布局是本章真正要建立的基础。第七章会在同一套“header + payload”协议上实现 closure,但不会把 closure 假装成源语言 record。

AST、IR 与汇编仍然逐层负责

同一个程序会依次经过:

1
2
3
4
source
  -> AST
  -> structured IrProgram
  -> x86-64 assembly

以字段读取为例:

1
(get (record 40 2) 1)

AST 保留 record 的字段子树和字面量 index;IR 明确表达“创建对象”和“读取字段”;汇编阶段才决定 tag、header、malloc、偏移与失败出口。compile 不会绕过 IR 直接递归 AST。

可以直接观察前两层:

1
2
./mini ast examples/record_get.lang
./mini ir examples/record_get.lang

编译、链接和观察结果

生成汇编:

1
./mini compile examples/record_get.lang -o out.s

compile 只输出 x86-64 System V ABI 的 AT&T 汇编,不调用 cc,也不负责链接。Linux、WSL 和 Intel Mac 可以继续手动执行:

1
2
3
cc out.s -o out
./out
echo $?

Apple Silicon Mac 需要明确链接 x86-64 目标:

1
2
3
cc -arch x86_64 out.s -o out
./out
echo $?

record_get.lang 最终得到整数 2,因此退出状态是 2。顶层若直接返回 record,main 仍返回 0;要观察整个 record 的文本形式,应使用 mini run。

本章为编译后的 size 与 get 加入 heap-object tag 和 record header-kind 检查,并为 get 加入边界检查。检查失败时,生成程序以状态 70 结束。它不打印解释器式错误文字;这一小步的重点是让错误程序安全停止,而不是建立完整异常系统。

本章路线

我们按以下顺序推进:

  1. 先定清可变长度、空 record、求值顺序、身份相等和错误语义。
  2. 设计统一 tagged value、通用 header 和可变大小布局。
  3. 用 std::vector<std::unique_ptr<Expr>> 表示数量不定的 AST 子树。
  4. 让解释器创建共享的 RecordValue,实现 predicate、长度与安全读取。
  5. 在结构化 IR 中加入 record 相关 op,保持字段从左到右 lowering。
  6. 生成 malloc、header、payload 循环、tag 检查和边界检查。
  7. 最后明确本章的错误、内存管理和后续章节边界。

范围说明

源语言新增的是 record,不是 vector、struct、class 或对象系统。正文出现的 C++ std::vector 只是编译器和解释器保存“不定数量元素”的实现容器;读者写的程序里没有 vector API。

record 在本章中不可变:创建后只能观察,不能更新。生成代码使用 malloc,暂时不回收堆对象;垃圾回收留到后面的专门章节。


5.9 练习

上一节

6.1 语言:一组值,也可以成为一个值

下一节