在Zig中利用Comptime实现标签联合子集
摘要
Mitchell Hashimoto 展示了如何利用 Zig 的 comptime 创建标签联合的子集类型,无需穷举处理即可实现编译时安全。
暂无内容
查看缓存全文
缓存时间: 2026/05/16 03:40
# 使用 Zig Comptime 实现带标签联合子集
来源:https://mitchellh.com/writing/zig-comptime-tagged-union-subset
Zig 支持带标签联合[^1],并且如果你在 `switch` 中未处理所有可能的情况,Zig 编译器会报错。这是一个很棒的特性,因为它可以帮助你在联合类型新增分支时避免错误。许多语言都支持类似的功能。
然而,有时你想处理的是该带标签联合的一个子集。你不想为了使用 `switch` 语句中的通配分支而丢失编译器的安全性,但也不想为每个不关心的分支都写一个空操作。
在这篇文章中,我将用一个实际例子说明这种情况,以及如何利用 Zig 的 comptime 优雅地解决它。这个实际例子来自我的终端项目[Ghostty](https://mitchellh.com/ghostty)。
## 一个真实例子:键绑定动作(Keybind Actions)
以下是在 [Ghostty](https://mitchellh.com/ghostty) 中表示键绑定动作的方式。键绑定动作是键盘快捷键触发的事件。例如:`ctrl+n` 可能触发打开新窗口的动作。
```
pub const Action = union(enum) {
quit: void,
new_window: void,
close_window: void,
close_all_windows: void,
open_config: void,
reload_config: void,
scroll_lines: i16,
// 实际上还有很多,但本文只使用上述这些。
};
```
大多数键绑定动作没有关联数据(`void`),但有些有。例如,`scroll_lines` 动作关联了一个 `i16`,用于指定向上(负数)或向下(正数)滚动的行数。
这种带标签联合的好处在于,在执行键绑定动作的代码中,我可以对 `Action` 联合进行 `switch`,如果未处理所有分支,编译器会报错。这确保当我添加新的键绑定动作时,也会添加处理它的代码。
```
pub fn performAction(action: Action) void {
switch (action) {
.quit => ...,
.new_window => ...,
.close_window => ...,
.close_all_windows => ...,
.open_config => ...,
.reload_config => ...,
.scroll_lines => |amount| ...,
}
}
```
这是 Ghostty 最初实现键绑定动作的方式。
## 为什么需要联合子集
最近,我需要添加一个新的键绑定动作,它不针对某个特定终端,而是针对整个 Ghostty 应用程序。为此,我想重构 `performAction`,让终端特有的动作由终端结构处理,应用程序级别的动作由应用程序结构处理。
问题在于 `Action` 联合既包含终端特有的动作,也包含应用程序级别的动作。最初,我写了类似这样的代码:
```
pub fn performAppAction(action: Action) void {
switch (action) {
.quit => ...,
.close_all_windows => ...,
.open_config => ...,
.reload_config => ...,
else => {},
}
}
```
这*可以工作*,但你看到那个 `else` 了吗?这是一个通配分支,当存在通配分支时,编译器无法在 `Action` 联合新增动作且忘记在 `performAppAction` 中处理时报错。
接下来,我穷举列出所有我不关心的动作,并执行空操作:
```
pub fn performAppAction(action: Action) void {
switch (action) {
.quit => ...,
.close_all_windows => ...,
.open_config => ...,
.reload_config => ...,
// 对终端特有的动作不做任何操作。
.new_window,
.close_window,
.scroll_lines,
=> {},
}
}
```
这*可以工作*,但我有几个问题:
1. 代码冗长。我必须列出所有终端特有的动作,即使我不关心它们。博文示例只展示了几个动作,但实际上还有很多。
2. 从类型角度来看,我不喜欢 `performAppAction` 可以接受终端特有的动作。如果函数签名能自我说明它只接受应用程序级别的动作及其具体范围,会更好。
3. 同样的问题也出现在 `performTerminalAction` 函数中(这里没有展示)。
我真正想要的是像下面这样的东西:
```
// 这不是真正的 Zig 代码!这是伪代码!
const AppAction = union(enum) {
quit: void,
// ... 其他 app 动作 ...
};
const TerminalAction = union(enum) {
new_window: void,
// ... 其他终端动作 ...
};
// 两者的联合。注意这不是真正的代码,只是概念。
const Action = AppAction | TerminalAction;
```
有人可能会问:“为什么不用两个独立的联合?”答案是代码库的其他部分需要处理单一的 `Action`。例如,配置文件解析器需要能够解析所有键绑定动作。
## 编译时(Comptime)联合子集
我们可以利用 Zig 的 comptime 来创建子集类型。(另一种方式:我们也可以先创建子集类型,再用 comptime 构建完整的联合类型。作为读者的练习,我鼓励你尝试一下。它也完全可行,但存在不同的边界情况和权衡。)
首先,我们需要知道每个动作属于哪个范围。为此,我们可以引入一个新函数:
```
pub const Scope = enum { app, terminal };
pub fn scope(action: Action) Scope {
return switch (action) {
.quit, .close_all_windows, .open_config, .reload_config => .app,
.new_window, .close_window, .scroll_lines => .terminal,
};
}
```
这是一个非常直接的函数。它需要写下每个分支,因此较为冗长,但集中在一个位置,易于维护。
接下来,我们可以使用这个函数和 comptime 来创建我们的子集类型:
```
/// 返回只包含给定范围动作的联合类型。
pub fn ScopedAction(comptime s: Scope) type {
const all_fields = @typeInfo(Action).Union.fields;
// 找出所有作用域为 s 的字段
var i: usize = 0;
var fields: [all_fields.len]std.builtin.Type.UnionField = undefined;
for (all_fields) |field| {
const action = @unionInit(Action, field.name, undefined);
if (action.scope() == s) {
fields[i] = field;
i += 1;
}
}
// 构建我们的联合
return @Type(.{ .Union = .{
.layout = .auto,
.tag_type = null,
.fields = fields[0..i],
.decls = &.{},
} });
}
```
抱歉,我知道这有点“画猫头鹰的其余部分”的感觉[^2]。下面我逐步解释。
```
pub fn ScopedAction(comptime s: Scope) type {
```
首先,Zig 中大写标识符惯用作类型名。编译器并不强制要求,只是风格习惯。当一个函数大写首字母时,表示它会返回一个类型。
这是一个返回类型的函数。这就是 Zig 实现泛型的方式。由于 Zig 具有 comptime 代码执行能力,并且 comptime 代码执行是*完整语言*,我们可以使用在 comptime 接受参数并返回……类型的函数。
在本例中,我们的函数接受一个 `Scope`,并利用它生成一个新的动作联合,该联合只包含该范围的动作。
```
const all_fields = @typeInfo(Action).Union.fields;
```
内置函数 `@typeInfo`[^3] 在 comptime 返回类型的结构化信息。我们在 `Action` 上调用它,以提取联合中的所有字段。
```
// 找出所有作用域为 s 的字段
var i: usize = 0;
var fields: [all_fields.len]std.builtin.Type.UnionField = undefined;
for (all_fields) |field| {
const action = @unionInit(Action, field.name, undefined);
if (action.scope() == s) {
fields[i] = field;
i += 1;
}
}
```
现在我们遍历 `Action` 联合中的所有字段。对于每个字段,判断它是否属于我们关注的范围。如果是,则保存它。
`@unionInit` 是一个内置函数,它使用 comptime 已知的字段名和值创建一个联合值。我们使用的值是 `undefined`(未初始化内存),因为我们不关心值;我们的 `scope()` 函数只查看标签。如果在 comptime 中访问未定义的内存,会导致编译器错误,因此这是安全的。
我们需要创建一个给定类型的值,以便在其上调用 `scope()`。注意,上面的 `scope()` 函数完全不知道它是在 comptime 还是运行时执行。它不关心这些。它只是一个普通函数。Comptime 在这方面真是酷极了。
```
// 构建我们的联合
return @Type(.{ .Union = .{
.layout = .auto,
.tag_type = null,
.fields = fields[0..i],
.decls = &.{},
} });
```
内置函数 `@Type` 具体化(创建)一个类型。在本例中,我们创建一个联合,其字段是我们累积的、具有所需范围的字段。
**最后,经过这一切之后,**我们得到了一个新的类型,它只包含原始联合的一个子集。它看起来像这样:
```
pub fn performAppAction(action: ScopedAction(.app)) void {
switch (action) {
.quit => ...,
.close_all_windows => ...,
.open_config => ...,
.reload_config => ...,
}
}
```
太棒了。我们有一个简单的 `switch`,编译器强制穷尽性,并且我们的函数签名自我说明它只接受应用程序级别的动作。当我们在 `Action` 联合中添加新动作时,编译器会报错要求更新 `scope()` 函数,之后所有其他代码都会正常工作。
另外,需要明确的是:所有这些逻辑都是在*编译时*执行的。这样做没有任何运行时开销。由于我们只在编译时执行此逻辑,也不会产生任何新的运行时代码。
## 转换为子集
最后一个挑战:给定一个完整的 `Action`,如何将它转换为一个类型化的子集,例如 `ScopedAction(.app)`?下面的函数实现了这一点:
```
pub fn scoped(self: Action, comptime s: Scope) ?ScopedAction(s) {
switch (self) {
inline else => |v, tag| {
// 利用 comptime 剪除无效的动作
if (comptime @unionInit(
Action,
@tagName(tag),
undefined,
).scope() != s) return null;
// 初始化我们的 app 动作
return @unionInit(
ScopedAction(s),
@tagName(tag),
v,
);
},
}
}
```
我们也来逐步分析。
```
pub fn scoped(self: Action, comptime s: Scope) ?ScopedAction(s) {
```
这里有几个巧妙之处。首先,这个函数同时接受运行时和 comptime 参数。编译器会强制要求 comptime 参数在编译时已知。
范围参数必须编译时已知,因为我们用它来确定返回值。注意我们在返回类型 `?ScopedAction(s)` 中使用了 `s`。如果 `s` 前面没有 `comptime` 前缀,编译器会报错,因为我们不能使用运行时值来生成编译时必须已知的信息(类型)。
返回类型中的 `?` 表示可选的返回值,即返回值可能为 null。在这个函数中,如果 `self` 动作不属于我们寻找的范围,我们将返回 null。
```
switch (self) {
inline else => |v, tag| {
```
`inline else` 是 Zig 中的一个特殊情况。它是一个通配分支,为每个未显式处理的分支生成运行时代码。由于它是内联的,编译器会提供 `tag` 作为编译时已知的值。这一点很重要,因为我们需要使用它来确定动作的范围。
`inline else` 一开始可能难以理解。我建议你尝试删除 `inline` 关键字,看看编译器会告诉你什么。然后思考编译器在做什么,就会更清楚为什么需要 `inline`。
```
// 利用 comptime 剪除无效的动作
if (comptime @unionInit(
Action,
@tagName(tag),
undefined,
).scope() != s) return null;
```
这里我们再次使用 `@unionInit` 创建一个具有 comptime 已知标签的联合值。然后调用 `scope()` 来判断它是否属于我们寻找的范围。如果不是,则返回 null。由于这个条件语句是 comptime 的,它会条件性地编译掉其余的块,因此不会因为为无效标签初始化作用域动作而出现类型错误。
```
// 初始化我们的 app 动作
return @unionInit(
ScopedAction(s),
@tagName(tag),
v,
);
```
最后,我们返回初始化后的作用域动作。
与上一节相比,这个函数确实有*一些*运行时开销。它使用运行时 switch 语句来过滤掉不属于目标范围的动作。我没有反汇编代码来看它是如何降级的,但我怀疑编译器会优化掉很多,因为转换后的联合具有相同的内存布局。我在一个合成案例中对这个函数进行了性能分析:让 Ghostty 无限循环执行一个轻量级键盘快捷键,结果它没有出现在性能分析中,所以我不担心。
## 结论
Zig 的 comptime 是一个强大的特性,能让你完成一些非常了不起的事情。在这篇文章中,我结合了 Zig 的多个 comptime 特性来解决一个真实问题:在 comptime 生成类型、在 comptime 调用不关心 comptime 的函数、`inline else`、`@unionInit` 等等。
这篇文章展示了 Zig 的 comptime 一种较高的用法。对于 Zig 的新手来说,这里使用的技术和语言特性可能会让人不知所措。我自己也花了一些时间才适应这些特性,但一旦掌握,它们就会变得极其强大。
我是一个类型实用主义者。我相信依靠类型系统来强制执行正确性是一种强大的工具。同时,我也认为有些语言在这方面走得太远,使类型系统成为一种负担。对我来说,Zig 达到了一个很好的平衡,我认为这篇文章也体现了这一点。
[^1]: https://ziglang.org/documentation/0.13.0/#Tagged-union ↩
[^2]: https://knowyourmeme.com/memes/how-to-draw-an-owl ↩
[^3]: 在 Zig 中,以 `@` 开头的函数是编译器内置函数。 ↩
相似文章
使用Comptime实现更多标记联合子集
本文探讨了如何利用Zig的comptime功能创建标记联合子集,受Mitchell Hashimoto工作的启发,并将该技术应用于MyST解析器的AST遍历。
在Zig中使用Comptime条件禁用代码
Mitchell Hashimoto解释了如何利用Zig的comptime特性在编译时条件禁用代码,并与C和Go中的实现方式进行了比较。
Zig 的数组结构体 (2024)
说明 Zig 的 comptime 和类型反射如何支持创建像 MultiArrayList 这样的数组结构体 (SoA) 数据结构,从而提升高性能应用中的缓存性能。
@lukeed05: 为庆祝 @rolldown_rs 1.0 发布,隆重推出 `comptime`——一个受 Zig 启发的构建时评估原语,作为 Vite 和 Rolldown 插件提供……
Luke Edwards 发布了 `comptime`,这是一个受 Zig 启发的构建时评估原语,适用于 Vite 和 Rolldown,允许在构建过程中执行代码,将调用位置替换为评估后的值。
Zig 示例教程
通过带注释的示例,对 Zig 编程语言进行实践性介绍,涵盖从基础到高级的主题。灵感来源于 Go by Example。