常见错误、练习与全书收束
本节阅读量:本章没有增加后端指令,却同时调用了 parser、AST pass、词法作用域、closure、共享 cell、record、调用、寄存器分配和 GC。错误往往发生在这些边界的组合处。
|
|
常见错误
- 直接在解释器里特殊处理 yield:会让解释器和编译器形成两套语义。
- 创建 coroutine 时执行 body:body 的副作用和错误都应等到首次 resume。
- 返回 paused record 后才保存 continuation:当前调用已经返回,后续状态会丢失。
- 每次完成后重新分配 record,或 done 返回前不把自身写回 state:前者破坏对象身份,后者会让下一次 resume 读到 dispatcher 留下的
0。 - 把 continuation AST 复制进两个分支:嵌套
if会造成指数膨胀。 - 使用
state、next作为内部名:这些都是合法用户变量,会发生捕获。 - 认为 closure 可达就不用 root:safepoint 时 tagged pointer 仍必须位于 collector 扫描的位置。
练习 1:手推三次 resume
|
|
画出 state 的变化:
|
|
写出三次返回 record,解释前两次 tag 为 0、最后一次为 1。
练习 2:证明创建是懒执行
|
|
在 core 中找到 source body 对应的 Set。它位于哪个 lambda body?为什么创建 dispatcher 只构造 closure,不执行这条 Set?
把 body 改成必然类型错误但不 resume,程序仍应完成;加入一次 resume 后错误才出现。
练习 3:yield value 怎样回到 let
运行:
|
|
寻找 $coro.letcont、$coro.yieldvalue 和调用 continuation 的位置。解释为什么第一次 resume 返回 (record 0 40),第二次 resume 中的 x 仍得到 40,但调用者没有发送值。
练习 4:验证左到右顺序
|
|
列出三个程序实际发生的 set! 顺序。思考如果提前执行两个分支,或在 yield value 求完前安装下一步,哪项回归会先失败。
要区分编译期访问两条分支的 AST 与运行时执行分支;lowering 可以递归构造两边,生成的 core 仍只能执行被选择的一边。
练习 5:读懂 reentry guard
|
|
在 dispatcher 中找到“读取 state 到 next、写 state 为 0、调用 next”。回答:
- 为什么先保存 next?
- 正常 yield 在哪里恢复 closure?
- 完成在哪里安装自引用 done closure?done 每次调用为何还要把自身写回 state?
- 为什么重入复用了普通函数错误?
思考删掉写 0 后,示例会怎样重新进入同一个 step。
练习 6:独立状态机
|
|
找出两组不同的 $coro.state.*,分别画出 left/right 的 dispatcher、state 与 continuation。改变 resume 顺序,只要正确读取协议字段,二者不应互相干扰。
练习 7:追踪 suspended root
|
|
在 alloc 中找出 dispatcher、state、next closure、yielded value cell,以及恢复后的加法结果。说明哪些是 root、哪个可进入 integer register、collector 搬移对象后从哪里更新引用。
练习 8:完成结果的对象身份
|
|
找到 completion record 的唯一构造位置和捕获它的自引用 done closure。若把 record 构造移进 done,字段仍相等,为什么 eq? 会变成 0?若删除 done body 中的 (set! state done),为什么第二次完成后 resume 能返回、再下一次却会报函数错误?
再说明 coroutine_done_no_restart.lang 如何从副作用方向验证 body 没有重新执行。
练习 9:检查代码规模
写一个 coroutine,让多个 if 顺序出现,每个分支都可能 yield,最后共享同一尾部。逐渐增加 if,运行:
|
|
记录输出长度。正确 lowering 把共同后续绑定成 continuation closure,规模近似线性;若 clone 整棵 continuation AST,规模会快速翻倍。
练习 10:静态边界的正反例
先看非法写法,yield 跨进了普通 lambda:
|
|
再看合法写法,lambda 只负责创建一个拥有自己控制边界的新 coroutine:
|
|
为第二项补一个完整程序,用 ast、core、run 确认 surface 节点存在、lowering 成功、只有 resume 后才执行内层 body。
进阶练习:改变协议前先写语义
若想实现 (resume coroutine value),先明确:首次发送值是否忽略;yield 恢复后得到什么;完成后继续发送是否报错;嵌套 coroutine 如何区分接收者。
这会同时改变 parser、surface AST、continuation 参数和测试协议,属于课后扩展。先把语义写成示例,再改 lowering,仍不要给解释器和编译器各写一套。
全书收束:你已走完一条编译器路径
第一章只有整数和加法:
|
|
随后每章只增加一个主要困难:
|
|
它们没有成为十一套互不相干的演示。第十一章仍经过同一个解释器语义、同一种结构化 IR、同一套寄存器分配、同一个汇编 emitter 和 copying collector。这种连续性说明前面建立的边界足够稳定,可以承载后来没有预先写死的新抽象。
你现在已经亲手处理过这些核心关系:
- surface 特性可以 lowering 到较小的 core;
- 词法名字通过环境找到共享位置;
- closure 的自由变量决定暂停后保存什么;
- 堆可达性最终落实为 safepoint 处的机器位置;
- 控制流同时影响语义、活跃性与寄存器冲突;
- ABI、动态错误和 GC 必须在同一条后端中成立。
本书没有实现宏、静态类型、模块、异常、并发或通用协程,也没有把小语言包装成生产工具。它完成的是更基础、也更可迁移的路径:从文本出发,经过可逐层检查的数据结构和 pass,最后成为真正由机器执行的 x86-64 程序。
到这里,编译器不再是只能远观的黑箱。你已经把它拆开过,也把它重新接起来了。