在 Zig 中以 Comptime 建立 Tagged Union 子集
本文為 Zig comptime(編譯期) 用例 系列之一。
Zig 支援 tagged union(標籤聯集)1,若你對 tagged union 使用 switch 卻未處理所有可能的分支,Zig 編譯器會報錯。這是一項很棒的功能,因為它能協助你在聯集中新增分支時避免產生錯誤。許多語言也支援類似的功能。
然而,有時你只想處理 tagged union 的一個子集。你既不想在 switch 陳述式中使用 catch-all 分支而失去編譯器的安全檢查,也不希望對每個分支都寫一個無操作的處理。
在本文中,我將以一個真實世界的範例來說明這種情境,並展示如何利用 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,用來指定向上(負值)或向下(正值)捲動的行數。
這種 tagged union 的作法很不錯,因為在執行按鍵綁定動作的程式碼中,我可以對 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 嗎?那是一個 catch-all 分支,當存在 catch-all 分支時,若我在 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 存取了 undefined 記憶體,編譯器會報錯,因此這樣做是安全的。
我們需要建立一個具有指定型別的值,才能對其呼叫 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 參數在編譯期就必須已知。
scope 參數必須是 comptime 已知,因為我們用它來決定回傳值。請注意我們在回傳型別 ?ScopedAction(s) 中使用了 s。如果我們沒有在 s 前加上 comptime 前綴,編譯器就會報錯,因為我們無法使用執行期的值來產生必須在編譯期已知的資訊(型別)。
回傳型別中的 ? 表示可選的回傳值。這表示回傳值可能是 null。對於這個函式來說,若 self 動作不屬於我們要尋找的作用範圍,就會回傳 null。
switch (self) {
inline else => |v, tag| {inline else 是 Zig 中的一個特殊分支。它是一個 catch-all 分支,會為每個未被明確處理的分支產生執行期程式碼。由於它是 inline,編譯器會將 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,它會在編譯期條件式地排除區塊的其餘部分,因此我們不會因為對無效的標籤初始化 scoped action 而得到型別錯誤。
我曾寫過一篇專門探討以 comptime 條件式停用程式碼的文章,其中解釋了這種模式。
// Initialize our app action
return @unionInit(
ScopedAction(s),
@tagName(tag),
v,
);最後,我們回傳初始化後的 scoped action。
相較於前一節,這個函式確實會產生一些執行期成本。這個函式使用執行期的 switch 陳述式來過濾掉不屬於我們所需作用範圍的動作。我沒有特地去反組譯程式碼來觀察它是怎麼被降低(lower)的,但我猜測編譯器會將其中大部分最佳化掉,因為轉換後的聯集具有相同的記憶體配置。我曾在一個合成的情境中做過效能分析,讓 Ghostty 無限循環執行一個成本極低的鍵盤快捷鍵,這個函式並未出現在效能分析結果中,所以我並不擔心它。
結論
Zig 的 comptime 是一項強大的功能,能讓你做到許多令人驚艷的事。在本文中,我結合了 Zig 的多項 comptime 功能來解決一個真實世界的問題:於 comptime 產生型別、在 comptime 呼叫與 comptime 無關的函式、inline else、@unionInit 等等。
本文展示的是 Zig comptime 比較進階的用法。對於剛接觸 Zig 的人來說,這裡使用的技巧與語言功能可能會讓人感到資訊量過大。我也花了一些時間才熟悉這些功能,但一旦掌握,你會發現它們非常強大。
我是個務實的型別派。我相信依賴型別系統來確保正確性是一個強大的工具。同時,我也認為有些語言把這件事做得太過頭,讓型別系統反而成為一種負擔。Zig 在這之間取得了很好的平衡,而我認為本文正好展現了這一點。
註腳
以
@開頭的函式在 Zig 中是編譯器內建的函式。↩
隨機一篇部落格