章节目录

常见错误与练习

本节阅读量:

可变状态最容易出错的地方,不是忘记写一条 movq,而是把“名字、位置和值”重新混在一起。下面先用常见错误定位不变量,再通过练习从源语言语义一路检查到 IR、对象布局和原生程序。

常见错误:set! 创建新 cell

set! 应写入已有 Location。若每次赋值都创建新 cell,已经捕获旧 cell 的 closure 永远看不到变化。只有 let、函数参数和 letrec 初始化会引入新 Location。

常见错误:closure 捕获一次 load 的结果

第七章捕获 Value,第八章捕获 cell。若 closure 创建时先读取 x 再保存结果,它只能得到创建时的快照;外层后来执行的 set! x 不会被看见。

常见错误:自由变量分析忽略 set! target

在 (lambda value (set! x value)) 中,x 即使从未作为普通变量表达式出现,也仍然是自由变量。漏掉它会让解释器和 lowering 在函数体中找不到目标 Location。

常见错误:变量读取直接返回 cell

cell 是内部位置,不是源语言 Value。每个普通变量表达式都必须生成 load。否则加法、函数调用和 record 字段会收到 cell 引用,而不是 cell 当前保存的整数、closure 或 record。

常见错误:所有调用共用一个参数 cell

函数参数在每次调用时都获得新 Location。把参数 cell 放进 closure 对象并反复复用,会让递归调用和嵌套调用互相覆盖参数。

常见错误:把递归 self 继续当作不可变 closure

第七章的隐藏 callee self 不足以表达可变递归绑定。第八章的 letrec 必须先创建 self cell,让 closure 的 capture 0 保存它,再把 closure 写回该 cell。

常见错误:让 self 遮蔽同名参数

函数环境必须按 self cell、普通 captures、参数 cell 的顺序建立。查找从后向前进行,参数因此最后生效。顺序反过来会让 (letrec f f f (f 42)) 返回 closure,而不是参数 42。

常见错误:在 argument 之后检查 callee

call 的顺序必须是:求 callee、确认 closure、求 argument、调用。argument 现在可能执行 set!;把检查放在后面会让本应立即失败的程序先修改状态。

常见错误:给 cell 发明新的 pointer tag

record、closure 和 cell 共用 low tag 4,通过 header kind 1、2、3 区分。另设 cell pointer tag 会破坏第六章开始建立的统一堆对象协议,也会让后续 GC 多出不必要的表示分支。

常见错误:过早进行选择性装箱

“只给被修改的变量创建 cell”听起来简单,却会立刻引入绑定身份分析、两套变量读取路径和两种 closure capture。主线标准快照先统一装箱,保证语义正确;选择性装箱只作为后面的进阶选做。

练习 1:区分写入结果与当前位置

先不要运行,分析:

1
2
(let x 0
  (+ (set! x 40) 2))

依次回答:

  1. set! 返回什么?
  2. 写入后 cell 中保存什么?
  3. 加法是否需要再次读取 x?
  4. 整个程序结果是多少?

然后验证:

1
2
3
4
cd code/08_state
make
./mini run examples/set_result.lang
./mini ir examples/set_result.lang

检查点:结果应为 42;IR 中 store 后直接出现 40 + 2,不需要为了得到 set! 的结果再生成 load。

练习 2:手算二元 begin 的顺序

阅读 begin_order.lang:

1
2
3
4
5
6
(let x 0
  (begin
    (set! x 40)
    (begin
      (set! x (+ x 2))
      x)))

画出 cell 内容的变化:

1
2
3
4
创建后       ______
第一次写入后 ______
第二次写入后 ______
最后读取     ______

运行:

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

在 IR 中标出两个 store 和最后一个 load。说明为什么交换两个 begin 子表达式会改变结果。

练习 3:同名变量不等于同一个位置

预测:

1
2
3
4
5
(let x 40
  (begin
    (let x 0
      (set! x 2))
    (+ x 2)))

运行:

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

找出外层和内层两个不同的 .cell 名字,并回答:

  1. set! 选择哪一个 cell?
  2. 内层 let 结束后,外层 cell 保存什么?
  3. 为什么结果是 42 而不是 4?

这个练习的重点是:词法查找选择 Location,store 只修改选中的 Location。

练习 4:参数修改不会改写调用者绑定

先运行本章示例:

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

在函数 IR 中找出:

1
2
3
4
函数签名里的原始参数 local
函数第一条 cell op
参数读取对应的 load
参数修改对应的 store

再把下面程序保存为 parameter_copy.lang:

1
2
3
4
(let x 41
  (begin
    ((lambda x (set! x 0)) x)
    x))

分别验证:

