Roc 编译器快照测试实战:从 `|_, _| 42` 看双参闭包的完整编译流水线
2026/9/20 2:29:48 网站建设 项目流程

【免费下载链接】roc

A fast, friendly, functional language.

项目地址:https://gitcode.com/GitHub_Trending/ro/roc
点击查看免费下载

Roc 是一门快速、友好、函数式的编程语言(仓库自述 "A fast, friendly, functional language"),其编译器源码仓库以 Zig 实现,并配套了一套"快照测试(snapshot tests)"体系来锁定编译器的每一阶段行为。本文以仓库中的快照文件 test/snapshots/expr/two_arg_closure.md 为骨架,逐段拆解|_, _| 42这样一个双参数闭包从词法分析、语法解析、格式化、规范化到类型推断的完整编译流水线。读完本文,你将掌握 Roc 编译器快照文件的格式约定与读写方法,理解闭包与下划线占位参数在源码层面的表示形态,并能据此自行编写、更新和排查expr类快照测试。

快照测试是什么:为什么编译器需要它

Roc 编译器是一个多阶段流水线:源码先被词法分析为 token 流,再被语法分析为 AST,随后经过**规范化(canonicalize)**得到 CIR,最后交由类型检查与各后端(LLVM、Wasm、x86_64/aarch64 等)生成目标代码。任何一个阶段的输出发生变化,都可能引发难以察觉的回归。

快照测试的职责,正如 test/snapshots/README.md 所述:通过把每个编译阶段针对特定 Roc 代码样例的输出固化下来,检测编译器行为发生意外变化时的回归。每个快照文件同时记录"期望输出",一旦编译器输出与快照不符,测试即可精确定位到是哪个阶段出了问题。

值得强调的是,快照测试分为两类(见 test/snapshots/README.md 的 "Semantic diagnostics vs renderer output" 一节):

  • 普通快照type=filesnippetexpr等):捕获诊断的语义PROBLEMS段存放每个reporting.Report的规范化 S-expression 序列化(见src/reporting/report_sexpr.zig),不包含任何渲染器细节(无边框字符、ANSI 转义、换行或标记),NIL表示编译没有产生任何报告。它回答的问题是:编译器是否产生了正确的诊断?
  • 报告快照type=reporting,位于reporting/目录):固定渲染器的输出,逐节固化REPORTCLIMARKDOWNHTMLLSP各格式的布局细节。

本文讨论的 two_arg_closure.md 属于普通快照中的expr类型。

快照文件的七段式结构

每个expr快照文件由若干以#开头的段落构成,two_arg_closure.md完整展示了全部七段。下表汇总了各段的作用:

段落作用本例内容
# METAINI 格式元信息,声明descriptiontypedescription=two_arg_closuretype=expr
# SOURCE被编译的 Roc 源码片段\|_, _\| 42
# EXPECTED期望的诊断/错误(语义诊断的 S-expression)NIL(无错误)
# PROBLEMS实际产生的问题报告NIL(无问题)
# TOKENS词法分析产出的 token 序列OpBar,Underscore,Comma,Underscore,OpBar,Int,EndOfFile
# PARSE语法分析产出的 AST(Clojure 风格 S-expression)(e-lambda ...)
# FORMATTED格式化器的输出NO CHANGE(格式已规范)
# CANONICALIZE规范化后的 CIR(含类型名解析后的常量)(e-lambda (args (p-underscore) (p-underscore)) (e-num (value "42")))
# TYPES类型推断结果_arg, _arg2 -> a,带from_numeral约束

注:部分快照还可能出现type=file(整个文件编译)、type=snippet(如 test/snapshots/001.md 同时带FORMATTED段)等变体;repl类型快照则额外支持--trace-eval解释器跟踪(见 test/snapshots/README.md)。

核心示例:|_, _| 42到底是什么

|_, _| 42是 Roc 中一个双参数闭包||之间是参数列表,两个参数都用下划线_占位(即"忽略该参数"),函数体直接返回整数常量42。它不引用任何参数,因此是最简形式的闭包,非常适合用来观察编译流水线如何处理"参数 + 常量"结构。

逐段解读快照内容:

1. META 与 SOURCE

description=two_arg_closure type=expr
|_, _| 42

