Roc 编译器限定名解析剖析:局部关联类型为何优先于模块引用

原创2026-09-17 23:52:351,383 阅读

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 时,编译器可能遇到两种截然不同的解释:

  1. Foo 是一个模块,Foo.Bar 是从该模块导入的顶层类型;
  2. 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")))))

三处细节共同构成了"局部优先"的完整证据:

  1. (ty-lookup (name "Foo.Bar") (local)):useBar 注解中的类型查找被标记为 (local)。local 即"局部解析成功"——编译器在本文件内找到了 Foo.Bar,没有发起模块导入查找。这是规则落地的直接标记。
  2. (s-nominal-decl (ty-header (name "Foo"))):顶层类型 Foo 以本名登记为名义类型声明。
  3. (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 描述——"限定名应先在本地检查,再当作模块引用处理"——这条规则的实际工程价值在于:

  1. 消除歧义,保证确定性:当局部类型与外部模块同名时,Foo.Bar 永远有且只有一个含义(局部关联类型),开发者无需猜测解析结果;
  2. 保护类型模块的封装性:类型模块只暴露模块的类型及其关联项(见 docs/langref/modules.md),局部优先的查找顺序确保了 Foo.Bar 这类引用不会意外穿透到同名外部模块,维护了"模块内聚"的语义边界;
  3. 全限定名保证全局唯一:嵌套类型以 模块名.类型路径 的全限定名登记,从根源上避免不同模块间嵌套类型名称冲突;
  4. 快照即规范:EXPECTED/PROBLEMS 均为 NIL、ty-lookup 带 (local) 标记,这些固化输出就是该规则的"可执行文档",任何未来改动若破坏此语义,都会在 zig build run-snapshot-tool 时立即暴露。

对希望深入 Roc 编译器内部的读者,建议以本文快照为起点,配合 docs/langref/types.md(名义类型与嵌套类型)、docs/langref/modules.md(类型模块体系)以及 nominal/ 目录下其余快照交叉阅读,即可完整拼出 Roc 名称解析与类型系统的全貌。

登录后查看全文
roc