1
2
./mini run parameter_copy.lang
./mini ir parameter_copy.lang

检查点:结果仍是 41。调用时只把 x 当前的 Value 传给函数,函数随后为参数创建新的 cell;它不是把调用者的 cell 当作参数传入。

练习 5:用 Environment 与 Store 追踪 closure

分析:

1
2
3
4
5
(let x 40
  (let read (lambda ignored x)
    (begin
      (set! x 42)
      (read 0))))

在纸上为每个阶段填写表格:

阶段 Environment 中 x Store 中该 Location 的 Value closure 保存什么
let x 后 尚未创建
创建 read 后
set! x 后
调用 read 时

再运行:

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

检查点:Environment 中 Location 不变,Store 中的 Value 从 40 变为 42,closure 始终保存那个 Location。

练习 6:只写不读也必须捕获

不用运行,先写出 lambda 的自由变量列表:

1
2
3
(let x 0
  (let put (lambda value (set! x value))
    (begin (put 42) x)))

然后执行:

1
2
3
./mini ast examples/write_only_capture.lang
./mini ir examples/write_only_capture.lang
./mini run examples/write_only_capture.lang

检查以下对应关系:

1
2
3
4
5
源码 set! target x
创建位置的 x cell
closure field 0
函数 capture local
store 的 lhs

正确的自由变量列表是只含 x 的列表。再查看 free_vars.cpp 中 ExprKind::set 分支,说明为什么应先把 target 加入结果,再遍历 RHS。

练习 7:两个 closure 共享同一个 cell

运行:

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

画出下面的对象关系:

1
2
3
put closure  ─┐
              ├─> x cell -> current Value
read closure ─┘

在 IR 中确认两个 closure 创建操作的 fields 都使用同一个创建位置 cell。然后回答:

  1. put 的 store 发生在哪个函数体?
  2. read 的 load 使用哪个 capture local?
  3. 两个函数体里的 capture local 名字为什么不同,却仍能表示同一个对象?
  4. 如果 closure 捕获的是整数 40,程序会得到什么错误行为?

练习 8:返回的 counter 为什么还能工作

阅读 counter.lang:

1
2
3
4
5
6
7
8
(let make
  (lambda start
    (lambda step
      (begin
        (set! start (+ start step))
        start)))
  (let counter (make 40)
    (begin (counter 1) (counter 1))))

分别写出外层和内层 lambda 的自由变量。然后运行:

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

在输出中找出:

1
2
3
4
start 的参数 cell
内层 closure 保存 start cell 的位置
内层函数体中会在每次调用时执行的 store
两次动态调用都使用的同一个 capture cell

再运行:

1
2
./mini run examples/returned_counter.lang
./mini run examples/independent_counters.lang

两个结果都应为 42。对 independent_counters.lang,画出两个不同的 start cell,说明为什么一个 counter 的写入不会影响另一个。

练习 9:手写一段 cell IR

为下面程序手写结构化 IR dump:

1
2
3
4
(let x 1
  (begin
    (set! x (+ x 1))
    x))

要求包含:

1
2
3
4
5
6
cell
第一次 load
add
store
最后一次 load
return

保存为 manual.lang 后运行:

1
./mini ir manual.lang

比较临时变量编号以外的操作顺序。检查点:store 的 RHS 必须是加法结果;最后的变量 x 必须再生成一次 load。

练习 10:callee 检查不能越过副作用

把下面程序保存为 order.lang:

1
2
(let x 0
  ((record) (set! x 1)))

先预测解释器会报告什么,再运行:

1
2
./mini run order.lang
./mini ir order.lang

IR 的关键顺序应是:

1
2
3
4
t.0 = record
check-closure t.0
store x0.cell 1
t.1 = call t.0 1

回答:

  1. 为什么 check-closure 必须排在 store 前?
  2. 为什么不能等到发射最后的 call 时才检查?
  3. 解释器在哪个时刻检查 callee?
  4. 生成代码遇到错误时应返回什么状态?

最后一个答案应是 70。

练习 11:追踪可变的递归名字

运行:

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

程序先把原 closure 保存到 old,再把递归名字 loop 改成一个总是返回 42 的新 closure,最后调用 old 1。

在 IR 中依次找出:

1
2
3
4
5
6
self cell 的创建
原 closure 的 capture 0
closure 回填 self cell 的 store
old cell 保存的原 closure Value
set! loop 对 self cell 的第二次 store
原函数体递归调用前对 self cell 的 load

解释为什么最后一次 load 得到新 closure,所以程序结果是 42。这也说明第八章不能继续只用隐藏 callee Value 表示递归 self。

再检查同名遮蔽:

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

