Roc 编译器限定名解析剖析:局部关联类型为何优先于模块引用
Roc 编译器限定名解析剖析:局部关联类型为何优先于模块引用
Roc 是一门快速、友好的函数式语言,其编译器以"模块即类型"为核心理念组织代码。当你在类型注解中写出 Foo.Bar 这样的限定名(qualified name)时,编译器必须在"Foo 模块导出的类型"与"Foo 类型关联块(associated block)内嵌套定义的类型"之间做出裁决。本文以仓库中一条编译器快照测试 test/snapshots/nominal/nominal_associated_vs_module.md 为主干,逐段还原其在分词、解析、规范化、类型推断各阶段的内部输出,并结合 docs/langref/types.md 与 docs/langref/modules.md 说明这一名称解析规则的底层原理。读完你既能读懂 Roc 快照测试的完整格式,也能理解限定名解析的优先级设计及其在类型模块体系中的意义。
一、快照测试的定位:一条"语义级"编译行为规范
Roc 仓库将编译器行为验证组织为大量快照测试(snapshot test),统一存放在 test/snapshots/ 目录下。根据 test/snapshots/README.md 的说明,快照测试通过"捕获每个编译阶段的输出"来验证编译器行为:源码会依次经过 tokenization(分词)、parsing(语法解析)、canonicalization(规范化)、type checking(类型检查) 等阶段,每个阶段的结果都被固化在快照文件里。当编译器行为发生意外变化时,这些快照能立刻暴露回归。
我们的主角 test/snapshots/nominal/nominal_associated_vs_module.md 属于 nominal/ 子目录下的"名义类型(nominal type)"专题快照。它在 META 中一句话点明测试目标:
description=Qualified names should be checked locally before treating as mod references
翻译过来即:限定名应先检查是否为局部(local)引用,再考虑当作模块(mod)引用来处理。 这是本次文章要论证的核心编译语义。
二、歧义从何而来:关联类型块与类型模块的碰撞
要理解这条规则的价值,先要弄清 Foo.Bar 为什么存在歧义。
2.1 名义类型与关联项块
Roc 中用 := 声明名义类型(nominal type),Foo := [Whatever] 定义了一个仅由 Whatever 标签构成的标签联合类型。名义类型可以附带一个以 . 开头的关联项块(associated block),在块内嵌套定义关联类型、方法或常量。以快照中的源码为例:
Foo := [Whatever].{
Bar := [Something]
}
这里 Foo 是顶层名义类型,Bar 是 Foo 的关联块里嵌套定义的类型,通过点号访问写作 Foo.Bar。docs/langref/types.md 的 "Nested Nominal Types" 一节明确支持这种写法,并给出更复杂的例子,例如 Geometry.Rectangle 这类嵌套类型的构造与访问:
Geometry := [].{
Point := { x: F64, y: F64 }.{
origin : Point
origin = { x: 0, y: 0 }
}
Rectangle := { top_left: Point, bottom_right: Point }.{
area : Rectangle -> F64
area = |{ top_left, bottom_right }|
width = bottom_right.x - top_left.x
height = bottom_right.y - top_left.y
width * height
}
}
rect = Geometry.Rectangle.{ top_left: Geometry.Point.origin, bottom_right: { x: 10, y: 10 } }
文档原文强调:"Nested types are accessed using dot notation",嵌套类型常用于把相关类型组织到同一命名空间下。
2.2 类型模块:另一个提供 Foo.Bar 的途径
Roc 的模块体系是"模块即类型"的。docs/langref/modules.md 说明:每个 .roc 文件都是一个模块,其中 type module(类型模块) 以大写文件名命名,例如 Url.roc,并且该文件顶层必须声明 Url := 或 Url ::,这个类型被称作"模块的类型(the module's type)"。类型模块对外只暴露这一个类型及其全部关联项:
# Url.roc 内部
Url := Str
Url.ParseErr := [Invalid, Missing] # 其他模块以 Url.ParseErr 访问
Url.from_str : Str -> Result Url [Url.ParseErr]
Url.from_str = ...
于是问题出现了:在另一个模块中写下 Foo.Bar 时,编译器可能遇到两种截然不同的解释:
Foo是一个模块,Foo.Bar是从该模块导入的顶层类型;Foo是一个名义类型,Foo.Bar是其关联块中嵌套定义的类型。
这正是 nominal_associated_vs_module.md 这条快照要钉死的边界情形:当两者都可行时,局部关联类型优先。
三、逐段解读快照:从源码到类型推断的完整证据链
快照文件采用 # 小节名 + ~~~语言 代码块的固定格式,下面按编译阶段逐一还原。
3.1 SOURCE:触发歧义的测试源码
Foo := [Whatever].{
Bar := [Something]
}
# This should resolve to the local Foo.Bar, not try to import from a Foo mod
useBar : Foo.Bar
useBar = Something
源码分为两部分:先声明带关联类型的 Foo,再声明一个带类型注解的顶层值 useBar : Foo.Bar,并将其绑定为标签 Something。源码中的注释直接给出了测试预期:Foo.Bar 应解析为局部定义的 Foo.Bar(即 Foo 关联块中的 Bar),而不是尝试从名为 Foo 的模块导入。
注意 Bar := [Something] 与 useBar = Something:Something 既是 Bar 的唯一标签,也是 useBar 的值,二者类型上恰好匹配,这为类型检查阶段"恰好通过"埋下伏笔。
3.2 EXPECTED 与 PROBLEMS:编译必须零报告
# EXPECTED
NIL
# PROBLEMS
NIL
根据 test/snapshots/README.md,普通快照(type=file 等)的 PROBLEMS 小节保存的是语义诊断(reporting.Report)的规范化 S 表达式序列化结果,NIL 表示本次编译没有产生任何报告——既无错误也无警告。这意味着:把 useBar : Foo.Bar 解析为局部关联类型,是完全合法、顺理成章的编译行为。如果编译器错误地走"模块引用"路线去查找名为 Foo 的模块,必然产生"模块未找到"或"类型不匹配"的报告,PROBLEMS 就不会是 NIL 了。
3.3 TOKENS:词法层面对 Foo.Bar 的识别
UpperIdent,OpColonEqual,OpenSquare,UpperIdent,CloseSquare,Dot,OpenCurly,
UpperIdent,OpColonEqual,OpenSquare,UpperIdent,CloseSquare,
CloseCurly,
LowerIdent,OpColon,UpperIdent,NoSpaceDotUpperIdent,
LowerIdent,OpAssign,UpperIdent,
EndOfFile,
分词器把源码切成词法单元(token)。最值得关注的是第 4 行的 LowerIdent,OpColon,UpperIdent,NoSpaceDotUpperIdent:useBar : Foo 之后紧跟一个 NoSpaceDotUpperIdent(无空格的点号加大写标识符)词元。也就是说,词法阶段就已识别出 Foo.Bar 这种"UpperIdent . UpperIdent 无空格连写"的限定名形态,并将其作为一个独立词元保存,供后续语法层处理。
3.4 PARSE:语法树中关联块的形态
(file
(type-mod)
(statements
(s-type-decl
(header (name "Foo")
(args))
(ty-tag-union
(tags
(ty (name "Whatever"))))
(associated
(s-type-decl
(header (name "Bar")
(args))
(ty-tag-union
(tags
(ty (name "Something")))))))
(s-type-anno (name "useBar")
(ty (name "Foo.Bar")))
(s-decl
(p-ident (raw "useBar"))
(e-tag (raw "Something")))))
语法树(S 表达式形式)清楚展示了两个关键结构:
- 顶层
s-type-decl(类型声明)的header名为Foo,主体是ty-tag-union标签联合,末尾挂着associated子节点;associated内部又是一个完整的s-type-decl,名为Bar。这证实 关联块在语法层就是一个"类型内嵌类型"的嵌套声明。 - 顶层
s-type-anno(类型注解)把useBar的类型记作ty (name "Foo.Bar"),即一个限定名类型,具体含义留给后续阶段裁决。
FORMATTED 小节展示的格式化结果与源码逐字一致,说明该写法同时满足 Roc 的格式规范。
3.5 CANONICALIZE:裁决发生的核心阶段(关键证据)
规范化(canonicalization)阶段负责把语法树转成语义明确的中间表示。这一节的输出是整个快照的题眼:
(can-ir
(d-let
(p-assign (ident "useBar"))
(e-tag (name "Something"))
(annotation
(ty-lookup (name "Foo.Bar") (local))))
(s-nominal-decl
(ty-header (name "Foo"))
(ty-tag-union
(ty-tag-name (name "Whatever"))))
(s-nominal-decl
(ty-header (name "nominal_associated_vs_mod.Foo.Bar"))
(ty-tag-union
(ty-tag-name (name "Something")))))
三处细节共同构成了"局部优先"的完整证据:
(ty-lookup (name "Foo.Bar") (local)):useBar注解中的类型查找被标记为(local)。local即"局部解析成功"——编译器在本文件内找到了Foo.Bar,没有发起模块导入查找。这是规则落地的直接标记。(s-nominal-decl (ty-header (name "Foo"))):顶层类型Foo以本名登记为名义类型声明。(s-nominal-decl (ty-header (name "nominal_associated_vs_mod.Foo.Bar"))):嵌套类型Bar被登记为全限定名(fully qualified name)nominal_associated_vs_mod.Foo.Bar,其中nominal_associated_vs_mod是编译器从快照文件名派生出的模块名(此处经过快照工具的后处理截断,nominal_associated_vs_module变为nominal_associated_vs_mod)。全限定名保证了即使多个模块定义各自的Foo.Bar,在全局符号表中也不会互相污染。
综合来看:类型查找先命中本文件内 Foo 的关联块 Bar(标记 local),再以模块前缀 + 类型路径的全限定名登记到底层声明表。若改走模块导入路线,这里出现的就应是 (ty-lookup (name "Foo.Bar") (mod …)) 之类的标记以及一条"模块 Foo 不存在"的报告——但快照中并没有。
3.6 TYPES:类型推断阶段确认最终类型
(inferred-types
(defs
(patt (type "Foo.Bar")))
(type_decls
(nominal (type "Foo")
(ty-header (name "Foo")))
(nominal (type "Foo.Bar")
(ty-header (name "nominal_associated_vs_mod.Foo.Bar"))))
(expressions
(expr (type "Foo.Bar"))))
类型推断结果再次印证:顶层定义 useBar 的模式类型是 Foo.Bar;类型声明表中有两条名义类型——Foo 和 Foo.Bar(后者的 ty-header 指向全限定名 nominal_associated_vs_mod.Foo.Bar);表达式 Something 的类型同样被推断为 Foo.Bar。useBar 的注解与定义值类型完全一致,整条编译链路干净收尾。
四、相邻快照互证:规则在不同模块名下的稳定性
4.1 对照 nominal_associated_lookup_type.md
同目录下的 test/snapshots/nominal/nominal_associated_lookup_type.md 是这条规则的"文件命名版"验证。它的 META 指定了 type=file:Foo.roc,即把快照源码当作名为 Foo.roc 的文件编译——这正是 docs/langref/modules.md 所描述的类型模块形态:文件名大写,顶层声明 Foo :=。其源码与前者几乎相同,只把 useBar 改名为 myBar:
Foo := [Whatever].{
Bar := [Something]
}
myBar : Foo.Bar
myBar = Something
其 CANONICALIZE 输出为:
(can-ir
(d-let
(p-assign (ident "myBar"))
(e-tag (name "Something"))
(annotation
(ty-lookup (name "Foo.Bar") (local))))
(s-nominal-decl
(ty-header (name "Foo"))
(ty-tag-union
(ty-tag-name (name "Whatever"))))
(s-nominal-decl
(ty-header (name "Foo.Bar"))
(ty-tag-union
(ty-tag-name (name "Something")))))
两相对照,可得出两点确认与一点推断:
- 确认:无论文件是普通模块还是类型模块,
(ty-lookup … (local))都成立,"局部优先于模块引用"的规则不随文件形态变化。 - 确认:
nominal_associated_lookup_type.md中嵌套类型Bar的全限定名是Foo.Bar(模块名Foo+ 类型路径Foo.Bar中,顶层类型名与模块名重合)。而nominal_associated_vs_module.md中全限定名是nominal_associated_vs_mod.Foo.Bar。从源码结构看,这符合"模块名 + 从顶层类型开始的嵌套路径"的拼接规律:当顶层类型恰与模块同名(类型模块)时,路径自然压缩为Foo.Bar。这也与 docs/langref/modules.md 中"类型模块的类型就是模块本身,外部通过Url.ParseErr访问其关联项"的描述一致。
4.2 更多相关快照
nominal/ 目录还包含大量姊妹快照,从不同侧面覆盖关联类型的解析行为,可作为深入研究的入口:
nominal_associated_decls.md、nominal_associated_deep_nesting.md:关联块声明与深层嵌套;nominal_associated_lookup_decl.md、nominal_associated_lookup_nested.md、nominal_associated_lookup_in_containers.md:限定名查找在声明、嵌套结构、容器中的表现;nominal_associated_alias_within_block.md、nominal_associated_type_alias.md、nominal_associated_value_alias.md:关联块内的类型别名与值别名;nominal_import_type.md、nominal_import_wildcard.md:模块导入类型与通配符导入;type_module_associated_items_exposed.md及type_module_nominal_field_depends_on_*.md系列:类型模块对外暴露关联项及嵌套类型依赖关系。
需要说明的是,上述文件中的具体断言属于各快照自身内容,本文不逐一展开,仅指出其主题归属,供读者按需检索。
五、快照测试的运行与维护方式
如果你希望亲手验证本文结论,test/snapshots/README.md 给出了完整的操作方式。快照工具基于 Zig 构建系统:
# 生成全部快照
zig build run-snapshot-tool
# 只更新指定快照文件
zig build run-snapshot-tool -- test/snapshots/nominal/nominal_associated_vs_module.md
# 依据最新诊断报告更新 EXPECTED
zig build run-snapshot-tool -- <file_path> --update-expected
# 调试 REPL 快照(仅 type=repl)时启用解释器追踪
zig build run-snapshot-tool -- <repl_snapshot.md> --trace-eval
此外 README 还解释了快照体系的设计分权:普通快照(type=file、snippet、expr 等)只钉住诊断的"语义"(即 PROBLEMS 中的规范 S 表达式),不包含渲染器细节(无边框字符、ANSI 转义、换行、标记等);而 reporting 快照(reporting/ 目录,type=reporting)钉住渲染器的"呈现",分别输出 REPORT/CLI/MARKDOWN/HTML/LSP 五种格式。这种拆分保证"语义改动"与"呈现改动"不会混在同一批文件里。同时,快照后处理会全局性地把被移除的 mod 头关键字重写为 mod——这也是我们会在 S 表达式输出中看到 mod 相关标记的原因之一。
六、规则总结与工程意义
回到 test/snapshots/nominal/nominal_associated_vs_module.md 的 META 描述——"限定名应先在本地检查,再当作模块引用处理"——这条规则的实际工程价值在于:
- 消除歧义,保证确定性:当局部类型与外部模块同名时,
Foo.Bar永远有且只有一个含义(局部关联类型),开发者无需猜测解析结果; - 保护类型模块的封装性:类型模块只暴露模块的类型及其关联项(见 docs/langref/modules.md),局部优先的查找顺序确保了
Foo.Bar这类引用不会意外穿透到同名外部模块,维护了"模块内聚"的语义边界; - 全限定名保证全局唯一:嵌套类型以
模块名.类型路径的全限定名登记,从根源上避免不同模块间嵌套类型名称冲突; - 快照即规范:
EXPECTED/PROBLEMS均为NIL、ty-lookup带(local)标记,这些固化输出就是该规则的"可执行文档",任何未来改动若破坏此语义,都会在zig build run-snapshot-tool时立即暴露。
对希望深入 Roc 编译器内部的读者,建议以本文快照为起点,配合 docs/langref/types.md(名义类型与嵌套类型)、docs/langref/modules.md(类型模块体系)以及 nominal/ 目录下其余快照交叉阅读,即可完整拼出 Roc 名称解析与类型系统的全貌。