章节目录

小结:从一个 Value 到一张对象图

本节阅读量:

第六章增加的不只是 (record ...) 语法。我们第一次让生成程序按照源码决定对象大小,在堆上创建对象,并让一个 64 位 tagged Value 引用它。

现在语言可以表达:

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

record 不可变、长度可变、允许为空,并按对象身份参与 eq?。

语言语义已经闭合

四种操作的规则是:

表达式 行为
(record e...) 字段从左到右求值,每次创建独立对象
(record? e) 对任意 Value 返回整数 1 或 0
(size e) record 返回字段数,其他值报错
(get e i) record 且 i < size 时返回字段,否则报错

index i 是非负整数字面量。parser 接受 (get r 2) 和 (get r 01),拒绝负数、变量与表达式 index;实际越界由运行时根据对象 header 判断。

空 record 没有特殊例外:

1
2
3
(size (record))
(record? (record))
(eq? (record) (record))

结果依次是 0、1、0。它是真正分配的零字段对象,所以两次创建具有不同身份。

AST、解释器和 IR 各有一种 vector

可变字段数量需要 C++ 容器,但三个阶段保存的内容不同:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
AST
    vector<unique_ptr<Expr>>
    尚未求值的字段子树

interpreter
    vector<Value>
    已经求值的字段值

IR
    vector<Operand>
    已经 lowering 的简单 operand

这三种 std::vector 都是实现细节。源语言没有增加 vector、struct、class 或方法系统。

统一机器表示分成 tag 与 header

一个机器字先区分大类:

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

heap-object tag 4 不专属于 record。对象自己的 header 再记录:

1
header = (payload_count << 8) | heap_kind

本章 HeapKind::record = 1。因此 record 的布局是:

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

通用公式:

1
2
size(n)          = 8 * (1 + n)
payload_offset(i) = 8 * (1 + i)

(record) 分配 8 字节并写 header 1;(record 40 2) 分配 24 字节并写 header 513;这些都是同一公式的实例,不再是固定 record 布局。

解释器与编译器保持同一行为

解释器使用 shared_ptr<RecordValue> 表达共享身份,用 vector<Value> 保存字段。生成代码使用带 tag 的堆地址和 payload 机器字。

两条路线都实现:

  • 字段从左到右求值;
  • 每次 record 表达式独立创建,包括空 record;
  • 字段可保存任意已有 Value;
  • record 在条件中为真;
  • eq? 比较身份,不比较结构;
  • record? 同时识别空和非空 record;
  • size/get 检查对象种类;
  • get 在读取前检查实际边界。

解释器在错误时给出文字诊断;编译后的 size/get 失败调用 exit(70)。不同的错误呈现方式没有改变成功程序与失败条件的语义。

编译路径没有绕过 IR

两条命令共享同一 lowering:

1
2
mini ir       AST -> IrProgram -> dump
mini compile  AST -> IrProgram -> assembly

record 相关 IR 形状是:

1
2
3
4
t.0 = record 10 20 30
t.1 = record? t.0
t.2 = size t.0
t.3 = get t.0 2

汇编 emitter 根据字段 operand 数量计算 malloc 字节数和 header,并发出有序 payload stores。

安全读取依赖正确的检查顺序

record?:

1
2
3
4
检查 heap tag
  -> 是 heap object 才读取 header
  -> 检查 HeapKind::record
  -> 返回 tagged 1/0

size/get:

1
2
3
4
5
6
检查 heap tag
  -> 清 tag
  -> 读取并检查 header kind
  -> 提取 payload count
  -> get 再检查 index < count
  -> 最后访问字段

任何时候都不能先把普通整数当地址解引用,也不能先越界 load 再检查。

可以运行的检查

构建并运行核心示例:

1
2
3
4
5
6
7
cd code/06_records
make
./mini run examples/record.lang
./mini run examples/record_get.lang
./mini run examples/nested_record_get.lang
./mini run examples/record_alias.lang
./mini run examples/record_identity.lang

观察前端和 IR:

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

生成并手动链接:

1
2
3
4
./mini compile examples/record_get.lang -o out.s
cc out.s -o out
./out
echo $?

Apple Silicon Mac 的链接命令使用 cc -arch x86_64。record_get.lang 的最终整数是 2,退出状态应为 2。

完成本章后的自检

读者应当能够解释:

  • 为什么空 record 仍要分配 header;
  • 为什么 std::vector 没有成为源语言特性;
  • 为什么字段求值必须先于 record op;
  • 为什么 record Value 保存引用而不是内联全部 payload;
  • 为什么低位 4 只能说明 heap object;
  • record? 为什么还要检查 header kind;
  • size 为什么要把裸 count 重新编码成 tagged integer;
  • get 为什么必须在 load 前检查 index < count;
  • 为什么两个内容相同的 record 可能不相等;
  • 为什么嵌套 record 是对象图,而不是一块扁平结构。

为第七章留下的接口

record 已经建立通用的 heap object 协议:

1
2
3
4
5
heap-object tag
header(kind, payload_count)
variable-size allocation
payload offsets
tagged Values in payload

第七章实现 closure 时可以复用这套协议,但 closure 仍是不同 kind:它的 payload 包含代码入口和捕获值,调用语义也不同。下一章真正的新困难应集中在自由变量、捕获环境和调用,而不必重新发明堆对象的基本布局。

第六章至此完成了从“值都能放进一个机器字”到“一个机器字可以安全引用整张对象图”的跨越。


6.7 边界:这套 record 提供什么,不提供什么

上一节

6.9 练习:把对象布局真正画出来

下一节