Roc 编译器 mono 快照测试解析:为什么顶级常量永远不会被 lambda 捕获
【免费下载链接】rocA fast, friendly, functional language.项目地址: https://gitcode.com/GitHub_Trending/ro/roc
导读
本文以 Roc 仓库中的 mono 快照测试 test/snapshots/mono_pure_lambda.md 为线索,逐区块拆解这份“纯 lambda”编译测试文件,说明它如何验证一个关键编译器行为:顶级(top-level)常量不会被 lambda 捕获,因此不会进入闭包的捕获集合。读完本文,你将掌握 Roc 快照测试文件的区块结构与阅读方法,理解e-closure/e-lookup-local等规范 IR(canonical IR)形态的区别,并能对照 src/snapshot_tool/main.zig 中的 mono 测试执行与校验流程,把这份快照当作调试编译器行为时的可复现样本。
一、快照文件整体结构与 mono 测试的定位
Roc 仓库的 test/snapshots/ 目录存放大量编译快照测试。每份快照是一个 Markdown 文件,用#标题将一次编译过程切成多个“观察点”,从源码到词法、语法、规范 IR、类型推断逐层记录结果。mono_pure_lambda.md属于mono类型测试:在 src/snapshot_tool/main.zig 的NodeType枚举中,mono与file、package、platform、app、snippet、reporting一样属于“完整模块”级测试(见 src/snapshot_tool/main.zig 的注释“Snippet, mono, and reporting tests are full modules”)。
整份文件共十个区块:
| 区块 | 内容 |
|---|---|
META | 测试元信息:description一句话概括测试意图,type=mono标记测试类型 |
SOURCE | 被测的原始 Roc 源码 |
MONO | 单态化(monomorphize)后的类型化表示 |
FORMATTED | 格式化结果,NO CHANGE表示格式化前后无差异 |
EXPECTED | 期望输出,NIL表示无 |
PROBLEMS | 编译诊断问题列表,NIL表示无 |
TOKENS | 词法分析产出的 token 流 |
PARSE | 语法分析产出的 AST(s-expression 形式) |
CANONICALIZE | 规范 IR(canon IR),即去糖、消解后的中间表示 |
TYPES | 类型推断结果(inferred types) |
执行快照测试时,工具会按固定顺序输出区块;对 mono 测试,顺序是META, SOURCE, MONO, FORMATTED之后紧跟其余区块(见 src/snapshot_tool/main.zig)。
二、被测源码:一行常量加一个纯 lambda
SOURCE区块给出了三段极简的顶层声明:
one = 1 add_one = |x| x + one result = add_one(5)one = 1:顶级常量,位于模块顶层(type-mod),并非任何函数体内的局部变量;add_one = |x| x + one:Roc 的 lambda 语法,|x|是参数列表,函数体引用x(自身参数)与one(外部名字);result = add_one(5):对 lambda 应用整数5。
这份测试的主题即description所述:“top-level constants are never captured by lambdas”(顶级常量永远不会被 lambda 捕获)。one虽然被 lambda 的函数体引用,但它属于模块作用域,而不是某个外层函数调用帧中的局部变量,因此编译器在规范 IR 中处理它的方式与处理“局部变量捕获”有本质区别——这正是后文CANONICALIZE对比的重点。
三、MONO 输出:单态化后的类型标注视图
one : Dec one = 1 add_one = |x| x + one result : Dec result = add_one(5)MONO是 mono 测试特有的产物,它给出“单态化(monomorphized)后的类型字符串”。在 src/snapshot_tool/main.zig 的注释中明确写着“Get the defaulted (monomorphized) type string for an expression”。可以看到:
one : Dec:整数字面量1默认解析为Dec(十进制小数类型),所以顶级常量one被标注为Dec;add_one未附加类型标注:说明单态化后add_one仍是多态的(详见第六节类型推断);result : Dec:add_one(5)的应用结果单态化为Dec。
值得强调的是,MONO输出并非随意打印,工具会对它做一次完整校验:validateMonoOutput会把 MONO 文本当作“无 header 的 type module”重新解析、规范化并类型检查(见 src/snapshot_tool/main.zig)。也就是说,这份带标注的代码必须本身是合法、可类型检查的 Roc 代码,测试才算通过。
四、词法与语法证据:TOKENS 与 PARSE
TOKENS区块用逗号分隔的 token 名记录了词法流:
LowerIdent,OpAssign,Int, LowerIdent,OpAssign,OpBar,LowerIdent,OpBar,LowerIdent,OpPlus,LowerIdent, LowerIdent,OpAssign,LowerIdent,NoSpaceOpenRound,Int,CloseRound, EndOfFile,三行分别对应三条声明:
LowerIdent(one) OpAssign(=) Int(1);LowerIdent(add_one) OpAssign(=) OpBar(|) LowerIdent(x) OpBar(|) LowerIdent(x) OpPlus(+) LowerIdent(one)——注意 lambda 的|x|由两个OpBar包裹参数名,函数体是x + one;LowerIdent(result) OpAssign(=) LowerIdent(add_one) NoSpaceOpenRound(5) CloseRound——NoSpaceOpenRound表示调用括号紧跟被调用者,无空格(Roc 的调用语法)。
PARSE区块给出了等价的 AST:
(file (type-mod) (statements (s-decl (p-ident (raw "one")) (e-int (raw "1"))) (s-decl (p-ident (raw "add_one")) (e-lambda (args (p-ident (raw "x"))) (e-binop (op "+") (e-ident (raw "x")) (e-ident (raw "one"))))) (s-decl (p-ident (raw "result")) (e-apply (e-ident (raw "add_one")) (e-int (raw "5"))))))关键结构一目了然:文件根节点带type-mod(类型模块);三条s-decl声明中,add_one对应e-lambda(参数x、函数体是e-binop "+"连接e-ident "x"与e-ident "one"),result对应e-apply(调用add_one,实参为整数5)。语法层面此时并不区分“捕获”与“普通引用”,该区分发生在下一阶段的规范 IR 中。
五、核心主题:CANONICALIZE 证明顶级常量不进捕获集
规范 IR(CANONICALIZE区块)是这份快照真正想“说话”的地方:
(can-ir (d-let (p-assign (ident "one")) (e-num (value "1"))) (d-let (p-assign (ident "add_one")) (e-lambda (args (p-assign (ident "x"))) (e-dispatch-call (method "plus") (constraint-fn-var 222) (receiver (e-lookup-local (p-assign (ident "x")))) (args (e-lookup-local (p-assign (ident "one"))))))) (d-let (p-assign (ident "result")) (e-call (constraint-fn-var 234) (e-lookup-local (p-assign (ident "add_one"))) (e-num (value "5")))))注意三点:
- 每条顶层声明变成
d-let(let 绑定),one的值是e-num (value "1"); add_one的 lambda 体把x + one编译成e-dispatch-call (method "plus"):x是 receiver,one是参数,二者都以e-lookup-local方式按名查表;- 关键差异:这里的 lambda 节点没有任何
e-closure包装,也没有captures列表。one作为顶级常量,在规范 IR 中被当作可直接按名查找(e-lookup-local)的全局定义,而不是需要随闭包对象携带的捕获值。
把它与同目录下的“局部变量捕获”快照对比,差异立刻显现。在 test/snapshots/mono_closure_single_capture.md 中,外层函数参数x被内层 lambda 引用,其规范 IR 出现:
(s-let (p-assign (ident "add_x")) (e-closure (captures (capture (ident "x"))) (e-lambda ...)))内层 lambda 被显式包进e-closure,并列出捕获集合(capture (ident "x"))。多变量版本 test/snapshots/mono_closure_multiple_captures.md 同样出现(capture (ident "a")) (capture (ident "b"))。
由此可以得出这条编译器行为链:函数参数、函数体内 let 绑定的局部变量被内层 lambda 引用时,编译器会构建e-closure并记录捕获列表;而模块顶层声明的常量被引用时,只生成e-lookup-local查找,永不进入任何闭包的捕获集合。从源码结构看,这与 Roc 的模块语义一致——顶级常量是全局可寻址的定义,其生命周期覆盖整个模块,无需闭包为其保留环境快照。这既是本快照description的验证目标,也是理解 Roc 闭包内存布局(捕获集大小即闭包负载)的起点。
六、TYPES:类型推断与约束求解
TYPES区块给出推断类型:
(inferred-types (defs (patt (type "Dec")) (patt (type "a -> a where [a.plus : a, Dec -> a]")) (patt (type "Dec"))) (expressions (expr (type "Dec")) (expr (type "a -> a where [a.plus : a, Dec -> a]")) (expr (type "Dec"))))one : Dec:字面量1默认单态化为Dec;add_one : a -> a where [a.plus : a, Dec -> a]:由于one是Dec,x + one中的+(即plus方法)要求“a加上一个Dec仍得到a”,于是add_one保持多态a -> a,但携带约束a.plus : a, Dec -> a。也就是说,a在“存在与Dec相加能力”的数字类型范围内自由;result : Dec:以5(Dec)实例化add_one后得到Dec。
对照 test/snapshots/mono_arithmetic.md 可以看到同样模式:sum = 1 + 2推断为Dec,规范 IR 中是e-dispatch-call (method "plus")作用于两个e-num。而涉及闭包的 test/snapshots/mono_closure_single_capture.md 里,类型约束还会出现from_numeral(数字字面量转换)约束,本测试因one已显式为Dec而省去了该约束的复杂度。
七、FORMATTED / EXPECTED / PROBLEMS:格式化与诊断基线
FORMATTED输出NO CHANGE:源码本身已符合 Roc 格式化规范,格式化前后无任何改动。快照测试会把源码交给格式化器,任何非规范书写(如多余空格)都会导致此区块出现改写后的代码,从而暴露格式化回归;EXPECTED输出NIL:该测试没有独立的“期望输出”,mono 测试的断言对象主要是MONO/CANONICALIZE/TYPES等中间产物;PROBLEMS输出NIL:编译全程未产生任何诊断问题(无类型错误、无解析错误),与第六节推断结果吻合。
八、背后的测试基础设施
这份快照不是孤立的文本,它在 src/snapshot_tool/main.zig 中被真实执行:
- 类型识别:
# MONO\n~~~roc\n是区块标题常量(src/snapshot_tool/main.zig),遇到即标记为 mono 节点; - 执行路径:mono 测试走“完整模块”路径,先对
SOURCE做解析、规范化和类型检查,再生成MONO区块,并对MONO文本调用validateMonoOutput重新校验(src/snapshot_tool/main.zig),确保单态化输出本身是合法可编译的 Roc; - 单态化取型:通过“Get the defaulted (monomorphized) type string”的接口从活动符号表中取出每个定义/表达式的单态化类型字符串(src/snapshot_tool/main.zig);
- 已知限制:源码中以 TODO 注明“mono 测试暂未运行常量折叠(constant folding),待 zig-16 分支的 ComptimeEvaluator 就绪后补上”(src/snapshot_tool/main.zig),这说明当前
MONO输出反映的是未做编译期求值的真实单态化视图。
九、如何利用这份快照做进一步验证
读者可以沿两条路径继续深入:
- 横向对比同一测试家族:把本文件与 test/snapshots/mono_closure_single_capture.md、test/snapshots/mono_closure_multiple_captures.md、test/snapshots/mono_nested_closures.md 并排阅读,可以系统观察“捕获数量从 0 → 1 → 多”时
e-closure/captures的增长形态,从而理解 Roc 闭包表示与内存布局; - 运行快照测试:在构建好编译器的环境下执行快照测试工具(参见仓库根目录 README.md 与构建文档 BUILDING_FROM_SOURCE.md),对本文件做增删捕获变量的改动后重新生成快照,即可亲手验证“顶级常量不进入捕获集”这一边界行为——注意仓库为只读,实验应在本地副本中进行。
总结
mono_pure_lambda.md用十一个区块、二十余行文本完整记录了一次“纯 lambda”编译过程:SOURCE定义顶级常量one与引用它的 lambda,MONO给出单态化类型标注,TOKENS/PARSE呈现词法与语法证据,CANONICALIZE以“无e-closure、仅e-lookup-local”的形态证明顶级常量不被捕获,TYPES展示约束求解结果。作为type=mono快照家族的一员,它与 test/snapshots/mono_closure_single_capture.md 等文件互为镜像,共同界定了 Roc 闭包捕获系统的语义边界:捕获只针对局部环境,全局常量永远按名解析。
【免费下载链接】rocA fast, friendly, functional language.项目地址: https://gitcode.com/GitHub_Trending/ro/roc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考