Roc 语言%与//运算符优先级解析:从 issue 9712 回归测试看左结合规则与 REPL 快照验证
【免费下载链接】rocA fast, friendly, functional language.项目地址: https://gitcode.com/GitHub_Trending/ro/roc
1 % 10 // 100到底该先算哪个?在 Roc 语言中,这个看似简单的表达式背后隐藏着一个运算符优先级回归测试——repro_issue_9712_mod_floordiv_precedence.md。本文以该快照测试文件为主线,深入 Roc 编译器的词法分析与绑定优先级(binding power)实现,讲解%(取模)与//(整除)为何必须从左到右解析为(1 % 10) // 100,以及 REPL 快照测试如何机械地验证这一行为。读完你将掌握 Roc 运算符优先级的底层机制、快照测试文件的格式规范与更新流程。
背景:issue 9712 到底修复了什么
Roc 是一门"fast, friendly, functional"的语言,其编译器(即当前仓库)在 src/parse/Parser.zig 中统一维护所有二元运算符的优先级。issue 9712 描述的缺陷是:当取模运算符%与整除运算符//出现在同一表达式中时,如果解析器将//的优先级错误地置于%之上,表达式1 % 10 // 100会被解析为1 % (10 // 100)。
按数学计算,10 // 100整除结果为 0,于是整个表达式退化为1 % 0——对零取模,直接导致运行时崩溃。正确的语义要求这两个运算符属于同一优先级组并保持左结合(left-associative),使1 % 10 // 100解析为(1 % 10) // 100:先得1 % 10 = 1,再1 // 100 = 0,安全得到结果 0,全程不崩溃。
逐段解析快照文件:META / SOURCE / OUTPUT / PROBLEMS
该回归测试位于 test/snapshots/repro_issue_9712_mod_floordiv_precedence.md,全文仅四个段落,却完整编码了一次 REPL 求值的全部期望:
# META ~~~ini description=repro for https://github.com/roc-lang/roc/issues/9712—`1 % 10 // 100` must parse left-to-right as (1 % 10) // 100 = 0, not crash type=repl ~~~ # SOURCE ~~~roc » 1 % 10 // 100 ~~~ # OUTPUT 0.0 # PROBLEMS NIL- META:以
ini代码块声明快照元数据。description用一句话记录该测试的动机——复现 issue 9712,强调1 % 10 // 100必须按从左到右解析为(1 % 10) // 100 = 0而非崩溃;type=repl声明这是 REPL 类型的快照(详见 test/snapshots/README.md 中关于快照类型的说明),与file、expr、snippet等类型区分开来。 - SOURCE:
»是 Roc REPL 的输入提示符,其后是要在 REPL 会话中逐行求值的表达式。快照工具正是以»为分隔符切分输入(见下文源码分析)。 - OUTPUT:记录 REPL 求值输出的期望值
0.0。注意结果以浮点形式呈现——这是 REPL 表达式求值器对数值表达式的输出格式(由解释器内省输出路径决定,见 src/snapshot_tool/main.zig 的lirInterpreterInspectedStr调用)。数值上(1 % 10) // 100 = 1 // 100 = 0,与0.0等价。 - PROBLEMS:
NIL表示该表达式在编译全流程(词法、语法、canonicalization、类型检查)中没有产生任何诊断报告。若优先级解析出错导致崩溃或类型错误,此段将不再是NIL,从而让回归测试立即失败。
词法层面://与%如何被切分为 Token
优先级问题的第一层证据在词法分析器 src/parse/tokenize.zig 中。//与%分别被切分为独立的运算符 Token:
- 字符
/的切分逻辑(tokenize.zig):当当前位置的下一个字符也是/时,一次性消费两个字符并生成.OpDoubleSlash(整除运算符//);否则生成单个.OpSlash(普通除法/)。这说明//是单一 Token,而不是两个/的拼接,语法层由此才能对//单独赋优先级。 - 字符
%的切分逻辑(tokenize.zig):消费单个字符并生成.OpPercent(取模运算符%)。
Token 定义本身也印证了这一点:.OpSlash与.OpPercent同属于二元运算符 Token 枚举(tokenize.zig),而.OpDoubleSlash则单独存在。Token 化完成后的 Token 流即为解析器优先级表(下节)的输入。
解析层面:绑定优先级表与乘法组的左结合规则
优先级决策的真正核心在解析器 src/parse/Parser.zig 中。Roc 使用 Pratt 解析器风格的绑定优先级(binding power)表,每个运算符同时记录左右两侧的优先级数值:
pub const BinOpBp = struct { left: u8, right: u8 };相关条目如下(Parser.zig):
| 运算符 Token | left | right | 说明 |
|---|---|---|---|
OpStar(*) | 32 | 33 | 乘法 |
OpSlash(/) | 32 | 33 | 除法 |
OpDoubleSlash(//) | 32 | 33 | 整除 |
OpPercent(%) | 32 | 33 | 取模 |
OpPlus(+) | 22 | 23 | 加法 |
OpBinaryMinus(-) | 22 | 23 | 减法 |
源码注释对此给出了精确的语义解释(Parser.zig):*、/、//、%构成同一个乘法优先级组,组内彼此左结合——因为right > left,当解析器在右操作数位置遇到同组的下一个运算符时,其left(32)无法满足left >= min_bp(此时最小可接受优先级为 33),于是循环终止,不会把后续运算符吞进右操作数。具体到1 % 10 // 100:
- 解析
1,遇到%(left=32, right=33),进入右操作数解析,min_bp设为 33; - 解析
10,遇到//(left=32)——但32 >= 33不成立,//不被吞入%的右操作数; %的解析完成,得到(1 % 10),随后//作为同一层级的下一运算符继续左结合解析,最终得到(1 % 10) // 100。
同理,该组整体绑定比加法组(22/23)更紧,即a + b % c // d会先归组为a + ((b % c) // d)。这一设计与常见 C 系语言的乘法/除法/取模同级左结合语义一致,也是 Roc "friendly" 设计理念在运算符层面的体现。
实操:运行与更新这条 REPL 快照测试
快照测试的驱动工具由 build.zig 暴露为zig build run-snapshot-tool步骤,用法与说明详见 test/snapshots/README.md:
# 运行/重新生成全部快照 zig build run-snapshot-tool # 只处理当前这条优先级回归快照 zig build run-snapshot-tool -- test/snapshots/repro_issue_9712_mod_floordiv_precedence.md # 用当前编译器输出覆盖期望值(OUTPUT 段) zig build run-snapshot-tool -- test/snapshots/repro_issue_9712_mod_floordiv_precedence.md --update-expected # 带解释器 trace 调试 REPL 快照(仅限 type=repl 且单文件) zig build run-snapshot-tool -- test/snapshots/repro_issue_9712_mod_floordiv_precedence.md --trace-eval工作流程上(.rules 亦有记载):当修改编译器后,运行zig build run-snapshot-tool,通过 diff 审查快照变化是否为预期行为。若解析器优先级表被误改,本快照的OUTPUT会从0.0变为崩溃错误文本或异常值,测试随即暴露回归。快照工具的 CLI 校验逻辑对--trace-eval有强约束:它只能用于type=repl快照且只能搭配单个文件使用(src/snapshot_tool/main.zig),与本文件类型完全匹配。
源码视角:REPL 快照的机械执行流程
REPL 快照并非手写结果,而是由工具按固定管线生成与校验的,核心实现位于 src/snapshot_tool/main.zig:
- 分发:
processSnapshotFile检测content.meta.node_type == .repl后转交processReplSnapshot(main.zig),后者依次生成 HTML 包装、META、SOURCE、OUTPUT、PROBLEMS 各段(main.zig)。 - 切分输入:
generateReplOutputSection以»为分隔符把 SOURCE 切成若干条 REPL 输入(main.zig),每条输入经snapshotReplStep分发为"定义 / 表达式 / 语句表达式"三种求值模式之一(main.zig)。 - 求值输出:表达式最终由 LIR 解释器求值并经
lirInterpreterInspectedStr转成字符串(main.zig),多步输出之间以独立的---行分隔,空输出(如纯类型标注步骤)也按位置保留(main.zig)。 - 更新或校验:在
--update-expected模式下把实际输出写回快照文件;在校验模式下与实际输出比对并报告差异。
正是这套管线保证了"语法语义正确性"与"渲染展示效果"分离:普通快照(含本文件)的PROBLEMS段记录的是诊断的规范化 S-expression(见 test/snapshots/README.md),不含任何盒式绘制字符、ANSI 转义等渲染细节——NIL即"无任何报告"的规范表示。本快照的PROBLEMS: NIL因此意味着该表达式在全部编译阶段均无错误与警告。
小结:一条快照背后的完整语义链条
回到最初的问题:1 % 10 // 100在 Roc 中之所以安全求值为0.0,是因为从词法(//作为独立 Token 切分)到语法(*、/、//、%共享 left=32/right=33 的同一乘法优先级组且组内左结合)再到快照测试(OUTPUT: 0.0、PROBLEMS: NIL)形成了一条完整的、可机械验证的语义链条。对编译器开发者而言,这条快照既是 issue 9712 的回归防线,也是理解 Roc 运算符优先级设计的绝佳入口;对语言使用者而言,记住"乘法组内一律从左到右"这一规则即可安全混用%、//、*、/,必要时用括号显式表达意图。
进一步探索建议:
- 阅读 src/parse/tokenize.zig 中运算符 Token 的完整枚举与
%、//的切分分支; - 阅读 src/parse/Parser.zig 中
BinOpBp定义、bin_op_bp_table全表及分组注释; - 阅读 test/snapshots/README.md 了解普通快照与 reporting 快照的分工;
- 浏览 test/snapshots/binops.md 等同类快照,对比其他二元运算符的解析期望。
【免费下载链接】rocA fast, friendly, functional language.项目地址: https://gitcode.com/GitHub_Trending/ro/roc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考