在 Zig 中用 Comptime 实现带标签联合的子集
本文是 Zig comptime 使用场景 系列的一部分。
Zig 支持带标签联合(tagged union)1,而且如果你在 switch 一个带标签联合时没有处理所有可能的情况,Zig 编译器会报错。这是一个很棒的特性,因为当你给联合新增分支时,它能帮你避免 bug。许多语言也支持类似的功能。
然而,有时你只想处理带标签联合的一个子集。你既不想在 switch 语句里使用兜底分支而失去编译器的安全保障,也不想为每个不相关的分支写一个什么都不做的空处理。
在这篇文章中,我会用一个真实场景的例子来说明这一点,以及如何用 Zig 的 comptime 优雅地解决这个问题。这个真实例子来自我的终端项目。
一个真实的例子:快捷键动作
下面是 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,
// Many more in reality, but we'll use the above for this blog post.
};大多数快捷键动作没有关联数据(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 => ...,
// Do nothing for terminal-specific actions.
.new_window,
.close_window,
.scroll_lines,
=> {},
}
}这样也能运行,但我对它有几个不满:
太啰嗦了。即使我不关心那些终端相关的动作,也得把它们全都列出来。这篇博客的例子只展示了六个动作,但实际中要多得多。
从类型的角度看,我不喜欢
performAppAction能接受终端相关的动作。如果类型签名能自我说明它只接受应用全局的动作、并且指明是哪些动作,那就更好了。同样的问题也出现在
performTerminalAction函数中(这里没有展示)。
我真正想要的是类似下面这样的东西:
// NOT REAL ZIG CODE! THIS IS PSUEDO CODE!
const AppAction = union(enum) {
quit: void,
// ... other app actions ...
};
const TerminalAction = union(enum) {
new_window: void,
// ... other terminal actions ...
};
// Union of both. NOTE THIS IS NOT REAL CODE. THIS IS CONCEPTUAL.
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 来创建子集类型:
/// Returns a union type that only contains actions that are scoped to
/// the given scope.
pub fn ScopedAction(comptime s: Scope) type {
const all_fields = @typeInfo(Action).Union.fields;
// Find all fields that are scoped to 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;
}
}
// Build our union
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 调用它,以提取联合中的所有字段。
// Find all fields that are scoped to 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 就是这么酷。
// Build our union
return @Type(.{ .Union = .{
.layout = .auto,
.tag_type = null,
.fields = fields[0..i],
.decls = &. {},
} });@Type 内建函数将一个类型具体化(创建一个新类型)。在这个例子里,我们用积累下来的字段——即具有我们所找作用域的字段——创建一个联合。
有趣的是,我在 Zig 编译器中实现了大量类型具体化的逻辑。如果你用过
@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| {
// Use comptime to prune out invalid actions
if (comptime @unionInit(
Action,
@tagName(tag),
undefined,
).scope() != s) return null;
// Initialize our app action
return @unionInit(
ScopedAction(s),
@tagName(tag),
v,
);
},
}
}我们也来逐步讲解这个函数。
pub fn scoped(self: Action, comptime s: Scope) ?ScopedAction(s) {这里有几个精妙之处。首先,这个函数同时接受运行时参数和 comptime 参数。编译器会强制要求 comptime 参数在编译期已知。
作用域参数必须是 comptime 已知的,因为我们要用它来确定返回值。注意我们在返回类型 ?ScopedAction(s) 中用到了 s。如果 s 没有 comptime 前缀,编译器就会报错,因为我们不能用运行时的值来生成必须是编译期已知的信息(一个类型)。
返回类型中的 ? 表示可选返回值,也就是说返回值可能为 null。对于这个函数,如果 self 动作不属于我们所找的作用域,就返回 null。
switch (self) {
inline else => |v, tag| {inline else 是 Zig 中的一个特例。它是一个兜底分支,会为每个未被显式处理的分支生成运行时代码。由于它是内联的,编译器会把 tag 作为编译期已知的值提供给我们。这很重要,因为我们需要用它来确定动作的作用域。
inline else一开始可能不太好理解。我建议你试着删掉inline关键字,看看编译器会告诉你什么。然后思考一下编译器在做什么,你应该就能更清楚地理解为什么inline是必要的。
// Use comptime to prune out invalid actions
if (comptime @unionInit(
Action,
@tagName(tag),
undefined,
).scope() != s) return null;这里我们再次使用 @unionInit,用 comptime 已知的标签创建一个联合值。然后我们对它调用 scope(),判断它是否属于我们所找的作用域。如果不是,就返回 null。由于这个条件判断是 comptime 的,它会条件性地把代码块的其余部分编译掉,从而避免为无效标签初始化作用域动作时出现类型错误。
我写过一篇专门讲解用 comptime 条件性禁用代码的博客文章,解释了这一模式。
// Initialize our app action
return @unionInit(
ScopedAction(s),
@tagName(tag),
v,
);最后,我们返回初始化好的作用域动作。
与上一节相比,这个函数确实有一些运行时开销。这个函数使用运行时的 switch 语句来过滤掉不属于我们所找作用域的动作。我没有费心去反汇编代码看它如何被降低,但我怀疑编译器会把其中大部分优化掉,因为转换前后的联合具有相同的内存布局。我在一个合成场景中分析过这个函数——在 Ghostty 中无限循环触发一个廉价的键盘快捷键——它没有出现在性能剖析中,所以我并不担心。
结论
Zig 的 comptime 是一个强大的特性,让你能做一些非常惊人的事情。在这篇文章中,我组合了 Zig 的多个 comptime 特性来解决一个真实问题:在 comptime 生成类型、在 comptime 调用对 comptime 无感知的函数、inline else、@unionInit 等等。
这篇文章展示的是 Zig comptime 一个公认较为高级的用法。对 Zig 新手来说,这里用到的技术和语言特性可能会让人应接不暇。我自己也花了一些时间才适应这些特性,但一旦适应,它们强大得不可思议。
我是一个类型系统实用主义者。我相信依靠类型系统来保证正确性是一个强大的工具。同时,我也认为有些语言把这一点做得过头,让类型系统成了负担。Zig 在这方面为我取得了很好的平衡,我相信这篇文章也证明了这一点。
脚注
在 Zig 中以
@开头的函数是编译器内建的。 ↩
随机一篇博客