章节目录

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

本节阅读量:

这些练习按语言语义、AST、解释器、IR、机器布局和安全检查逐步推进。建议先在纸上写出答案,再保存为 .lang 文件运行;只看最终退出状态,很容易跳过本章真正重要的对象模型。

可以统一在本章目录操作:

1
2
cd code/06_records
make

练习 1:预测可变长度 record

先不要运行,写出以下表达式的解释器输出:

1
(record)
1
(record 10 20 30)
1
(size (record))
1
(size (record 10 20 30))
1
(get (record 10 20 30) 2)
1
(record? (get (record 0 (record)) 1))

预期依次是:

1
2
3
4
5
6
(record)
(record 10 20 30)
0
3
30
1

检查自己有没有把空 record 当成整数 0,或者把 size 得到的字段数从 1 开始计算。

练习 2:区分合法 index 与合法访问

判断哪些程序会在 parser 阶段失败,哪些会形成 AST 后在运行时报错:

1
2
3
4
5
6
(get (record 10 20) 2)
(get (record 10 20) 01)
(get (record 10 20) -0)
(get (record 10 20) -1)
(get (record 10 20) index)
(get (record 10 20) (+ 1 1))

答案要点:

  • index 2 是合法非负字面量,但对 (record 10 20) 越界;
  • 01 合法并转换为 index 1,结果是 20;
  • -0、-1、变量和表达式 index 都被 parser 拒绝。

解释为什么 parser 不能仅根据 (get ... 2) 判定越界:对象表达式可能是变量、函数调用或条件,它的实际长度要到运行时才知道。

练习 3:观察四种 AST

分别保存并运行 mini ast:

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

预期核心 dump 是:

1
2
3
4
5
Record()
Record(Int(10), Record(), Int(30))
RecordPredicate(Int(42))
RecordSize(Record(Int(10)))
Get(Record(Int(10), Int(20), Int(30)), 2)

回答:

  1. 为什么 RecordExpr 需要 vector<unique_ptr<Expr>>?
  2. 为什么 GetExpr::index 是 std::size_t,而不是 unique_ptr<Expr>?
  3. AST 中的空 vector 与运行时空 record 有什么区别?

练习 4:确认左到右求值与“全部成功后分配”

观察:

1
2
3
(record
    (get (record) 0)
    unknown)

解释器应先报告 record index 越界,而不是 undefined variable: unknown。这说明第二个字段没有在第一个字段之前求值。

再阅读 ExprKind::record 的解释器分支,指出:

  • 哪一行决定字段求值顺序;
  • 哪一行才真正创建 RecordValue;
  • 为什么某个字段失败后不会留下可观察的半成品 record。

练习 5:空对象也有身份

预测:

1
(eq? (record) (record))
1
2
(let empty (record)
  (eq? empty empty))
1
2
3
(let empty (record)
  (let box (record empty empty)
    (eq? (get box 0) (get box 1))))

预期依次为 0、1、1。

画出第三段程序中的对象图。应有一个空 record 对象和一个含两个字段的外层对象;外层两个 payload 都保存同一个 tagged heap pointer,而不是复制两个空对象。

练习 6:手算 header、大小和偏移

填写表格:

表达式 payload count header 分配字节数
(record) ? ? ?
(record 10) ? ? ?
(record 10 20 30) ? ? ?
(record 1 2 3 4 5) ? ? ?

使用:

1
2
header = (count << 8) | 1
size   = 8 * (1 + count)

答案是:

表达式 payload count header 分配字节数
(record) 0 1 8
(record 10) 1 257 16
(record 10 20 30) 3 769 32
(record 1 2 3 4 5) 5 1281 48

再写出五字段对象的 field 0、field 2、field 4 偏移。使用:

1
offset(i) = 8 * (1 + i)

结果应是 8、24、40。

练习 7:不要混淆三个 vector

把下面三种 C++ 类型分别填到正确阶段,并说明元素代表什么:

1
2
3
vector<unique_ptr<Expr>>
vector<Value>
vector<Operand>

