练习
本节阅读量:这些练习按“语言语义 → AST/parser → 解释器 → IR → 汇编 → 实现边界”逐层推进。先构建第五章快照:
|
|
需要自行输入的程序可以保存到 practice.lang。每题先在纸上写下预测,再用 mini 检查;递归程序只看最终数字,很容易漏掉调用展开和返回回卷这两个不同阶段。
|
|
练习 1:sub1 只做一件事
预测下面三个程序的结果:
|
|
|
|
|
|
依次用 ./mini run practice.lang 检查。预期结果是 42、3 和 -3。
再回答:为什么 sub1 本身不会保证递归结束?它只把当前数字减一。若参数从负数开始,而基例是 (eq? n 0),每次 sub1 反而让参数离零更远。
最后在 interpreter.cpp 和 assembly.cpp 中分别找到它的实现,确认两条路径做的是同一件事:解释器返回 value - 1,汇编生成器发出 subq $1, %rax。
练习 2:手动展开并回卷 sum 3
把贯穿示例的入口从 5 改成 3:
|
|
不要先运行。分别写出调用展开:
|
|
再从基例开始写返回回卷:
|
|
运行检查:
|
|
预期输出 6。指出哪一段是基例,哪一个表达式让参数靠近基例,以及为什么每一层都必须保留自己的 n,直到更深一层返回。
练习 3:从 letrec 语法还原 AST
先给 examples/recursive_sum.lang 手画 AST。最外层应该是一个 LetRecExpr,它有四项有效信息:
|
|
运行:
|
|
当前实现输出:
|
|
沿着这一行找出两个 Var(sum) 分别属于哪棵子树:一个在 function body 的递归调用中,一个在 letrec body 的首次调用中。它们都由同一个 LetRecExpr::name 引入,却会在不同环境中被解析。
接着检查三种错误输入:
|
|
|
|
|
|
分别保存到 practice.lang 并运行 ./mini ast practice.lang。它们依次多了 sub1 操作数、缺少 letrec body、缺少调用实参。第三个应明确报告:
|
|
在 parse_expr() 中说明为什么 head 为 sub1 或 letrec 时先进入固定特殊形式,而 head 为普通名字 sum 时才落入 call 分支。
练习 4:追踪一次递归调用环境
仍以 sum 3 为例。写出进入四层函数体时的初始环境:
|
|
每一行都是一份新的 Env,不是在同一份环境里反复改写 n。找到解释器的 call 分支,标出以下四步:
|
|
再解释为什么绑定 self 时使用“这次实际调用得到的 callee”,而不是让 Function 在创建时持有一个指向自己的 shared_ptr。前者既能让函数调用自己,也不会形成永远无法归零的强引用环。
练习 5:让递归函数作为值离开
运行已经准备好的例子:
|
|
解释器输出应为 15。这个程序在 letrec 的 body 中没有立即调用 sum,而是直接返回函数值;外层把它改名为 f 后才调用。
回答两个问题:
- 调用位置只知道名字
f,函数体为什么仍然能用原来的 self namesum? - 编译结果为什么不需要复制函数代码,只需要复制指向同一 label 的地址?
再尝试把函数作为参数传递:
|
|
预期结果仍然是 15。这说明“函数是一等值”和“函数能自调用”可以同时成立;它并不意味着函数已经能捕获任意外层变量。
练习 6:逐行解释递归 IR
先不看输出,手写 recursive_sum.lang 的 IR 形状。要求分成主程序和 lambda0(n) 两部分,并包含:
|
|
再运行:
|
|
当前输出是:
|
|
逐项回答:
- 为什么主程序的
f.0不能直接供.Llambda0的栈帧读取? f.0和sum0为什么可以有不同名字却表示同一个函数值?- 为什么
t.2 = sub1 n必须出现在递归 call 之前? - 为什么
t.4 = n + t.3必须出现在递归 call 之后? - IR 中的 then 是 branch 的目标;为什么汇编物理布局可以让 then label 紧跟条件跳转,并只对 else 发出
je?
最后在 lowerer 中确认:function_env 是新环境,先加入 sum -> sum0,再加入 n -> n;它没有复制外层 main 的环境。
练习 7:画出七个同时存在的栈帧
生成汇编:
|
|
在 out.s 中找出:
|
|
想象 sum 0 刚进入、还没有返回的时刻,画出同时存在的七个 frame:main,以及 sum 5、sum 4、sum 3、sum 2、sum 1、sum 0 中的六个递归函数 frame。不要被“入口调用一次”骗过。
对每个函数 frame 至少标出:
|
|
然后从 sum 0 向上画箭头,说明 %rax 怎样依次携带 0、1、3、6、10、15 返回。关键点是:更深一层返回时,当前层的 frame 仍然存在,所以 addq 还能读到本层保存的 n。
最后在当前平台链接并检查:
|
|
Apple Silicon Mac 改用:
|
|
退出状态应为 15。
练习 8:尾位置不等于尾调用优化
查看 examples/countdown.lang:
|
|
递归分支把 (down ...) 的结果直接作为分支结果,没有再做加法,所以调用处于尾位置。运行:
|
|
结果应为 42。在 IR 中仍能看到普通 call down0,在汇编中仍能看到 call *%rcx;每次调用仍会创建新 frame,返回时仍逐层经过 retq。
回答:如果真的做尾调用优化,哪一种资源增长应该消失?本章不要求实现优化,只需说明“语法上位于尾位置”和“后端已经复用当前 frame”是两件事。
练习 9(进阶选做):把直接自尾调用变成跳转
这一题不改变第五章标准代码快照,而是在自己的练习副本中实现一个严格受限的优化。只处理同时满足以下条件的调用:
|
|
先让 lowering 携带“当前表达式是否位于尾位置”的信息,并按下面的规则向下传递:
|
|
call 表达式本身可以是候选者,但求 callee 和 argument 的过程都不是尾位置。遇到嵌套 letrec 时,内层 function body 还要切换到内层函数自己的 self binding,不能继续沿用外层函数身份。
不要只比较源码名字是否等于 letrec 的函数名。下面的调用虽然位于尾位置,但 loop 已被内层 let 遮蔽,因此不能优化成外层函数的自跳转:
|
|
可以在名字解析后比较 callee 对应的唯一 self operand,或者显式保存 binding identity。这个程序应得到 3;若只比较字符串,很可能错误地不断跳回外层 loop。
接着在结构化 IR 中加入明确的 tail_self_call op,保存已经求值完成的新参数和当前函数体入口。不要扫描 IR 文本或汇编字符串猜测 call 后面是否紧跟 return。函数汇编需要两个入口位置:
tail_self_call 是终结当前控制流路径的 op,不会产生供后续表达式使用的结果。现有 lower_expr() 总是假设表达式会返回一个 Operand,因此不要为它捏造一个假临时值。可以增加一个只处理尾位置的 lower_tail_expr():普通表达式仍委托给 lower_expr() 并写入函数结果;尾位置的 if 递归处理两个分支;直接自尾调用则在参数求值后发出 tail_self_call,到这里结束该分支。
|
|
tail_self_call 可以生成类似下面的代码:
|
|
先求值并保存 new_argument,再覆盖旧参数槽,因为参数表达式可能仍要读取旧参数。跳转目标必须是 prologue 之后的 body label;若跳回 .Llambda0,就会再次执行 pushq %rbp,不但没有复用 frame,还会破坏栈布局。
完成后做三组检查:
countdown.lang的 main 仍用一次普通call进入函数,但递归分支改用jmp .Llambda0_body。recursive_sum.lang中的递归调用后面还要做加法,必须继续生成普通call。- 上面的 shadowing 程序继续返回
3,不能被误判为直接自调用。
最后把 countdown.lang 的初始参数暂时改成一个较大的数,确认编译后的程序仍返回 42,并说明为什么递归深度不再带来线性增长的机器栈。这个练习只建立“直接自尾调用”这一种最小优化;不要继续扩展到通用尾调用约定。
练习 10:比较外层名字和 self name
先运行不受支持的捕获例子:
|
|
两条命令都会报告 undefined variable: base。按名字来源分类:
|
|
再把程序改成一个从未调用的递归函数:
|
|
预测并检查:
|
|
run 输出 42,因为函数体没有执行;ir 仍在 lowering 函数体时报告 undefined variable: base。解释为什么这只是报错阶段不同,而不是解释器已经支持闭包。
练习 11:做一次完整回归
依次运行:
|
|
除了最后两个递归求和例子输出 15,前七个都应输出 42。这一步验证第五章没有为了加入递归而丢掉前几章的主线能力。
最后任选一个结果在 0 到 255 之间的示例,完成:
|
|
逐层写下“输入是什么、输出是什么”。如果两条路线产生同一整数,并且你能解释 self binding 在解释器、IR 和汇编中的对应位置,第五章的核心就已经走通了。