常见错误与练习
本节阅读量:可变状态最容易出错的地方,不是忘记写一条 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:区分写入结果与当前位置
先不要运行,分析:
|
|
依次回答:
set!返回什么?- 写入后 cell 中保存什么?
- 加法是否需要再次读取
x? - 整个程序结果是多少?
然后验证:
|
|
检查点:结果应为 42;IR 中 store 后直接出现 40 + 2,不需要为了得到 set! 的结果再生成 load。
练习 2:手算二元 begin 的顺序
阅读 begin_order.lang:
|
|
画出 cell 内容的变化:
|
|
运行:
|
|
在 IR 中标出两个 store 和最后一个 load。说明为什么交换两个 begin 子表达式会改变结果。
练习 3:同名变量不等于同一个位置
预测:
|
|
运行:
|
|
找出外层和内层两个不同的 .cell 名字,并回答:
set!选择哪一个 cell?- 内层
let结束后,外层 cell 保存什么? - 为什么结果是
42而不是4?
这个练习的重点是:词法查找选择 Location,store 只修改选中的 Location。
练习 4:参数修改不会改写调用者绑定
先运行本章示例:
|
|
在函数 IR 中找出:
|
|
再把下面程序保存为 parameter_copy.lang:
|
|
分别验证:
|
|
检查点:结果仍是 41。调用时只把 x 当前的 Value 传给函数,函数随后为参数创建新的 cell;它不是把调用者的 cell 当作参数传入。
练习 5:用 Environment 与 Store 追踪 closure
分析:
|
|
在纸上为每个阶段填写表格:
| 阶段 | Environment 中 x |
Store 中该 Location 的 Value | closure 保存什么 |
|---|---|---|---|
let x 后 |
尚未创建 | ||
创建 read 后 |
|||
set! x 后 |
|||
调用 read 时 |
再运行:
|
|
检查点:Environment 中 Location 不变,Store 中的 Value 从 40 变为 42,closure 始终保存那个 Location。
练习 6:只写不读也必须捕获
不用运行,先写出 lambda 的自由变量列表:
|
|
然后执行:
|
|
检查以下对应关系:
|
|
正确的自由变量列表是只含 x 的列表。再查看 free_vars.cpp 中 ExprKind::set 分支,说明为什么应先把 target 加入结果,再遍历 RHS。
练习 7:两个 closure 共享同一个 cell
运行:
|
|
画出下面的对象关系:
|
|
在 IR 中确认两个 closure 创建操作的 fields 都使用同一个创建位置 cell。然后回答:
put的store发生在哪个函数体?read的load使用哪个 capture local?- 两个函数体里的 capture local 名字为什么不同,却仍能表示同一个对象?
- 如果 closure 捕获的是整数
40,程序会得到什么错误行为?
练习 8:返回的 counter 为什么还能工作
阅读 counter.lang:
|
|
分别写出外层和内层 lambda 的自由变量。然后运行:
|
|
在输出中找出:
|
|
再运行:
|
|
两个结果都应为 42。对 independent_counters.lang,画出两个不同的 start cell,说明为什么一个 counter 的写入不会影响另一个。
练习 9:手写一段 cell IR
为下面程序手写结构化 IR dump:
|
|
要求包含:
|
|
保存为 manual.lang 后运行:
|
|
比较临时变量编号以外的操作顺序。检查点:store 的 RHS 必须是加法结果;最后的变量 x 必须再生成一次 load。
练习 10:callee 检查不能越过副作用
把下面程序保存为 order.lang:
|
|
先预测解释器会报告什么,再运行:
|
|
IR 的关键顺序应是:
|
|
回答:
- 为什么
check-closure必须排在store前? - 为什么不能等到发射最后的
call时才检查? - 解释器在哪个时刻检查 callee?
- 生成代码遇到错误时应返回什么状态?
最后一个答案应是 70。
练习 11:追踪可变的递归名字
运行:
|
|
程序先把原 closure 保存到 old,再把递归名字 loop 改成一个总是返回 42 的新 closure,最后调用 old 1。
在 IR 中依次找出:
|
|
解释为什么最后一次 load 得到新 closure,所以程序结果是 42。这也说明第八章不能继续只用隐藏 callee Value 表示递归 self。
再检查同名遮蔽:
|
|
指出不同的 self-cell 和参数 cell,并确认函数体读取参数 cell。
练习 12:计算 cell 与 closure 布局
先填写 cell:
| 项目 | 值 |
|---|---|
| heap-object pointer tag | |
| header kind | |
| payload count | |
| header | |
| object size | |
| current Value offset |
使用公式:
|
|
检查点: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。
生成汇编核对:
|
|
在汇编中找出传给 malloc 的大小、offset 0 的 header、offset 8 的 code pointer,以及从 offset 16 开始的每个 tagged cell Value。
练习 13:守住对象种类边界
依次运行:
|
|
解释为什么前两个结果分别是 1 和 0,后两个程序报错。回答时必须区分:
|
|
cell 没有源语言 constructor 或 predicate,普通变量读取会先 load,所以用户程序不能直接把 cell 交给 record?、size 或 get。
把两个错误程序编译并手动链接,确认退出状态都是 70。
练习 14(进阶选做):只给需要的绑定装箱
标准快照统一为每个 let 和参数创建 cell。若想减少分配,可以尝试设计 assigned-variable analysis,但先不要直接删除 cell op。
第一步,回答这些问题:
- 如何区分外层
x和被内层let遮蔽的另一个x? set!应标记源码名字,还是词法解析后的唯一 binding?- 未修改但被 closure 捕获的变量能否继续按 Value 捕获?
- 被修改且被 closure 捕获的变量为什么必须保留 cell?
- 参数在递归调用中如何获得彼此独立的存储?
- 可变
letrec的 self 是否允许取消装箱?
一个可行的方向是先给每个 binding 分配唯一 ID,再收集哪些 ID 会成为 set! target。lowering 根据分析结果选择:
|
|
这会让 Env、变量读取和 closure capture 都出现两种表示,因此必须显式记录 binding 的表示种类,不能靠名字是否带 .cell 猜测。
若真正实现,请在代码副本中进行,并用本章全部示例回归。标准章节代码仍保持统一装箱,不把这项优化作为完成条件。
练习 15:完整回归
选择这些正常程序:
|
|
把 <name> 替换为文件名中 .lang 前的部分,依次执行:
|
|
Apple Silicon Mac 的链接命令改为:
|
|
状态主线示例应得到 42;record_get_third.lang 得到 30。short_branch.lang 必须返回 42,未选择 else 中的非法 call 不能执行。
再检查错误程序:
|
|
set_undefined_error.lang 应在解释器和 lowering 阶段报告未绑定变量。其余程序的解释器应给出具体诊断;编译、链接后的程序应统一返回状态 70。
这组回归同时覆盖 mutation 语义、Environment/Store、自由变量、结构化 IR、callee 顺序、统一对象协议、递归 self cell,以及前七章 record 和 closure 能力的连续性。