指出不同的 self-cell 和参数 cell,并确认函数体读取参数 cell。

练习 12:计算 cell 与 closure 布局

先填写 cell:

项目 值
heap-object pointer tag
header kind
payload count
header
object size
current Value offset

使用公式:

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

检查点:cell 的答案依次包含 tag 4、kind 3、payload count 1、header 259、size 16 和 offset 8。

再计算三种 closure:

程序 capture count header size
无捕获普通 lambda 0
write_only_capture.lang 的 lambda 1
recursive_closure.lang 的递归 lambda 2

注意:closure 的 payload count 还包含一个 raw code pointer。第三行的两个 captures 是 self cell 与外层 base cell。

检查点:三行的 header/size 应依次为 258/16、514/24 和 770/32。

生成汇编核对:

1
2
./mini compile examples/write_only_capture.lang -o write.s
./mini compile examples/recursive_closure.lang -o recursive.s

在汇编中找出传给 malloc 的大小、offset 0 的 header、offset 8 的 code pointer,以及从 offset 16 开始的每个 tagged cell Value。

练习 13:守住对象种类边界

依次运行:

1
2
3
4
./mini run examples/record_predicate.lang
./mini run examples/record_predicate_closure.lang
./mini run examples/record_get_closure_error.lang
./mini run examples/record_size_closure_error.lang

解释为什么前两个结果分别是 1 和 0,后两个程序报错。回答时必须区分:

1
2
3
共同的 heap-object pointer tag
record、closure、cell 的不同 header kind
源语言 Value 与内部 cell 引用

cell 没有源语言 constructor 或 predicate,普通变量读取会先 load,所以用户程序不能直接把 cell 交给 record?、size 或 get。

把两个错误程序编译并手动链接,确认退出状态都是 70。

练习 14(进阶选做):只给需要的绑定装箱

标准快照统一为每个 let 和参数创建 cell。若想减少分配,可以尝试设计 assigned-variable analysis,但先不要直接删除 cell op。

第一步,回答这些问题:

  1. 如何区分外层 x 和被内层 let 遮蔽的另一个 x?
  2. set! 应标记源码名字,还是词法解析后的唯一 binding?
  3. 未修改但被 closure 捕获的变量能否继续按 Value 捕获?
  4. 被修改且被 closure 捕获的变量为什么必须保留 cell?
  5. 参数在递归调用中如何获得彼此独立的存储?
  6. 可变 letrec 的 self 是否允许取消装箱?

一个可行的方向是先给每个 binding 分配唯一 ID,再收集哪些 ID 会成为 set! target。lowering 根据分析结果选择:

1
2
不可变且不需要共享的 binding -> Value Operand
可变或需要共享位置的 binding -> cell Operand

这会让 Env、变量读取和 closure capture 都出现两种表示,因此必须显式记录 binding 的表示种类,不能靠名字是否带 .cell 猜测。

若真正实现,请在代码副本中进行,并用本章全部示例回归。标准章节代码仍保持统一装箱,不把这项优化作为完成条件。

练习 15:完整回归

选择这些正常程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
set.lang
set_result.lang
begin_order.lang
set_shadow.lang
parameter_set.lang
captured_read_after_set.lang
write_only_capture.lang
two_closures_share.lang
counter.lang
independent_counters.lang
recursive_binding_set.lang
self_param_shadow.lang
nested_closure.lang
record_get_third.lang
record_holds_closure.lang
short_branch.lang

把 <name> 替换为文件名中 .lang 前的部分,依次执行:

1
2
3
4
5
6
./mini run examples/<name>.lang
./mini ir examples/<name>.lang
./mini compile examples/<name>.lang -o <name>.s
cc <name>.s -o <name>
./<name>
echo $?

Apple Silicon Mac 的链接命令改为:

1
cc -arch x86_64 <name>.s -o <name>

状态主线示例应得到 42;record_get_third.lang 得到 30。short_branch.lang 必须返回 42,未选择 else 中的非法 call 不能执行。

再检查错误程序:

1
2
3
4
5
6
7
set_undefined_error.lang
call_integer_error.lang
call_record_error.lang
function_eq_error.lang
record_get_bounds_error.lang
record_get_closure_error.lang
record_size_closure_error.lang

set_undefined_error.lang 应在解释器和 lowering 阶段报告未绑定变量。其余程序的解释器应给出具体诊断;编译、链接后的程序应统一返回状态 70。

这组回归同时覆盖 mutation 语义、Environment/Store、自由变量、结构化 IR、callee 顺序、统一对象协议、递归 self cell,以及前七章 record 和 closure 能力的连续性。


8.6 边界与验收:第八章真正承诺什么

上一节

9.0 垃圾回收

下一节