type=expr表示这是一个表达式级快照——编译单元是单个表达式而非整个文件(文件级样例可对比 test/snapshots/001.md 中type=snippetfoo = "one"声明)。EXPECTEDPROBLEMS均为NIL,说明该表达式合法且无任何诊断报告

2. TOKENS:词法阶段

OpBar,Underscore,Comma,Underscore,OpBar,Int, EndOfFile,

词法器把源码切成 7 个 token:两个OpBar|)、两个Underscore_)、一个Comma,)、一个Int42)以及文件结束符EndOfFile。注意两点:

  • _在 Roc 词法中是独立 tokenUnderscore,而不是标识符;
  • 42被识别为Int(整数字面量),token 定义可在 src/parse/tokenize.zig 中看到OpBar的枚举项。

3. PARSE:语法阶段生成 e-lambda AST

(e-lambda (args (p-underscore) (p-underscore)) (e-int (raw "42")))

解析器把OpBar ... OpBar识别为 lambda 表达式节点e-lambda,其args子节点是两个下划线模式p-underscore(模式 kernel 的underscore变体),函数体是整数表达式e-int (raw "42")——raw保留源码中的原始文本"42"

这个 AST 的 S-expression 序列化实现在 src/parse/AST.zig:e-lambda节点先 pushargs标签,再遍历patternSlice(a.args)逐个输出参数模式。而解析器识别OpBar的逻辑在 src/parse/Parser.zig:遇到OpBar后若紧跟着又一个OpBar(即||),说明是零参数 lambda;否则进入expr_lambda_args状态,把后续内容当作参数模式列表解析,直到闭合的OpBar

4. FORMATTED:格式化器判定无需改动

NO CHANGE

|_, _| 42已经符合 Roc 格式化器的规范排版(紧凑参数、单一空格间距),因此输出为NO CHANGE。对照 test/snapshots/001.md 的FORMATTED段可以直观看到"有改动"与"无改动"两种输出形态的差异。

5. CANONICALIZE:规范化阶段的语义变化

(e-lambda (args (p-underscore) (p-underscore)) (e-num (value "42")))

这是最能体现"规范化"意义的一段:e-int (raw "42")变成了e-num (value "42")raw字段消失,取而代之的是value——整数文本42被解析为数值内容。从源码看,规范化的数字字面量路径在 src/canonicalize/Can.zig:未带类型后缀的整数字面量被构造为CIR.Expr{ .e_num = .{ .value = value_content, .kind = .int_unbound } },即未绑定具体整数类型的数字节点,同时通过recordNumeralLiteral登记该字面量,供后续类型检查阶段做"数字字面量约束"处理。

参数模式则保持为p-underscore:规范化代码在 src/canonicalize/Can.zig 中把underscore模式原样转换为Pattern.underscore = {}(空结构体,不携带任何名字或区域信息),因为下划线本身就是"占位、无绑定"的语义。

6. TYPES:类型推断揭示闭包的完整签名

(expr (type "_arg, _arg2 -> a where [a.from_numeral : Numeral -> Try(a, [InvalidNumeral(Str)])]"))

类型检查器为这个闭包推断出的类型是:

  • 入参:_arg_arg2——两个由下划线占位符生成的不同类型变量(尽管源码都是_,每个占位符各引入一个独立类型变量);
  • 返回:类型变量a
  • 约束:a.from_numeral : Numeral -> Try(a, [InvalidNumeral(Str)])——因为函数体42是未绑定类型的数字字面量,返回类型a必须满足"可以从Numeral转换而来"的约束,转换失败的错误类型为InvalidNumeral(Str)

from_numeral正是 Roc 内建数字类型的统一入口:在 src/build/roc/Builtin.roc 附近可以看到num.from_numeral : Builtin.Num.Numeral -> Try(num, [InvalidNumeral(Str)])这类声明被注入到各数值类型(U8、I32、F64、Dec 等)的能力中;类型检查器在 src/check/Check.zig 处解析.from_numeral方法,并在 src/check/Check.zig 用专门的工作列表(open_literal_vars/open_numeral_literals)跟踪这些由字面量转换产生的柔性类型变量。这也是 Roc "数字字面量在未确定具体类型前保持多态"这一设计的关键实现。

纵向对比:闭包快照家族

把 two_arg_closure.md 与同目录下的其他闭包快照对比,能更清楚地理解参数形态对编译流水线的影响:

