章节目录

闭包:从一行源码走到一次间接调用

本节阅读量:

前几节分别看了语义、自由变量、解释器、IR 和汇编。本节只跟踪一个程序,把同一个 x 在五层表示中的位置连起来。

起点:定义位置有一个 x

源码是:

1
2
(let x 40
  ((lambda y (+ x y)) 2))

从语言语义看:

1
2
3
4
5
定义位置       x = 40
lambda 参数    y
lambda 自由变量 x
调用参数       y = 2
结果           42

这个结论先于实现。解释器和编译器必须得到相同结果。

第一层:AST 只保存源码结构

运行:

1
2
cd code/07_closures
./mini ast examples/closure.lang

输出:

1
Let(x, Int(40), Call(Lambda(y, Add(Var(x), Var(y))), Int(2)))

AST 能看见两个变量引用,却还没有“capture slot”概念。parser 也不需要判断哪个 Var(x) 属于哪份机器环境。

第二层:自由变量分析得到 x

分析 Lambda(y, ...) 时,初始 bound 是 [y]:

1
2
遇到 x   不在 bound,加入 result
遇到 y   已在 bound,忽略

结果是 [x]。解释器和 IR lowering 都消费这份结果。

第三层:解释器保存 x 的 Value

求值外层 let 后,当前环境是:

1
[x -> number 40]

求值 lambda 创建:

1
2
3
4
5
Closure
  param      y
  body       (+ x y) 的 AST
  self_name  空
  env        [x -> number 40]

调用时先确认 callee 是 closure,再求值参数 2,然后建立:

1
[x -> 40, y -> 2]

解释器在这个环境中求值 (+ x y),返回 number Value 42。

第四层:IR 把捕获关系显式化

运行:

1
./mini ir examples/closure.lang

最终 IR 是:

1
2
3
4
5
6
7
8
9
x0 = 40
f.0 = closure lambda0 [x0]
check-closure f.0
t.1 = call f.0 2
return t.1
lambda0(y2, self: -, captures: [x1]):
  t.0 = x1 + y2
  return t.0
end lambda0

可以沿着 x 画一条线:

1
2
3
4
5
6
7
8
9
源码 x
  ↓ 当前 lowering 环境解析
x0
  ↓ closure fields[0]
机器 capture slot 0
  ↓ 函数入口装载
x1
  ↓ add op
t.0

x0 和 x1 是两个不同执行位置的 IR local,中间由 closure payload 连接。

check-closure f.0 位于 argument 和 call 之前。当前 argument 只是字面量 2,看不出区别;对复杂 argument,它保证 callee 类型错误先发生。

第五层:closure object 落到堆上

一个 capture 对应:

1
2
3
4
capture_count = 1
payload_count = 2
header        = (2 << 8) | 2 = 514
size          = 8 * (1 + 2) = 24 bytes

malloc 返回 raw address p 后,对象是:

1
2
3
p + 0   514
p + 8   address of .Llambda0
p + 16  tagged integer 40

源语言 closure Value 是:

1
p | 4

整数 40 在 capture slot 中仍是完整 tagged Value,不会因为进入对象而改用另一套表示。

第六层:检查与调用

执行 check-closure f.0 时:

1
2
3
检查 low tag == 4
清 tag 得到 p
检查 header kind == 2

通过以后,call 约定是:

1
2
%rdi = p | 4       tagged closure
%rsi = tagged 2    user argument

调用端用 scratch register 清掉 %rdi 的 tag,从 offset 8 取出 .Llambda0,执行间接 call。

第七层:函数入口恢复名字

.Llambda0 入口做两件与闭包有关的事:

1
2
把 %rsi 保存为参数 local y2
从 (%rdi & -8) + 16 读取 capture 0,保存为 x1

随后普通 add emitter 只看 IR operands x1 和 y2。它不需要知道这两个值分别来自 closure 和参数寄存器。

结果 tagged 42 放进 %rax 返回。主函数解码整数,并把 42 作为退出状态。

五层表示对照

层次 函数代码 外层 x 参数 y
源语言 lambda body 自由变量名 参数名
AST LambdaExpr::body VarExpr("x") LambdaExpr::param
解释器 Closure::body Closure::env call 时加入 call_env
IR IrFunction closure field 与 capture local IrFunction::param
机器 offset 8 code pointer offset 16 capture %rsi 后进入栈槽

表中的外形不同,但都在实现同一句话:函数体中的 x 由定义位置决定,y 由本次调用决定。

再看嵌套 closure

运行:

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

第一条命令输出 42。IR 中外层函数创建内层 closure:

1
2
3
4
lambda0(y2, self: -, captures: [x1]):
  f.1 = closure lambda1 [x1, y2]
  return f.1
end lambda0

此时 x1 来自外层 closure,y2 来自外层调用参数;对内层 closure 来说,它们都是创建位置可用的 Value,于是按普通捕获规则写入两个槽。

没有任何一步需要复制外层 closure 对象本身。内层对象只保存自己以后会读取的两个 Value。

再看递归 closure

运行:

1
./mini ir examples/recursive_closure.lang

创建位置是:

1
2
3
4
base0 = 10
f.0 = closure lambda0 [base0]
check-closure f.0
t.5 = call f.0 5

函数头是:

1
lambda0(n3, self: adddown1, captures: [base2]):

沿着两条线看:

1
2
base0 -> payload capture 0 -> base2
f.0   -> hidden %rdi       -> adddown1

base 是定义环境中的自由变量,所以进入 payload。adddown 是实际 callee 的递归名字,所以来自 %rdi。二者不能都笼统地叫“捕获值”。

record 与 closure 的交叉验证

两个示例检查统一对象协议的边界:

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

输出依次是:

1
2
3
42
42
0

前两个结果证明两种对象可以互相保存 tagged 引用;最后一个结果证明它们仍由 header kind 区分。

一条实用的排错路径

若闭包程序结果不对,可以按层检查:

  1. AST 中变量是否位于预期 lambda body;
  2. free_vars 是否排除了参数和 self,又保留真正外层名字;
  3. closure op 的 fields 是否解析到正确外层 IR operands;
  4. IrFunction::captures 是否与 fields 同序;
  5. header、code 和 capture 是否写在 0、8、16 + 8i;
  6. %rdi 是否仍是 tagged closure,临时 raw pointer 是否只用于读取;
  7. 函数入口是否把 capture i 装入对应 local。

按这条链逐层核对,比只盯着最后一条 call 更容易定位问题。


7.5 汇编:在通用堆对象里放入代码和环境

上一节

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

下一节