章节目录

第七章:让函数带着环境离开

本节阅读量:

前几章已经让函数成为值,也让递归函数能够调用自己。第六章又建立了一套通用的堆对象协议:一个 tagged Value 指向堆对象,header 说明对象种类和 payload 数量。

但到目前为止,普通 lambda 仍然只能使用自己的参数和函数体内部的绑定。下面的程序还缺少一种运行时表示:

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

lambda 的参数是 y,函数体却还需要外层的 x。函数创建以后,原来的 let 可能早已结束;单独保存一段代码的地址,无法回答以后应该到哪里寻找 x。

第七章把函数值升级为闭包:

1
闭包 = 函数代码 + 创建时需要的外层值

这个定义里有两个时间点:

  • 创建 lambda 时,确定并保存以后还需要的外层值;
  • 调用闭包时,取回这些值,再加入本次调用的参数。

因此上面的程序结果是 42。

本章不增加表面语法

读者写的仍然是已经认识的 lambda 和单参数调用:

1
2
(let inc (lambda x (+ x 1))
  (inc 41))

LambdaExpr、CallExpr 和 parser 都不需要新的语法分支。变化发生在 AST 之后:

1
2
3
4
5
6
7
源码
  ↓ lexer + parser
AST
  ↓ 自由变量分析
带捕获列表的结构化 IR
  ↓ 汇编生成
堆上的 closure 对象 + 间接调用

解释器也会实现同样的语言行为,只是使用 C++ 对象保存闭包,而不是生成机器布局。

先运行一个闭包

进入本章代码目录并构建:

1
2
3
cd code/07_closures
make
./mini run examples/closure.lang

输出:

1
42

再用一个同名遮蔽程序确认它采用词法作用域:

1
./mini run examples/lexical_scope.lang

源码是:

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

结果仍然是 42,不是 102。调用位置后来出现的 x 不会改变闭包创建时已经确定的名字含义。

AST 没有为闭包增加节点

查看第一个程序的 AST:

1
./mini ast examples/closure.lang

输出:

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

这仍然只是 LetExpr、CallExpr、LambdaExpr、AddExpr 和变量节点。AST 记录的是源码结构;“x 应该被捕获”是后续分析得到的事实,不需要塞进 parser。

IR 第一次明确写出捕获值

运行:

1
./mini ir examples/closure.lang

会得到:

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

最关键的是这三处:

1
2
3
f.0 = closure lambda0 [x0]
check-closure f.0
lambda0(y2, self: -, captures: [x1]):

第一行在创建位置保存当前的 x0。第二行在求值 argument 前确认 callee 确实是 closure。函数体里的 x1 是与捕获槽对应的局部 IR 名字。self: - 表示普通 lambda 没有递归名字。

没有自由变量的函数也统一创建 closure,只是捕获列表为空:

1
f.0 = closure lambda0 []

这样调用路径不必再区分“裸代码地址”和“带环境的函数”。

closure 复用堆对象协议,但不是 record

第六章已经规定所有堆对象引用共用低位 tag 4。第七章不再发明一个 closure 专用 pointer tag,而是在 header 中增加新的对象 kind:

1
2
3
4
heap-object pointer tag = 4

header kind 1 = record
header kind 2 = closure

closure 的 raw object 布局是:

1
2
3
4
5
offset 0       header
offset 8       raw code pointer
offset 16      capture 0
offset 24      capture 1
...

header 的 payload count 包含 code pointer,因此捕获 n 个值时:

1
2
payload_count = 1 + n
object_size   = 8 * (2 + n)

这叫复用同一套堆对象协议,不是使用源语言的 (record ...) 来假装闭包。两种对象拥有不同 header kind,所以:

1
(record? (lambda x x))

结果是 0。size 和 get 收到 closure 时也会拒绝操作,而不是把 code pointer 暴露成 record 字段。

编译、链接和观察结果

生成汇编:

1
./mini compile examples/closure.lang -o out.s

compile 的路径是:

1
AST -> IrProgram -> x86-64 assembly

汇编生成器只消费结构化 IrProgram,不会绕过 IR 再遍历 AST。compile 仍然只写出汇编文件,不调用 cc。

Linux、WSL 和 Intel Mac 可以手动链接:

1
2
3
cc out.s -o out
./out
echo $?

Apple Silicon Mac 需要明确生成 x86-64 可执行文件:

1
2
3
cc -arch x86_64 out.s -o out
./out
echo $?

程序不打印内容,退出状态是 42。解释器负责显示完整 Value;编译后的主函数仍用进程退出状态观察整数结果。

本章会新增什么

主要改动集中在这些位置:

1
2
3
4
5
front/free_vars.*        分析 lambda 和 letrec 的自由变量
interp/interpreter.cpp   闭包保存自由变量,并从定义环境执行函数体
compile/ir.*             新增 closure op 和结构化 IrFunction
runtime/value.h          增加 HeapKind::closure 与布局辅助函数
compile/assembly.cpp     分配 closure、检查 callee、传隐藏参数并间接调用

AST 与 parser 只继承第六章已有接口;本章没有新的表面语法。

已有能力不会回退

第七章快照继续支持:

  • 整数、负整数、加法、变量和 let;
  • eq?、if、函数值、调用和 letrec;
  • 空 record、任意字段数的 record、record?、size 与安全 get;
  • record 身份比较以及 record 和 closure 互相保存。

本章新增的唯一语言能力是:函数可以读取定义位置的外层变量。可变捕获、共享 cell 和垃圾回收仍留给后续章节。

学完以后应该能回答

读完本章,你应该能够解释:

  1. 为什么代码地址不足以表示能捕获变量的函数;
  2. 什么是相对于某个 lambda 的自由变量;
  3. 为什么调用位置的同名绑定不能改变闭包行为;
  4. 解释器为什么保存自由变量对应的 Value;
  5. IR 怎样把代码标签和捕获 operand 放进一个 closure op;
  6. closure 怎样复用通用 heap-object tag 和 header;
  7. 为什么 %rdi 要携带 tagged closure Value,函数体又怎样读取捕获槽。

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

上一节

7.1 语言:名字由定义位置决定

下一节