第五章:函数怎样调用自己
本节阅读量:第四章已经让函数可以作为值创建、保存和调用。不过,下面这个看起来很自然的写法仍然不能工作:
|
|
问题不在 call,而在名字 sum 的作用域。普通 let 会先求值右边的 lambda,然后才把结果绑定到 sum。因此,lambda 的函数体并不处在 sum 的绑定范围内。真正调用函数并执行到 (sum ...) 时,解释器只能报告:
|
|
这正是本章要解决的核心困难:怎样让一个函数在自己的函数体里找到自己?
用 letrec 建立自引用
第五章加入专门的递归函数绑定 letrec:
|
|
这段程序保存在 examples/recursive_sum.lang。letrec 创建一个参数为 n 的函数,把它绑定到 sum,并且让这个绑定同时出现在两个地方:
|
|
sub1 是本章同时加入的一个小型辅助表达式。它把一个整数减一,让递归参数逐步靠近停止条件 n == 0:
|
|
这里真正的新概念是递归绑定;sub1 只是让示例不必提前加入一整套减法语法。
两条路线仍然一起前进
本章继续维护解释器和编译器两条路线:
|
|
同一个 letrec 在不同层次有不同的落点:
|
|
递归不是一种新的机器指令。机器仍然只会执行第四章已经见过的 call 和 ret;变化在于,被调用的地址又指回当前这段函数代码。
先运行贯穿示例
进入第五章代码快照:
|
|
两条命令依次输出:
|
|
再观察 recursive_sum.lang 的 AST:
|
|
输出为:
|
|
这一行虽然长,但最外层只有一个新的 LetRec 节点。它依次保存函数名 sum、参数名 n、递归函数体和使用该函数的 body。
最后先看一眼 IR:
|
|
此时不必理解所有临时变量,只观察下面的骨架:
|
|
主程序中的 f.0 和函数体中的 sum0 都指向 lambda0。前者发起第一次调用,后者让已经进入 lambda0 的函数再次调用自己。IR 一节会完整解释为什么需要这两个名字。
建议的学习顺序
这一章可以分成两个阶段:
|
|
第一遍阅读时,先抓住“函数体得到自己的名字”即可。栈帧大小、临时变量编号和具体寄存器可以留到后半章。
本章改动落在哪里
|
|
整数、负整数、加法、变量、let、shadowing、eq?、if、普通函数值和调用都继续保留。第四章末尾的 cin、cout 只是一次独立的 C++ runtime 联调实验,已经完成了它的教学任务;第五章回到课程主线,不继承这两个 primitive。
这一章刻意停在哪里
第五章只实现一个具名、单参数函数的自递归:
|
|
它还不支持相互递归、普通值的递归绑定、尾调用优化,也不能让函数捕获任意外层变量。第六章会先加入 record 和堆对象,第七章再正式解决闭包捕获。
读完本章后,你应该能够说明:
- 普通
let + lambda为什么不能直接定义具名递归函数。 letrec的函数名在哪些表达式中可见。- 解释器为什么让函数值记录 self name,再在每次调用时建立自绑定。
- IR 为什么在函数体里再次取得同一个函数标签的地址。
- 一次递归
call为什么会增加一层栈帧,以及结果怎样逐层返回。