Roc 编译器回归测试深度解析:expect 块内的?操作符如何避免 UNUSED VARIABLE 警告(issue 9612)
【免费下载链接】rocA fast, friendly, functional language.项目地址: https://gitcode.com/GitHub_Trending/ro/roc
本篇技术指南围绕 Roc 编译器(Roc 是一门快速、友好、函数式的系统编程语言)的回归测试快照test/snapshots/question_in_expect_no_unused_warning.md展开,剖析一个具体的编译器缺陷修复:在顶层expect块内使用?操作符时,脱糖(desugar)产生的Err载荷绑定不应触发UNUSED VARIABLE警告。读完本文,你将掌握 Roc 快照测试文件的完整结构(META/SOURCE/TOKENS/PARSE/CANONICALIZE/TYPES 等九段式格式)、?操作符在规范化和Try类型上的脱糖机制,以及e_expect_err在expect上下文中的特殊语义,并能在本地运行快照工具验证该回归场景。
快照文件是什么:一段可执行的编译器行为契约
Roc 编译器仓库通过"快照测试"(snapshot test)来固化编译管线的每一阶段行为。根据 test/snapshots/README.md 的说明,快照测试会针对一段 Roc 示例代码,捕获其经过词法分析(tokenization)、解析(parsing)、规范化(canonicalization)、类型检查(type checking)各阶段的输出,并将这些输出以固定格式存为快照文件。当编译器行为发生意外变化时,快照对比即可敏锐地暴露回归(regression)。
本仓库的每个快照文件由#开头的段落构成,常见段落如下:
# META:元信息,description说明本快照验证的行为,type声明快照类型(snippet表示普通代码片段快照);# SOURCE:被测的 Roc 源码;# EXPECTED/# PROBLEMS:期望的诊断结果,NIL表示编译过程没有产生任何报告(reporting.Report);# TOKENS:词法分析产出的 token 流;# PARSE:解析器产出的语法树(S-expression 形式);# FORMATTED:格式化器对该源码的输出;# CANONICALIZE:规范化(canonicalization)后得到的具体中间表示(CIR);# TYPES:类型推断结果。
本文主角快照test/snapshots/question_in_expect_no_unused_warning.md的 META 段落明确写道:
description=Regression test for issue 9612: ? inside a top-level expect must not produce an UNUSED VARIABLE warning for the desugared Err payload type=snippet也就是说,它专门回归验证:顶层expect块内的?操作符,不得为脱糖后产生的Err载荷(payload)绑定发出UNUSED VARIABLE警告。整个文件的EXPECTED与PROBLEMS均为NIL,即该代码在修复后的编译器上应当零诊断、零警告地通过。
被测源码:Try 类型与 expect 块的组合
快照的# SOURCE段落给出了最小化的复现样例,代码虽短,却完整覆盖了类型注解、标签联合、if-else 分支、?后缀操作符和顶层expect:
f : I64 -> Try(I64, [IsNegative]) f = |x| { if x < 0 { Err(IsNegative) } else { Ok(x) } } expect { result = f(3)? result == 3 }逐一解读这段代码:
f的类型注解为I64 -> Try(I64, [IsNegative]):接收一个I64,返回Try,其成功载荷为I64,错误类型是只有一个标签IsNegative的标签联合;- 函数体根据
x是否小于 0 返回Err(IsNegative)或Ok(x),这是 Roc 中手写Try返回值的惯用写法; expect { ... }是 Roc 的顶层测试/断言块,块内最后一个表达式必须是布尔值;- 块内第一行
result = f(3)?使用了?后缀操作符:若f(3)返回Ok则解包成功值赋给result,若返回Err则立即使整个 expect 失败(而不是从某个函数提前返回); - 第二行
result == 3是断言表达式。
# FORMATTED段落展示了 Roc 格式化器对同一源码的输出(缩进改为制表符、if/else分支展开为多行),说明该样例在格式化层面同样保持稳定:
f : I64 -> Try(I64, [IsNegative]) f = |x| { if x < 0 { Err(IsNegative) } else { Ok(x) } } expect { result = f(3)? result == 3 }词法与语法:快照如何固化编译前两阶段
# TOKENS段落把源码切分为 token 序列,可直接观察到?操作符被词法化为NoSpaceOpQuestion(?前不允许有空格,这正是后缀操作符的拼写约束):
LowerIdent,OpColon,UpperIdent,OpArrow,UpperIdent,NoSpaceOpenRound,UpperIdent,Comma,OpenSquare,UpperIdent,CloseSquare,CloseRound, LowerIdent,OpAssign,OpBar,LowerIdent,OpBar,OpenCurly, KwIf,LowerIdent,OpLessThan,Int,OpenCurly,UpperIdent,NoSpaceOpenRound,UpperIdent,CloseRound,CloseCurly,KwElse,OpenCurly,UpperIdent,NoSpaceOpenRound,LowerIdent,CloseRound,CloseCurly, CloseCurly, KwExpect,OpenCurly, LowerIdent,OpAssign,LowerIdent,NoSpaceOpenRound,Int,CloseRound,NoSpaceOpQuestion, LowerIdent,OpEquals,Int, CloseCurly, EndOfFile,# PARSE段落给出对应的语法树。关键节点有两处:
- 函数
f的定义被解析为s-decl包裹的e-lambda,内部是e-if-then-else,其 then 分支是e-apply (e-tag "Err") (e-tag "IsNegative"),else 分支是e-apply (e-tag "Ok") (e-ident "x")——Err/Ok在此处是标签构造器而非类型; expect块被解析为s-expect,块内第一个语句result = f(3)?是s-decl,其右值是一个e-question-suffix节点,包裹e-apply (e-ident "f") (e-int "3")——注意在语法层面,?已经作为一种独立的后缀表达式节点存在,尚未展开。
# TYPES段落给出类型推断结果,与注解完全一致:
(inferred-types (defs (patt (type "I64 -> Try(I64, [IsNegative])"))) (expressions (expr (type "I64 -> Try(I64, [IsNegative])"))))核心机制一:?操作符的规范化脱糖与in_expect标志
快照中最具技术价值的段落是# CANONICALIZE。它揭示了?后缀操作符在规范化阶段如何被展开为e_match(match 表达式):
(s-expect (e-block (s-let (p-assign (ident "result")) (e-match (match (cond (e-call (constraint-fn-var 312) (e-lookup-local (p-assign (ident "f"))) (e-num (value "3")))) (branches (branch (patterns (pattern (p-nominal-external (builtin) (p-applied-tag)))) (value (e-lookup-local (p-assign (ident "#ok"))))) (branch (patterns (pattern (p-nominal-external (builtin) (p-applied-tag)))) (value (e-expect-err (snippet "f(3)?") (e-lookup-local (p-assign (ident "#err"))))))))))))可以看出,f(3)?被脱糖为一个两分支的e_match:
- Ok 分支:模式是内建
Ok标签(#ok),分支值为e-lookup-local直接读取#ok载荷,即"成功即透传"; - Err 分支:模式是内建
Err标签(#err),分支值是一个e-expect-err节点,其snippet字段保存了原始源码文本f(3)?,载荷来自对#err绑定的e-lookup-local。
这里的e-expect-err正是本回归测试的核心。在src/canonicalize/Can.zig中,编译器上下文维护着一个in_expect布尔标志,其注释精确描述了语义(Can.zig):
Track whether we're directly inside a top-level expect (and not inside a lambda body within it). When true, the
?operator desugars toe_expect_erronErr, which fails the enclosing expect instead of returning early.
也就是说:?操作符在普通函数体内的默认语义是"遇到Err就提前从当前函数返回"(对应addTryReturnErr);但当它直接位于顶层expect块内时,并没有一个函数可供提前返回,因此编译器改用e_expect_err节点,在运行时让整个 expect 断言失败,并报告原始源码片段f(3)?。
e_expect_err节点的定义位于 src/canonicalize/Expression.zig,它是 CIR 表达式的一等成员,会被 src/canonicalize/RocEmitter.zig 以<expect_err>的形式序列化,并被DefaultCycles、DependencyGraph、类型检查等下游阶段统一处理。
核心机制二:脱糖产生的 Err 绑定为何不能算"未使用"
本快照要防住的回归(issue 9612)发生在 UNUSED VARIABLE 检查环节。?脱糖后,Err 分支需要把Err的载荷绑定到一个局部模式上(否则无法把载荷传给e_expect_err)。在编译器内部,这个绑定通过addTryErrAssignPatternInCurrentScope在当前作用域创建,再以e_lookup_local读取。
关键在于:这个模式绑定是编译器合成(synthesized)的内部变量,并非用户代码中的标识符。如果未使用分析(unused analysis)把它当作普通用户变量处理,就会误报UNUSED VARIABLE警告,导致合法的expect { x = f()?; ... }写法产生噪音诊断。
修复方式可在src/canonicalize/Can.zig中直接看到。后缀形式的脱糖函数finishSuffixSingleQuestionExpr(Can.zig)在创建完 Err 载荷模式与读取表达式后,显式将该模式登记为"已使用":
const err_assign_pattern_idx = try self.addTryErrAssignPatternInCurrentScope(region); const err_branch_pat_span = try self.appendTryErrPayloadPattern(try_target, err_assign_pattern_idx, region); const err_lookup_idx = try self.env.addExpr(CIR.Expr{ .e_lookup_local = .{ .pattern_idx = err_assign_pattern_idx, } }, region); try self.used_patterns.put(self.env.gpa, err_assign_pattern_idx, {});其中try self.used_patterns.put(self.env.gpa, err_assign_pattern_idx, {})就是把脱糖生成的 Err 载荷模式写入used_patterns集合,从而告诉未使用分析"这个绑定已被消费",消除误报。
随后,分支体依据self.in_expect分流:
const branch_value_idx = if (self.in_expect) blk: { break :blk try self.env.addExpr(CIR.Expr{ .e_expect_err = .{ .expr = err_lookup_idx, .snippet = try self.env.insertString(self.env.getSource(region)), } }, region); } else try self.addTryReturnErr(try_target, err_lookup_idx, region);- 在
expect内:生成e_expect_err,使断言失败并携带原始源码片段; - 在普通函数内:调用
addTryReturnErr生成提前返回Err的代码。
二元形式lhs ? handler的脱糖函数finishSingleQuestionBinop(Can.zig)遵循完全相同的模式:同样创建 Err 载荷模式并写入used_patterns,同样在in_expect为真时把分支体(无论是裸标签构造器? NoFirstError变换出的Tag(#err),还是任意处理函数? |e| ...变换出的handler(#err))包装成e_expect_err。这保证了后缀与二元两种?写法在 expect 内行为一致、且都不会误报未使用变量。
addTryMatch(Can.zig)负责把两分支组装成最终的e_match,并设置skip_exhaustiveness = true(因为Try的两分支天然穷尽),同时通过is_try_suffix = true标记这是?后缀形式脱糖而来。
如何在本地运行与验证该快照
快照工具的使用方式记录在 test/snapshots/README.md,本仓库使用 Zig 构建系统,具体命令如下:
# 生成/更新全部快照 zig build run-snapshot-tool # 仅运行(更新)指定快照文件 zig build run-snapshot-tool -- test/snapshots/question_in_expect_no_unused_warning.md # 依据当前编译器输出的 problems 更新期望值 zig build run-snapshot-tool -- test/snapshots/question_in_expect_no_unused_warning.md --update-expected验证思路:在修复了 issue 9612 的编译器上运行上述命令,question_in_expect_no_unused_warning.md的EXPECTED与PROBLEMS都应保持NIL;若未来某次改动重新引入该缺陷,快照对比会立刻在PROBLEMS中暴露UNUSED VARIABLE诊断,从而拦截回归。README 同时说明:普通快照(type=snippet等)只固化诊断的语义(PROBLEMS段是reporting.Report的规范化 S-expression 序列化),不含渲染细节,因此NIL即代表"编译未产生任何报告"。
小结:一张快照背后的编译器设计
question_in_expect_no_unused_warning.md虽然只是一个 176 行的快照文件,但它同时锁定了 Roc 编译器的三方面行为:
- 语法层:
?是NoSpaceOpQuestion后缀 token,f(3)?解析为e-question-suffix节点; - 规范化层:
?脱糖为 Ok 透传 / Err 载荷读取的两分支e_match;在顶层expect内分支体升级为e_expect_err(携带原始源码片段),在普通函数内则降级为提前返回Err——这一分流由Can.zig中的in_expect上下文标志驱动; - 诊断层:脱糖合成的 Err 载荷模式通过
used_patterns显式登记为已使用,从源头杜绝UNUSED VARIABLE误报,保证expect { result = f(3)?; ... }这类测试代码零警告通过。
对于 Roc 语言使用者,这意味着可以在expect断言块中安全、自然地对Try类型使用?解包;对于编译器开发者,这张快照则是一个可复现、可自动验证的回归防线——这正是快照测试体系(test/snapshots/README.md)的核心价值所在。
【免费下载链接】rocA fast, friendly, functional language.项目地址: https://gitcode.com/GitHub_Trending/ro/roc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考