然后回答:源程序能否写 (vector 1 2 3)?能否为 record 声明字段名?能否在 record 创建后 push_back?

三个答案都是否定的。C++ vector 只解决“实现中如何保存数量不定的有序元素”,没有扩大源语言表面能力。

练习 8:手写嵌套 record 的 IR

对下面程序先手写 IR:

1
2
(let r (record 10 (record) 30)
  (+ (size r) (get r 2)))

按照当前临时值编号,应得到:

1
2
3
4
5
6
7
t.0 = record
t.1 = record 10 t.0 30
r0 = t.1
t.2 = size r0
t.3 = get r0 2
t.4 = t.2 + t.3
return t.4

最终结果是 33。注意内层空 record op 必须先于外层 record op;即使它没有字段,也不能省略分配。

把程序保存后运行:

1
2
./mini ir examples/exercise.lang
./mini run examples/exercise.lang

练习 9:追踪三字段 record 汇编

编译:

1
(get (record 10 20 30) 2)

在汇编中找出:

1
2
3
4
5
6
7
8
malloc 参数 32
header 769
三个 payload store
heap-object tag 4
header kind 检查
payload count 右移
index 2 的边界比较
带比例索引的字段 load

解释三个字段为什么分别保存 81、161、241,而 header 中保存的 count 却是裸 3。

最后指出字段地址如何由:

1
8(%rax,%rdx,8)

表示 raw_address + 8 + index * 8。

练习 10:为什么 record? 检查两层

把 record? 的汇编控制流画成:

1
2
3
4
5
6
input
  -> low tag == 4 ?
       no  -> tagged 0
       yes -> header kind == record ?
                no  -> tagged 0
                yes -> tagged 1

回答两个问题:

  1. 为什么收到整数时不能直接清 tag 后读取 header?
  2. 当前只有 record 一种 heap object,为什么仍要检查 header kind?

第二问的关键是:低位 4 被定义为通用 heap-object tag。下一章加入 closure 后,closure 也可能通过第一层检查,但不应让 (record? closure) 得到 1。

练习 11:验证编译后的失败状态

分别准备三个程序:

1
(size 42)
1
(get 42 0)
1
(get (record 10) 1)

对每个程序生成、链接并运行:

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

Apple Silicon Mac 把链接命令改为:

1
cc -arch x86_64 out.s -o out

三者退出状态都应为 70。再分别使用 mini run,确认解释器能区分 size 类型错误、get 类型错误和 get 越界。

解释为什么公共错误标签调用 exit(70),而不是简单令 %rax = 70 后 retq:错误可能发生在生成函数内部,普通返回会继续执行调用者。

练习 12:用 record 编码一条小链

本章没有专门的 list、cons、nil、car 或 cdr,但可变长度 record 已足以编码一个简单链:

1
2
空链         (record)
非空节点     (record value rest)

补全并运行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(letrec sum xs
  (if (eq? (size xs) 0)
      0
      (+ (get xs 0)
         (sum (get xs 1))))
  (sum
    (record 10
      (record 20
        (record 12
          (record))))))

结果应为 42。

这道题展示的是 record 的组合能力,不是要求把源语言改造成完整 Lisp 列表系统。请画出四个独立堆对象,并标出每个非空节点的 field 1 如何指向下一个对象。

完成标准

完成练习后,你应能不看代码回答:

  • (record) 为什么既有 size 0 又必须分配 8 字节;
  • 为什么 (get r 2) 可能成功,也可能越界;
  • 为什么 01 合法而 -0 不合法;
  • 三个 C++ vector 分别保存什么;
  • header 中 kind 与 count 怎样拆分;
  • heap-object tag 与 record kind 为什么不是一回事;
  • size 如何把裸 count 变成 tagged integer;
  • get 的检查为什么必须先于 load;
  • 两个内容相同的 record 为什么可能不相等;
  • 可变长度布局怎样为下一章 closure 减少重复工作。

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

上一节

7.0 让函数带着环境离开

下一节