自由变量:只带走以后真正需要的名字
本节阅读量:解释器和编译器创建闭包前,都要回答同一个问题:函数体以后还会读取哪些外层绑定?
这些名字叫自由变量。第七章把自由变量分析放在 front/free_vars.*,让解释器和 IR lowering 共享同一条判断规则。
自由总是相对于某个函数而言
考虑:
|
|
相对于这个 lambda:
|
|
所以结果是:
|
|
分析不是给整个程序贴一份永久标签。同一个名字在不同 lambda 中可能扮演不同角色。
算法携带两份有序列表
collect 沿 AST 递归时维护:
|
|
遇到变量节点时:
|
|
add_unique 不使用无序集合,因为捕获顺序最后会变成对象槽位顺序:
|
|
同一个名字只出现一次,但第一次遇到的位置不会丢失。
每种 AST 节点都必须有规则
分析 pass 使用显式 ExprKind + switch。第七章完整表达式集合的递归规则是:
| 表达式 | 怎样分析 |
|---|---|
| integer | 不产生自由变量 |
| variable | 不在 bound 时加入 result |
+、eq? |
按源码顺序分析左右 operand |
sub1 |
分析子表达式 |
if |
依次分析 condition、then、else |
| call | 先分析 callee,再分析 argument |
let |
value 使用旧 bound;body 临时加入绑定名 |
letrec |
按递归绑定规则分别分析 function body 和 body |
lambda |
分析 body 时临时加入参数名 |
record |
从左到右分析所有字段 |
record? |
分析唯一子表达式 |
size |
分析 record 表达式 |
get |
分析 record 表达式;index 是字面量元数据 |
把第六章节点列全很重要,但不需要重讲 record 的语义。自由变量分析只关心哪些子树可能出现变量。
let 的名字只绑定 body
看这个函数:
|
|
分析 let 的 value 时,y 还没有进入作用域;分析 body 时才加入 y:
|
|
参数 x 和局部变量 y 都能在函数内部找到,因此捕获列表为空。
这个规则也能正确处理 value 中引用外层同名变量的情况:
|
|
内层 let 的 value 里的 y 仍指向外层 y,因此该 lambda 要捕获它。进入 inner body 后,新 y 才遮蔽旧绑定。程序结果是 42。
shadowing 不能靠字符串猜
自由变量分析使用源码名字判断作用域,lowering 随后通过当前环境把名字解析成唯一 operand。
例如:
|
|
分析 lambda 得到源码名字 [x]。创建 closure 时,lowering 所在环境把它解析成外层的 x0:
|
|
后面值为 100 的另一个 x 会得到不同 IR 名字,不可能替换已经写进 closure op 的 x0。
嵌套 lambda 必须让需要的名字向外传播
下面的程序是理解自由变量分析最容易遗漏的一步:
|
|
对内层 lambda 来说,自由变量是 [x, y]。
对外层 lambda 来说,y 是自己的参数,不自由;但 x 仍然来自外层,因此外层也必须捕获 [x]。原因不是外层函数立即计算 x,而是它以后创建内层 closure 时需要拿到 x 的 Value。
IR 把这条传递关系写得很清楚:
|
|
若分析嵌套 lambda 时简单停止递归,外层的捕获列表会错误地变成空,运行到 closure lambda1 [x1, y2] 时也就找不到 x1。
多次使用只捕获一次
下面的函数两次读取 x:
|
|
第一次遇到 x 时把它加入结果,第二次由 add_unique 去重,所以捕获列表是 [x],不是 [x, x]。两个变量引用在函数体里都会读取同一个捕获槽。
首次出现顺序是一份内部契约
运行:
|
|
会看到:
|
|
创建位置的 [x0, y1] 与函数体声明的 [x2, y3] 一一对应:
|
|
以后汇编 emitter 会把它们放在 offset 16、24。只要分析结果、closure op 和 IrFunction::captures 使用同一顺序,源码名字怎样 alpha-rename 都不会造成混淆。
letrec 的 self 不是自由变量
对于:
|
|
分析递归函数体时,从这两个名字已经绑定开始:
|
|
因此结果只有 [base]。接口直接把这条规则写出来:
|
|
self 不进入 payload 捕获列表。IR 用单独的 IrFunction::self 保存函数体里的递归局部名;机器调用时再把 %rdi 中的实际 closure Value 写入这个局部槽。
这比“让 closure 捕获自己”更直接,也避免创建自引用 payload。
letrec 出现在其他函数内部时
通用 collect 还要处理一个 letrec 作为普通子表达式的情况。作用域边界是:
- function body 中,函数名和参数名都已绑定;
- letrec body 中,只有函数名由这次
letrec绑定; - 参数名不应泄漏到 letrec body。
代码用成对的 push_back 和 pop_back 表达这条边界:
|
|
这也解释了为什么环境用可保留顺序的列表,而不是只有“存在或不存在”信息的全局集合。
两个消费者使用同一分析结果
自由变量 pass 同时服务两条实现路线:
|
|
解释器不需要为了教学方便复制整份环境;它也只保存分析选中的名字。于是两条路线都清楚表达“闭包只留住真正需要的外层值”。
本节的验收问题
读到这里,可以先不看答案判断:
|
|
捕获列表应为 [b, a],不是按字母排序的 [a, b],也不是包含重复项的 [b, a, b]。下一节会把这些名字对应的 Value 真正放进解释器闭包。