边界:会调用自己,还不是闭包
本节阅读量:letrec 只比第四章多解决一个名字问题:函数体能够通过固定名字再次取得自己。
|
|
这个变化足以写出递归,却没有让函数自动看到创建位置的全部环境。本节把这条界线说清楚,也区分“语言不支持什么”和“本章暂时怎样观察结果”。
自绑定不等于捕获外层环境
下面的程序属于第五章:
|
|
sum 的函数体只使用两类名字:自己的名字 sum 和参数 n。调用环境可以明确地建立这两个绑定。
下面的程序则越过了本章边界:
|
|
它保存在 examples/recursive_capture_error.lang。base 既不是递归函数名,也不是参数或函数体内部的 let 名字;它来自创建 adddown 时的外层环境。
|
|
解释器会报告:
|
|
IR lowering 也使用一份只含 self 和 parameter 的新环境,因此同样拒绝它:
|
|
|
|
要让 adddown 记住 base,函数值必须同时携带创建时需要的外层值。第七章会把函数值从单独的代码入口扩展成闭包;第五章不提前实现这件事。
递归函数可以离开 letrec
“不捕获外层环境”并不表示递归函数只能在 letrec 的 body 里立即调用。下面的程序先让 sum 作为 letrec 的结果离开,再把它绑定到 f:
|
|
它保存在 examples/recursive_function_value.lang:
|
|
输出仍是:
|
|
原因不是函数偷偷保存了整个外层环境,而是递归函数值自身记录了 self_name = "sum"。无论它后来通过 sum、f,还是另一个函数的参数到达调用点,解释器都会在调用时把当前 callee 重新绑定给 sum。编译器中的函数地址也仍然指向同一个 .Llambda0。
因此本章支持的是:
|
|
只有单个具名递归函数
本章语法是:
|
|
它一次只创建一个函数,所以没有同时声明多个函数的语法,也不支持 even 与 odd 互相调用这样的相互递归。
普通 let 的 value 也仍然看不到它刚要绑定的名字:
|
|
这里函数体中的 loop 不是 lambda 的参数或内部名字,因此仍然未定义。需要自绑定时,应明确使用 letrec,不要把普通 let 当成递归声明。
嵌套 letrec 当然可以存在,但内层函数不会因此自动捕获外层递归函数。每个递归函数都只有自己的 self、parameter 和内部绑定。
名字相同会发生普通遮蔽
建议始终给函数名和参数使用不同名字:
|
|
当前语法并未额外禁止 (letrec f f ...)。调用环境先放入 self 绑定,再放入参数绑定,所以同名参数会遮蔽函数名;函数体里的 f 将得到实参,而不是递归函数值。这个结果遵守项目一直使用的“后加入的同名绑定优先”规则,但几乎不会是递归程序想表达的意思。
终止依赖程序自己写对
解释器和生成代码都不会证明递归一定结束。一个正常的递归通常需要三部分:
|
|
examples/recursive_sum.lang 对非负整数满足这个结构。若把入口参数换成负数,sub1 会让它继续远离零;若完全忘记基例,程序也会一直递归:
|
|
不要实际用很深的输入反复试这个程序。本章没有递归深度限制,也没有把递归改写成循环;每次调用都会再占用一个机器栈帧,最终可能因栈耗尽而异常结束。
尾位置调用仍然会增加栈帧
countdown.lang 的递归分支没有在调用结果上继续做加法:
|
|
递归调用处在函数体的尾位置,但第五章仍然为它生成普通 call *%rcx。因此 down 3、down 2、down 1 和 down 0 的栈帧会同时存在,等基例返回后再逐层 retq。
尾调用优化会改变这种栈行为,不属于本章标准实现。这里用 countdown 先帮助你认出“尾位置”与“已经做了尾调用优化”不是一回事;章末进阶选做练习再把范围严格限制为直接自尾调用,尝试把它变成当前 frame 内的跳转。
动态语言与无标签编译器的边界仍然存在
源语言仍然是动态类型。解释器会在运行时检查:
|
|
例如把一个普通函数值交给 sub1,mini run 会报告 sub1 expected a number。但是本章编译器仍用一个裸机器字同时表示整数和函数地址,没有加入运行时 tag,也没有在汇编里复制这些检查。
所以编译路径继续只承诺这样的程序:名字符合本章作用域;实际执行到的算术收到整数;实际执行到的 call 收到函数;递归能够在机器栈耗尽前结束。第六章引入统一的 tagged value 和堆对象后,机器层才开始具备区分值种类的表示基础。
错误可能在不同阶段出现
解释器在函数真正被调用时才执行函数体;编译器则必须在生成代码时 lowering 函数体。于是一个从未调用的越界函数会表现出不同的报错时机:
|
|
mini run 会输出 42,因为 unused 的函数体没有执行。mini ir 和 mini compile 会在 lowering base 时报告未定义变量。两条路径没有给这个程序不同的闭包语义,只是在不同阶段遇到了同一个不受支持的自由变量。
第四章 I/O 实验不进入主线
第四章末尾的 (cin)、(cout expr) 和 C++ runtime 链接只是一次独立的 ABI 实验。第五章回到语言主线,代码快照不再包含这两个 primitive;递归示例继续使用 mini run 或链接后的小整数退出状态观察结果。
这不是功能回退:整数、负整数、加法、变量、let、条件、函数值和调用都继续保留;只有明确约定为第四章专属的 I/O 实验没有继承。