章节目录

常见错误与练习

本节阅读量:

闭包的代码量并不大,真正容易出错的是同一条对应关系跨越了自由变量分析、IR、对象布局和调用约定。先用常见错误定位思路,再通过练习把这条链亲手走一遍。

常见错误:使用调用点环境

若 lexical_scope.lang 得到 102,说明函数体从调用位置的环境开始执行了。call 必须复制 closure 保存的定义环境,再加入 self 和参数。

常见错误:解释器复制整份环境

复制整份定义环境通常也能算对简单结果,却掩盖了自由变量分析是否正确,还会无谓保留不相关 Value。本章解释器和编译器都只保存 free_vars 选出的名字。

常见错误:遇到嵌套 lambda 就停止分析

外层函数可能需要把一个值继续传给内层 closure。若在嵌套 lambda 处停止,nested_closure.lang 的外层 closure 会漏掉 x。

常见错误:把 self 当作 capture 0

letrec 的 self 已经由隐藏的实际 callee %rdi 提供。把 self 再写进 payload 会改变所有普通 capture 偏移,还会制造不必要的对象自引用。

常见错误:沿用旧 closure 偏移

offset 0 是 header,offset 8 才是 code pointer,offset 16 才是 capture 0。若从 offset 8 读取 capture,得到的是机器代码地址。

常见错误:只检查通用 heap tag

record 和 closure 的 low tag 都是 4。call、size 和 get 必须继续检查 header kind,否则一种对象的 payload 会被另一种操作错误解释。

常见错误:把 %rdi 永久改成 raw pointer

%rdi 按约定携带 tagged closure Value。需要读 header 或 code 时,复制到 scratch register 再清 tag。递归 self 保存的也必须是原始 tagged Value。

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

结构化 IR 必须在 callee lowering 后立即插入 check-closure,然后才 lower argument。否则错误 callee 的程序可能先执行 argument 中的另一项错误。

常见错误:顺手定义 closure 相等

closure 使用堆地址表示,不代表 eq? 应比较地址。函数相等仍不属于语言语义;解释器报错,生成代码进入状态 70 的错误出口。

练习 1:手算自由变量

分别写出下面三个 lambda 的自由变量列表。

1
(lambda x (+ x 1))
1
2
(let x 40
  (lambda y (+ x y)))
1
2
3
(let x 10
  (let y 20
    (lambda z (+ x (+ y z)))))

检查点:参数不能进入列表;最后一个列表必须保持函数体中首次遇到的顺序。

练习 2:判断 let 的作用域边界

分析这个程序中 lambda 的自由变量:

1
2
3
4
5
(let y 40
  ((lambda x
     (let y y
       (+ x y)))
   2))

先分别回答:

  1. 内层 let 的 value 中,y 指向哪一个绑定?
  2. 内层 let 的 body 中,y 指向哪一个绑定?
  3. closure 是否需要捕获 y?
  4. 程序结果是多少?

把程序保存后分别用 ./mini ast、./mini ir 和 ./mini run 验证。

练习 3:解释嵌套传播

不用运行,分别写出外层和内层 lambda 的自由变量:

1
2
3
4
5
(let x 40
  (((lambda y
      (lambda z (+ x (+ y z))))
    1)
   1))

然后运行:

1
2
cd code/07_closures
./mini ir examples/nested_closure.lang

在输出中找出:

1
2
3
4
外层 closure 保存 x 的位置
内层 closure 创建时使用的 x local
内层 closure 创建时使用的 y local
内层 IrFunction 对应的两个 capture locals

最后说明:为什么外层函数自己没有直接做 + x ...,仍然必须捕获 x?

练习 4:稳定顺序和去重

预测这个函数的捕获列表:

1
2
3
(let a 10
  (let b 20
    ((lambda x (+ b (+ a (+ b x)))) 2)))

正确实现应保留首次出现顺序并去重。把源码保存为临时文件,运行 ./mini ir,确认 closure fields 与 IrFunction::captures 都采用同一顺序。

再思考:如果只把分析结果一端排序,而另一端不排序,机器执行时会发生什么?

练习 5:从源码名追到 IR local

运行:

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

画出以下对应关系:

1
2
3
4
5
源码 x
创建位置 local
closure field 0
函数 capture local
add operand

再对 lexical_scope.lang 重做一遍,找出值为 40 和值为 100 的两个 x 分别得到什么 IR 名字。说明 closure 为什么不会捕获后者。

练习 6:计算 closure header 和大小

