使用Comptime实现更多标记联合子集
摘要
本文探讨了如何利用Zig的comptime功能创建标记联合子集,受Mitchell Hashimoto工作的启发,并将该技术应用于MyST解析器的AST遍历。
<p><a href="https://lobste.rs/s/xd5afy/even_more_tagged_union_subsets_with">评论</a></p>
查看缓存全文
缓存时间: 2026/05/19 10:40
# 使用 Comptime 实现更多带标签联合子集
来源:https://sinclairtarget.com/blog/2026/05/18/even-more-tagged-union-subsets-with-comptime/
#### 2026年5月18日
Zig 的 comptime 最酷的功能之一,就是可以创建 Mitchell Hashimoto 所说的"带标签联合子集"。Hashimoto 展示了如何将一个现有的带标签联合(表示 Ghostty 中所有可能的键盘快捷键操作)通过 comptime 推导出更具体的联合,仅代表影响特定终端窗口的操作,或仅代表影响整个应用的操作。
拥有这些更具体的联合类型意味着他可以编写只接受终端特定操作或只接受应用范围操作的函数。这些函数看起来像下面这样,其中 `ScopedAction(.app)` 是一个在 comptime 运行的函数调用,返回更具体的联合类型:
```
1// 处理应用级键盘快捷键的函数。
2pub fn performAppAction(action: ScopedAction(.app)) void {
3 switch (action) {
4 .quit => ...,
5 .close_all_windows => ...,
6 .open_config => ...,
7 .reload_config => ...,
8 }
9}
10
11// 处理限制在终端窗口内的快捷键的函数。
12pub fn performTerminalAction(action: ScopedAction(.terminal)) void {
13 switch (action) {
14 .new_window => ...,
15 .close_window => ...,
16 .scroll_lines => ...,
17 }
18}
```
这种模式之所以有用,主要是因为 Zig 支持穷举 switch。给定像上面的 `performAppAction()` 这样的函数,如果你添加了一个新的应用级操作但忘记在 `switch` 中添加对应的 case,Zig 会(有帮助地)向你报错。如果 `performAppAction()` 接受的是 `Action` 类型的 `action`,那么 Zig 的报错就会失去针对性;它会因为你添加了一个*终端特定*操作而没有匹配 case 而报错。那就不那么有用了,因为在 `performAppAction()` 的上下文中,我们并不关心终端特定操作。
## 树结构中的类似问题
我最近遇到一个情况,看起来很适合使用带标签联合子集。最终我得到的解决方案深受 Hashimoto 的启发,但在几个方面有所不同,我认为这些差异很有意思。
我正在为 MyST(一种新的 Markdown 格式)开发一个解析器。Markdown 解析器通常读取输入文档并直接生成 HTML,但 MyST 有一个规范,定义了已解析 MyST 文档的 AST。我的解析器中有大量代码负责构建和遍历这个 AST。
MyST AST 包含多种不同类型的节点。为了表示这一点,我使用了一个庞大的节点类型带标签联合。
有些节点(标题、内联强调、MyST 指令)允许有子节点,而其他节点(内联代码、图片)则不允许。在编写解析器的过程中,我经常需要编写通过递归操作树上节点来转换 AST 的函数。这类函数希望对特定类型的节点做一件事,而对于其他所有节点(只要它们有子节点),则只需递归到所有后代节点。
我最初实现这些函数的尝试看起来像下面这样。在这个例子中,我正在遍历树,寻找 `myst_directive` 节点,将其交给实现 MyST 指令的代码。
```
1fn transformDirectives(alloc: Allocator, node: *ast.Node) !*ast.Node {
2 switch (node.*) {
3 // 我们要找的节点。将节点传递给一个函数,该函数将通过添加子节点来实现指令。
5 .myst_directive => return try transformBuiltinDirective(alloc, node),
6 // 带有子节点的节点。我们需要递归以寻找更多指令。
7 // `inline` 是一个 Zig 关键字,允许下面的 switch
8 // 分支在所有有子节点的节点类型上多态地工作。
9 inline .block,
10 .heading,
11 .paragraph,
12 .emphasis,
13 .strong,
14 ...,
15 => |node_payload| {
16 for (0..node_payload.children.len) |i| {
17 node_payload.children[i] = try transformDirectives(
18 alloc,
19 node_payload.children[i],
20 );
21 }
22 return node;
23 },
24 // 叶子节点,无需处理。
25 .text,
26 .image,
27 .code,
28 .inline_code,
29 ...,
30 => return node,
31 }
32}
```
这个 switch 有三个分支。为了正确地将所有不同的节点类型分到这三个分支中,我需要把它们全部列举出来。这样很冗长(尤其是在实际中,类型比上面显示的多得多)。更糟糕的是,由于我有很多类似这样的函数,每次向 AST 中添加新的节点类型时,我都需要更新代码库中几十个 switch 语句,以确保每个节点类型都被正确归类为有子节点的节点或叶子节点。
也许我们可以通过使用 `else` case 省略叶子节点类型来改进:
```
1fn transformDirectives(alloc: Allocator, node: *ast.Node) !*ast.Node {
2 switch (node.*) {
3 // 我们要找的节点。将节点传递给一个函数,该函数将通过添加子节点来实现指令。
5 .myst_directive => return try transformBuiltinDirective(alloc, node),
6 // 带有子节点的节点。我们需要递归以寻找更多指令。
7 // `inline` 是一个 Zig 关键字,允许下面的 switch
8 // 分支在所有有子节点的节点类型上多态地工作。
9 inline .block,
10 .heading,
11 .paragraph,
12 .emphasis,
13 .strong,
14 ...,
15 => |node_payload| {
16 for (0..node_payload.children.len) |i| {
17 node_payload.children[i] = try transformDirectives(
18 alloc,
19 node_payload.children[i],
20 );
21 }
22 return node;
23 },
24 else => return node, // 其余节点为叶子节点。
25 }
26}
```
这样不那么冗长了。但是,即使我们不再需要担心叶子节点,我们仍然需要列出有子节点的节点。更重要的是,如果以后我们添加了一个有子节点的节点类型,但*忘记*更新所有 switch 语句,我们将在不知不觉中跳过 AST 的某些子树,因为我们会把一个有子节点的节点当作叶子节点处理。这将是灾难性的!
反过来,列举叶子节点并将有子节点的节点放在 `inline else` 分支中会更好。如果我们添加了新的叶子节点类型却忘记更新 `switch`,我们会得到一个编译错误(因为尝试在 `inline else` 块中访问一个没有子节点的节点的子节点)。但我们仍然需要维护整个代码库中每个类似 `switch` 语句里的叶子节点类型列表。
我们真正想要的是某种方式,既能有一个覆盖所有叶子节点的 `else`,又能有一个覆盖所有有子节点节点的 `inline else`,只留下 `.myst_directive` 这个分支来关心特定的节点类型。
## 使用 Comptime 创建子集
我们可以使用带标签联合子集来解决这个问题。这个场景与 Hashimoto 的不同之处在于,我们并不想限制 `transformDirectives()` 能接受的节点类型。相反,我们想要一种更智能的 switch 语句,它知道如何处理有子节点的节点和叶子节点,而无需我们在每个地方维护这些庞大、脆弱的状态列表。
首先,我们定义一个函数来确定一个节点是否可以拥有子节点,或是叶子节点:
```
1pub const HasChildren = enum {
2 yes,
3 no,
4
5 /// 将节点类型映射到 HasChildren 枚举的值上。
6 ///
7 /// 换句话说,回答一种节点类型是否有子节点。
8 ///
9 /// `NodeType` 是 `Node` 带标签联合的后台枚举。
10 pub fn fromNodeType(node_type: NodeType) HasChildren {
11 return switch (node_type) {
12 .block,
13 .heading,
14 .paragraph,
15 .emphasis,
16 .strong,
17 ...
18 => .yes,
19 .text,
20 .image,
21 .code,
22 .inline_code,
23 ...,
24 => .no,
25 };
26 }
27};
```
这类似于 Hashimoto 的 `scope()` 函数,只不过我选择在枚举自身上声明该函数。它很冗长,因为我们需要列出所有节点类型,但这是从现在开始唯一需要这样做的地方。
接下来,我们需要一个像 Hashimoto 的 `ScopedAction()` 那样的函数,为我们主 `Node` 联合的每个所需子集创建一个带标签联合类型。
```
1/// 返回一个带标签联合,只包含有子节点或没有子节点(取决于 `choice`)的节点类型的字段。
3fn HasChildrenNode(comptime choice: HasChildren) type {
4 const all_fields = @typeInfo(Node).@"union".fields;
5
6 var i: usize = 0;
7 var fields: [all_fields.len]std.builtin.Type.UnionField = undefined;
8 for (all_fields) |field| {
9 const node = @unionInit(Node, field.name, undefined);
10 if (HasChildren.fromNodeType(node) == choice) {
11 fields[i] = .{
12 .name = field.name,
13 .type = *field.type, // 使用指针类型以便修改节点
14 .alignment = field.alignment,
15 };
16 i += 1;
17 }
18 }
19
20 return @Type(.{
21 .@"union" = .{
22 .layout = .auto,
23 // 不能直接使用 `null`,至少在 Zig 0.15.2 中不行
24 .tag_type = HasChildrenNodeType(choice),
25 .fields = fields[0..i],
26 .decls = &.{},
27 },
28 });
29}
```
这个函数与 Hashimoto 的基本相同。我们将 `Node` 联合的字段复制到新的联合子集类型中,跳过那些不匹配 `choice`(节点是否有子节点)的字段。在第 11 行,我们修改字段类型为指针,以便仍然可以修改节点联合的有效载荷;这在 Hashimoto 的示例中不是必需的。在第 24 行,我们还调用了 `HasChildrenNodeType()`,而 Hashimoto 只是使用了 `null`。我发现如果直接将返回联合的 `tag_type` 设为 `null`,Zig 会报错;我怀疑这在新版本的 Zig 中已被禁止。
所以我们还需要为我们的联合定义一个标签类型。`HasChildrenNodeType()` 的定义如下。这个函数使用类似的方法来创建一个枚举子集,它将作为我们带标签联合子集的后台枚举/标签类型。
```
1/// 返回一个枚举,只包含 NodeType 枚举中有子节点或没有子节点(取决于 `choice`)的成员。
3fn HasChildrenNodeType(comptime choice: HasChildren) type {
4 const e_info = @typeInfo(NodeType);
5 const all_fields = e_info.@"enum".fields;
6
7 var i: usize = 0;
8 var fields: [all_fields.len]std.builtin.Type.EnumField = undefined;
9 for (all_fields) |field| {
10 // 这里可以直接通过 `@enumFromInit()` 从字段值推断节点类型,而不需要初始化 Node 再传给 `fromNodeType()`。
11 const node_type: NodeType = @enumFromInt(field.value);
12 if (HasChildren.fromNodeType(node_type) == choice) {
13 fields[i] = field;
14 i += 1;
15 }
16 }
17
18 return @Type(.{ .@"enum" = .{
19 .tag_type = e_info.@"enum".tag_type,
20 .fields = fields[0..i],
21 .decls = &.{},
22 .is_exhaustive = true,
23 } });
24}
```
现在我们已经有了所有这些部件,我们可以使用两种新类型:`HasChildrenNode(.yes)` 和 `HasChildrenNode(.no)`。我们可以定义只接受有子节点节点的函数(使用 `HasChildrenNode(.yes)` 类型的参数),或者只接受叶子节点的函数(使用 `HasChildrenNode(.no)` 类型的参数)。不过我们还不打算止步于此。
## 联合的联合
现在我再向你扔一个联合。别担心,这个不是由可怕的 comptime 函数生成的。它是一个普通的带标签联合,只不过其字段类型使用了上面那些可怕的 comptime 函数。
```
1// 将节点分为有子节点和没有子节点两类。
2const HasChildrenBisection = union(HasChildren) {
3 yes: HasChildrenNode(.yes),
4 no: HasChildrenNode(.no),
5};
```
这个联合将我们的两个子集联合类型重新组合成一个联合的联合。为什么要这样做?我们不是已经有最初的 `Node` 联合类型了吗?嗯,是的,但这个联合允许我们以区分有子节点和没有子节点的方式表示任何可能的节点类型的值。
我们离看到这一切如何衔接起来已经不远了。最后我们需要的是在 `Node` 带标签联合上定义一个函数。这个函数的作用类似于 Hashimoto 在他的 `Action` 带标签联合上定义的 `scoped()` 函数——它将一个 `Node` 缩小到我们的某个联合子集类型。但不同于只返回其中一个联合子集类型,我们将返回上面的 `HasChildrenBisection`:
```
1/// 返回一个联合,将节点分为有子节点和没有子节点两类。
3pub fn hasChildren(self: *Node) HasChildrenBisection {
4 return switch (HasChildren.fromNodeType(self.*)) {
5 .yes => .{
6 .yes = switch (self.*) {
7 inline else => |*n, tag| blk: {
8 if (comptime HasChildren.fromNodeType(tag) != .yes) {
9 unreachable;
10 }
11
12 break :blk @unionInit(
13 HasChildrenNode(.yes),
14 @tagName(tag),
15 n,
16 );
17 },
18 },
19 },
20 .no => .{
21 .no = switch (self.*) {
22 inline else => |*n, tag| blk: {
23 if (comptime HasChildren.fromNodeType(tag) != .no) {
24 unreachable;
25 }
26
27 break :blk @unionInit(
28 HasChildrenNode(.no),
29 @tagName(tag),
30 n,
31 );
32 },
33 },
34 },
35 };
36}
```
这里有一个外层 switch,它使用我们之前定义的 `HasChildren.fromNodeType()` 函数来检查 `self` 根据其标签是否有子节点。无论哪种情况,我们都使用 `@unionInit()` 创建适当的联合子集类型,然后将其设置为返回的 `HasChildrenBisection` 联合的活动字段(`.yes` 或 `.no`)。
外层 switch 中的 `HasChildren.fromNodeType()` 检查在运行时进行。而在 `inline else` 分支体内,`tag` 是一个 comptime 已知的值,那里的 `HasChildren.fromNodeType()` 检查在 comptime 期间发生。你可以将 `inline else` 视为一种告诉编译器自动生成大量 switch 分支的方式;这里我们使用 `unreachable` 基本上是说:"对于根据外层 switch 结果而不应该出现的节点类型,不要为这个节点类型生成内部 switch 分支。" Hashimoto 做了类似的事情,但使用 `return null` 作为提前退出;我们使用 `unreachable`,因为我们可以确定永远不会得到错误类型的节点(因为我们在 `HasChildren.fromNodeType()` 的 switch 中已经提前过滤了)。
译注:为了保持代码原样,代码块中的注释未翻译。
相似文章
在Zig中利用Comptime实现标签联合子集
Mitchell Hashimoto 展示了如何利用 Zig 的 comptime 创建标签联合的子集类型,无需穷举处理即可实现编译时安全。
在Zig中使用Comptime条件禁用代码
Mitchell Hashimoto解释了如何利用Zig的comptime特性在编译时条件禁用代码,并与C和Go中的实现方式进行了比较。
@charliermarsh: /目标:寻找能让你解析器提速20-30%的简单单行优化
Charlie Marsh 分享了一个个人目标:寻找能让解析器提速20-30%的简单单行优化。
CAIT: 儿童-成人互动句法分析工具包
CAIT 是一个开源的句法分析工具包,用于分析儿童-成人互动,包含一个依存句法分析器、词性标注器和构式标注器,这些模型基于 UD-English-CHILDES 树库训练,性能优于 SpaCy 和 Stanza 等通用英语句法分析器。
为什么 Tree-Sitter 不适合程序分析
文章解释了为什么 Tree-sitter 不适合深度程序分析,并指出它会丢弃运算符和关键字等关键标记。文章提倡使用 Cubix 框架作为构建语义分析和重构工具的更稳健替代方案。