结构化 IR:把值和位置分开
本节阅读量:第七章的 lowering 环境把源码名字解析成一个表示“值”的 Operand。第八章加入 set! 以后,同一个名字在不同时刻可能读出不同的值,因此环境不能再把名字直接解析成某次读取的结果。
本章采用一条统一规则:
|
|
这里的 cell 是编译器内部可见的可变位置。源程序不能直接取得 cell,也不能把某个普通表达式当作赋值目标。
从最小程序观察三个新操作
先看 examples/set.lang:
|
|
构建后输出 IR:
|
|
实际输出是:
|
|
逐行阅读:
cell 1创建一个初值为1的位置,x0.cell表示这个位置。store x0.cell 41把位置里的内容改成41。- 后面的变量
x不再直接变成某个旧值,而是生成load x0.cell。 - 加法使用刚刚读出的
41,最终得到42。
.cell 只是 IR 名字中的可读提示,不是源语言语法,也不是一套静态类型标注。
IR 仍然是结构化数据
本章没有退回“把每行 IR 拼成字符串”的做法。OpKind 在第七章的操作上增加 cell、load 和 store:
|
|
它们继续复用同一个 Op:
|
|
三个新操作使用这些字段的方式如下:
| 操作 | dst |
lhs |
rhs |
|---|---|---|---|
cell |
新 cell 的 IR 名字 | 初始 Value | 不使用 |
load |
读取结果 | cell Operand | 不使用 |
store |
不使用 | cell Operand | 要写入的 Value |
store 没有结果槽,是因为写入本身只产生副作用。但源语言中的 set! 有结果:它返回刚刚写入的 Value。lowering 会直接把 RHS 的 Operand 作为整个 set! 的结果,不需要再 load 一次。
运行:
|
|
会看到:
|
|
源码是 (+ (set! x 40) 2),所以加法可以直接使用 set! 返回的 40。
lowering 环境保存 cell Operand
lowerer 里的环境类型仍然很小:
|
|
变化在于其中的 Operand 现在表示 cell,而不是变量本次读取出的 Value。
变量节点先找到 cell,再生成 load:
|
|
let 则先求 initializer,成功以后创建 cell,再把源码名字和 cell 对应起来:
|
|
本章对所有 let 绑定和函数参数都采用这套表示,即使某个变量最后没有被 set! 修改,也仍然创建 cell。这样变量读取、闭包捕获和赋值只有一条路径。只给真正会修改的变量装箱需要额外的分析,留到进阶练习再讨论。
set! 与 begin 怎样 lowering
set! 必须修改已经存在的绑定。lowering 先解析目标 cell,再处理 RHS:
|
|
这个顺序带来两个结果:
- 未绑定的目标会得到
undefined variable,不会悄悄创建新 cell; - RHS 的所有操作都排在最终
store前,只有 RHS 成功以后才修改原值。
begin 不需要自己的 IR 操作。它只规定 lowering 和运行顺序:
|
|
第一项产生的操作会先进入 ops,它的结果 Operand 被丢弃;第二项随后 lowering,并成为整个 begin 的结果。
可以用 begin_order.lang 核对:
|
|
|
|
两个 store 的先后顺序就是源码中的执行顺序,最终 load 得到 42。
闭包捕获位置,而不是一次读取结果
考虑 write_only_capture.lang:
|
|
函数体没有普通的 Var(x) 读取,只有 (set! x value)。自由变量分析仍必须把赋值目标 x 算作自由变量,否则 closure 将不知道应该写哪个位置。
运行:
|
|
完整输出是:
|
|
围绕同一个运行时 cell,可以看到四处对应表示:
|
|
这四处不是四个运行时位置。x0.cell 和 x1.cell 是不同作用域里的 IR 名字,closure field 则保存带 tag 的 cell 指针;三者最终指向同一个 cell 对象。函数里的 store x1.cell t.0 因而能被外层最后的 load x0.cell 看见。
函数参数也会在每次调用时得到新位置。签名中的 value2 是本次调用传入的普通 Value,函数第一条操作:
|
|
把它装进本次调用独有的参数 cell。以后对参数执行 set!,不会修改调用者的变量绑定。
两个 closure 可以保存同一个 cell
two_closures_share.lang 同时创建一个写函数和一个读函数:
|
|
解释器结果是 42。IR 开头可以看到两个 closure 都捕获 x0.cell:
|
|
这正是共享状态在进入汇编前的形态。若创建 closure 时先 load x0.cell 再捕获 40,写函数和读函数就只会各自保存一个旧值,程序不可能得到正确结果。
letrec 为什么把 self 也变成 cell
第七章的递归名字不可修改,可以直接把隐藏的 callee closure 当作 self。第八章允许对 letrec 名字执行 set!,函数体、外层 body 和已经保存的旧函数别名必须观察同一个递归绑定,因此 self 也必须是共享 cell。
lowering 顺序是:
|
|
self_param_shadow.lang 还故意让递归名和参数都叫 f:
|
|
查看 IR:
|
|
|
|
IrFunction::self_cell 对应 closure 的 capture 0;普通 captures 从后面的槽开始。建立函数环境时,顺序是 self、普通 captures、参数 cell。查找从后往前进行,所以同名参数 f3.cell 正确遮蔽 self f1.cell,结果是 42。
这个 self cell 与 closure 会形成一个运行时对象环。它不是实现失误,而是可变递归绑定的自然对象图;第九章的 tracing GC 正是为了处理这类结构。
callee 检查必须排在 argument 之前
第七章已经规定:先求 callee,确认它是 closure,再求 argument。第八章有了副作用以后,这个顺序更加重要。
假设把下面程序保存为 order.lang:
|
|
它的 IR 是:
|
|
check-closure 位于 argument 的 store 之前。运行时发现空 record 不是函数后立即失败,不会先把 x 改成 1。因此 call lowering 必须保持:
|
|
branch 后仍然紧跟 then
状态操作不会改变既定的条件分支布局。运行:
|
|
|
|
branch 后物理上立即是 then0:。条件为真时顺序进入 then,条件为假时跳到 else。未选择分支里的 store、call 或其他操作都不会执行。
compile 消费同一份 IR
本章继续保持一条编译路径:
|
|
生成汇编:
|
|
CLI 先调用 lower_to_ir,再把得到的 IrProgram 交给 compile_to_assembly。因此 ./mini ir 展示的 cell、load、store、capture 和检查顺序,就是汇编生成器真正消费的数据,而不是旁路的调试文本。
读完这一节,应该能用一句话概括本章 IR:环境保存位置,表达式计算值,load 和 store 在两者之间建立明确边界。