不看生成汇编,填写表格:

capture count payload count header size
0
1
2
5

使用公式:

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

然后生成两个程序的汇编核对:

1
2
./mini compile examples/function_value.lang -o function_value.s
./mini compile examples/multiple_captures.lang -o multiple_captures.s

找出传给 malloc 的大小、写到 offset 0 的 header、offset 8 的 code pointer 和每个捕获槽。

练习 7:callee 检查必须早于 argument

考虑这个完整程序:

1
((record 0) (size (lambda x x)))

callee 是 record,argument 求值也会因 size 收到 closure 而失败。按本章求值顺序,解释器应该先报告哪一个错误?

把程序保存后运行:

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

IR 中应该先出现 callee 的 record op 和 check-closure;argument 产生的 closure op、size op 等全部排在检查之后。若任何 argument op 出现在 check-closure 前,lowering 就改变了语言求值顺序。

练习 8:逐条解释 call guard

分别编译 low-tag 错误和 header-kind 错误:

1
2
./mini compile examples/call_integer_error.lang -o call_integer.s
./mini compile examples/call_record_error.lang -o call_record.s

在两份汇编中找出:

1
2
3
4
low tag 检查
清 tag 的指令
header kind 检查
跳向 .Lruntime_error 的分支

回答两个问题:

  1. 为什么不能先清 tag 并读取 header,再检查 low tag?
  2. 为什么通过 low tag 4 以后仍不能直接执行 call *8(...)?

分别手动链接并运行,确认退出状态都是 70。说明整数在哪一层检查失败,record 又在哪一层检查失败。

练习 9:递归 self 不在 payload 中

查看:

1
2
./mini ir examples/recursive_closure.lang
./mini compile examples/recursive_closure.lang -o out.s

列出:

1
2
3
4
5
6
7
closure fields
IrFunction self
IrFunction captures
对象 header
对象 size
函数入口保存 %rdi 的栈槽
递归 call 使用的 callee local

验收答案中,closure 只能有 base 一个 capture,因此 header 是 514、size 是 24。若对象中还写入 self,说明实现仍沿用错误的自捕获设计。

练习 10:self 与参数同名

先判断结果:

1
2
3
(letrec f f
  f
  (f 42))

运行:

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

在 IR 中找出不同的 self local 和 param local。再到汇编中确认 %rdi、%rsi 被保存到不同栈槽,最终返回参数槽。

练习 11:检查对象种类边界

依次运行:

1
2
3
./mini run examples/closure_captures_record.lang
./mini run examples/record_holds_closure.lang
./mini run examples/record_predicate_closure.lang

解释为什么前两个程序都能得到 42,第三个却得到 0。回答时必须区分:

1
2
3
共同的 heap-object pointer tag
不同的 header kind
payload 中保存的完整 tagged Value

再分别编译 record_size_closure_error.lang 和 record_get_closure_error.lang,确认两者都在解引用错误 payload 前以状态 70 停止。

练习 12:函数相等不是地址相等

运行:

1
2
./mini run examples/function_eq_error.lang
./mini compile examples/function_eq_error.lang -o out.s

解释器应报告函数不能参与 eq?。手动链接生成的汇编后,程序应返回状态 70。

在汇编里找出 emit_equal 为两个 operand 生成的 closure kind 检查。说明为什么“两个 pointer 是否相同”不是本章语言已经定义的函数相等。

练习 13:自己写一个返回 closure 的程序

实现并运行:

1
2
3
4
(let make
  (lambda x
    (lambda y (record x y)))
  ((make 40) 2))

解释器应显示:

1
(record 40 2)

依次回答:

  1. 外层 lambda 的自由变量是什么?
  2. 内层 lambda 的自由变量是什么?
  3. 哪个 closure 是 16 字节,哪个是 24 字节?
  4. 内层 closure 为什么能在外层调用返回以后继续使用 x?
  5. 最终 record 的两个字段分别来自哪两个 Value?

练习 14:完整回归

选择这些正常程序:

1
2
3
4
5
6
7
8
9
closure.lang
lexical_scope.lang
multiple_captures.lang
nested_closure.lang
recursive_closure.lang
self_param_shadow.lang
closure_captures_record.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>

然后选择这些错误程序:

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

确认解释器给出具体诊断,生成程序统一返回 70。这组回归同时覆盖语义、自由变量、IR、对象布局、调用约定和第六章 record 连续性。


7.7 边界与验收:第七章真正承诺什么

上一节

8.0 让名字指向可以改变的位置

下一节