练习
本节阅读量:这些练习按“语言语义 → AST → 解释器 → IR → 汇编”的顺序推进。先构建第四章代码:
|
|
需要自行输入的程序,可以反复保存到 practice.lang。每次都先写下预测,再运行命令检查;只看输出,很容易错过函数创建和函数调用之间的区别。
可以按下面的难度推进:
|
|
练习 1:预测语义
依次判断下面三个程序的结果:
|
|
|
|
|
|
对每个程序写下:
|
|
把程序逐个保存到 practice.lang,然后检查:
|
|
预期输出依次是 <function>、42、42。第三个程序进入函数体时先有 x -> 40,执行内部 let 后才增加 y -> 2。
练习 2:从语法还原 AST
把下面程序保存到 practice.lang,先手画它的 AST:
|
|
检查每个节点的角色:
|
|
再运行:
|
|
当前实现应输出:
|
|
接着分别让 parser 读取两个参数个数错误的程序:
|
|
|
|
再检查缺少实参:
|
|
仍然运行 ./mini ast practice.lang。第一个应在读完一个函数体后期待 ),第二个应在读完一个 argument 后期待 ),第三个应报告 expected function argument。
结合 parse_expr() 回答:为什么 (lambda x x) 会进入特殊形式分支,而 ((lambda x x) 1) 会在外层进入普通调用分支?
练习 3:追踪解释器的两个环境
把下面程序保存到 practice.lang,再分析:
|
|
按时间顺序写出:
|
|
关键检查点是:调用点的环境里有 seed 和 twice,新建的 call_env 却只有 x -> 21。本章的 Function 不保存前一份环境,函数体也没有理由看到 seed。
运行检查:
|
|
预期输出是 42。然后在 eval_expr() 的 ExprKind::call 分支中,找出“检查 callee”“求 argument”“创建 call_env”和“求值 body”对应的代码。
练习 4:让函数真正成为一等值
把下面两个程序依次保存到 practice.lang。第一个程序把函数作为参数传入:
|
|
第二个程序先返回函数,再调用返回值:
|
|
第二个程序的括号较多。运行前先给每一层调用分别标出 callee 和 argument:中间那次调用得到一个函数值,最外层再把 41 交给这个返回值。
分别运行:
|
|
两个程序都应得到 42。检查 IR 时,不必先猜 lambda0、lambda1 的编号,但必须找到:
- 两个独立的函数定义;
- 产生两个函数地址的
function操作; - 至少一次以另一个函数为 argument,或者以一次调用的结果为 callee;
- 最终产生整数结果的调用。
第二个程序能运行,是因为返回的内层函数只使用自己的参数 x,没有读取 ignored。把内层函数改成 (lambda x (+ ignored x)) 后,就越过了第四章的边界。
练习 5:手写一次 lowering
先不要运行命令,给 examples/function_value.lang 手写 IR。临时变量的数字后缀可以先省略,但要分成主程序和独立函数体两部分,并包含:
|
|
然后对照实际输出:
|
|
当前实现输出:
|
|
逐项解释下面三个现象:
function_ref在主程序的ops中,函数体的add却在IrFunction::ops中。inc0保存的是f.0,调用不需要知道它最初来自哪个lambda。t.0在打印结果中位于t.1后面,编号却更小。这与 lowerer 先处理lambda的函数体、再继续处理外层调用有关。
最后在 ExprKind::lambda 的 lowering 分支里确认:函数体使用新建的 function_env,其中最初只有参数 x。
练习 6:画出两个同时存在的栈帧
生成并运行汇编:
|
|
退出状态应为 42。打开 out.s,依次找出:
|
|
在 .Llambda0 已经建立栈帧、但尚未执行加法的时刻,画出栈,并至少标出:
|
|
第一遍可以先不计算 %rsp mod 16,只画清两个 %rbp、返回地址和各自槽位;再回到正文补上 16 字节对齐。
回答两个问题:
- 此时 main 的栈帧为什么仍然存在?
retq怎样知道要回到 main 的哪条指令?
最后说明为什么调用点必须生成 call *%rcx,而不能一律写成 call .Llambda0:callee 是运行时求值出来的普通值,它可能来自变量、参数,甚至另一次调用的结果。
练习 7:划清一致性边界
先用一个符合本章约束的高阶函数检查解释器和编译器:
|
|
运行完整路径:
|
|
解释器输出和可执行程序退出状态都应是 42。
再把 practice.lang 改成错误程序:
|
|
只运行下面两条命令,不要再链接、运行生成的 out.s:
|
|
解释器应报告:
|
|
编译器目前会成功生成汇编。在 out.s 中可以看到与下面相同含义的指令:
|
|
也就是说,编译器把 42 当成了裸代码地址。它还没有运行时类型标签,因此不能像解释器一样在调用前区分整数与函数。
先写下本章的语义一致性契约:只比较符合本章作用域规则、实际执行路径上值种类使用正确、使用本章示例中的小整数且加法不发生有符号 long 溢出的程序。
再单独写下观察限制:只有为了用 shell 的 $? 检查结果时,才需要让顶层最终返回 0 到 255 的整数。它不是语义一致性的条件;mini run 可以直接显示更一般的合法结果和 <function>。
最后比较作用域错误:
|
|
三条路径都会遇到 undefined variable: x,但时机不同:解释器调用函数并执行 body 时才查找 x,lowering 在建立独立函数体时就会查找 x。支持范围内的结果一致,不代表所有错误都必须在同一阶段发生。
练习 8(进阶,可选):自由变量分析
这是一道可选扩展,不是完成第四章主线代码的前提。先完成集合推导;愿意继续写代码时,再做后半部分的工程挑战。目标是让不受支持的捕获和真正未定义的名字得到更准确、稳定的错误,而不是实现闭包。
概念部分:计算一个 lambda 需要的外部名字
可以用 std::unordered_set<std::string> 表示名字集合。对某个 lambda,用“只包含它的参数”的 bound 集合遍历函数体:
|
|
特别注意 let 的 value:新名字只在 body 中生效,不能提前加入 bound。
先手算并记录每个例子的期望集合;实现分析后,再把它们逐个保存到 practice.lang,通过接入分析的 CLI 命令验证:
|
|
最后一个例子说明:不能只检查最外层函数。遍历 AST 时,要把每个嵌套 lambda 都单独当作检查目标。
概念部分:区分捕获与未定义名字
检查 AST 时同时维护当前词法位置已经存在的名字集合 outer_names。对一个 lambda 计算出 needed_names 后,分成两类:
|
|
可以分别报告:
|
|
进入 lambda 的 body 检查嵌套函数时,把当前参数加入词法名字集合;进入 let 的 body 时,再把它的名字加入。若错误信息中可能出现多个名字,先排序再输出,测试会更稳定。
工程挑战:选择接入位置
一种做法是在 eval 和 lower_to_ir 之前共用一次 AST 预检查。这样解释器和编译器会在相同阶段拒绝捕获。也可以只在各自的 ExprKind::lambda 分支接入,但要接受两条路径遇到嵌套 lambda 的时间可能不同。
无论选择哪种方式,都不要给函数值增加外层环境;那是闭包章节的任务。这道练习只改善诊断。
工程挑战:回归测试
修改 C++ 后先重新构建,再确认合法程序没有回退:
|
|
预期仍然得到 42,并正常打印 IR。再检查已有的捕获示例:
|
|
完成扩展后,三条命令都应明确指出需要捕获 x。最后把下面两个程序分别保存到 practice.lang:
|
|
|
|
前者应指出内层函数需要捕获 x,后者应指出 missing 从未定义。能区分这两种错误,才说明分析同时理解了嵌套作用域和第四章的能力边界。