快照文件源码PARSE 参数形态TYPES 结果
lambda_simple.md\|x\| x + 1(p-ident (raw "x"))a -> a where [a.plus : a, b -> a, b.from_numeral : Numeral -> Try(b, [InvalidNumeral(Str)])]
lambda_with_args.md\|x, y\| x + y(p-ident (raw "x"))(p-ident (raw "y"))a, b -> a where [a.plus : a, b -> a]
two_arg_closure.md(本文)\|_, _\| 42两个(p-underscore)_arg, _arg2 -> a where [a.from_numeral : ...]

三个样例揭示了三条规律:

  1. 命名参数被解析为p-ident,规范化后变为p-assign (ident ...)(绑定一个局部变量);下划线参数始终是p-underscore,规范化后依旧如此,因为它不产生任何绑定。
  2. e-int/e-ident等"解析期节点"在规范化阶段统一转换为e-num/e-lookup-local等 CIR 节点,raw文本被替换为语义化value
  3. 类型变量命名:下划线占位符生成_arg_arg2这类匿名类型变量名,且每个_各自独立(两个_不共享同一类型变量)。

如何运行与维护这些快照

快照测试的命令全部集中在zig buildrun-snapshot-tool目标(详见 test/snapshots/README.md 的 Usage 一节):

# 生成/校验所有快照 zig build run-snapshot-tool # 只处理指定快照文件 zig build run-snapshot-tool -- test/snapshots/expr/two_arg_closure.md # 用编译器当前输出更新快照中的 EXPECTED 段 zig build run-snapshot-tool -- test/snapshots/expr/two_arg_closure.md --update-expected # REPL 快照调试:启用解释器跟踪(仅 type=repl、单文件) zig build run-snapshot-tool -- test/snapshots/repl/repl_record_field_access.md --trace-eval

补充两个使用要点:

  • SOURCE中需要嵌入回车符字节,可在META中添加source_escapes=true,并在SOURCE中把每个回车写作\r
  • --trace-eval要求使用 debug 构建(默认即开启跟踪),release 构建需通过-Dtrace-eval=true显式开启。

日常维护中,zig build run-snapshot-tool -- <file> --update-expected是把"行为变更"固化为"新期望"的标准流程:先人工确认新输出是正确的,再更新快照,避免用脚本盲改掩盖回归。测试与模糊测试基础设施同样围绕这些快照展开,例如 test/fuzzing/fuzz-tokenize.zig、test/fuzzing/fuzz-parse.zig 会以快照格式为基准做随机输入验证。

从快照反推编译流水线:一张全景图

综合本文所有源码证据,|_, _| 42的完整旅程可以概括为:

  1. 词法(src/parse/tokenize.zig):产出OpBar, Underscore, Comma, Underscore, OpBar, Int, EndOfFile
  2. 解析(src/parse/Parser.zig):OpBar触发 lambda 参数解析状态机,参数作为模式列表存储,产出e-lambdaAST(序列化见 src/parse/AST.zig)。
  3. 格式化:对已符合规范排版的输入输出NO CHANGE
  4. 规范化(src/canonicalize/Can.zig):e-inte-numkind = int_unbound)并登记数字字面量;下划线模式原样转为Pattern.underscore(src/canonicalize/Can.zig)。
  5. 类型检查(src/check/Check.zig):两个_各自引入类型变量_arg_arg2;数字字面量经from_numeral约束(src/build/roc/Builtin.roc)最终定型为多态返回类型a

这条链路正是 Roc 编译器"快速、友好、函数式"承诺的底层支撑:语法简洁(|_, _| 42一行表达忽略双参的闭包)、类型安全(每个数字字面量的多态性都由from_numeral约束显式管理)、且每个阶段都可被快照测试精确观测。

结语

test/snapshots/expr/two_arg_closure.md 虽然只有几十行,却浓缩了 Roc 编译器从 token 到类型推断的完整流水线信息。通过读懂它的每一段、对照同目录的 lambda_simple.md 与 lambda_with_args.md、再深入 src/parse/Parser.zig、src/canonicalize/Can.zig 与 src/check/Check.zig 的实现,你既能掌握快照测试的读写与排查方法,也能从"表达式粒度"理解闭包、下划线占位符与数字字面量多态在 Roc 编译器内部的真实表示。

【免费下载链接】roc

A fast, friendly, functional language.

项目地址:https://gitcode.com/GitHub_Trending/ro/roc
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询