在 Zig 中使用 Comptime 實作 Tagged Union 子集
原文由 Mitchell Hashimoto 于 發布,訂閱此部落格
本文是 Zig comptime 應用案例 系列的其中一篇。
Zig 支援 tagged union1,而且如果你對 tagged union 做 switch 卻沒有處理所有可能的分支,Zig 編譯器就會報錯。這是一個很棒的功能,因為當你在 union 中新增分支時,它能幫你避免 bug。許多語言也都支援類似的功能。
不過,有時候你只想處理 tagged union 的其中一個子集。你既不想在 switch 陳述式中使用 catch-all 分支而失去編譯器的安全檢查,也不想要為了每個分支都寫一個空操作(noop)來處理。
在這篇文章中,我會用一個實際的例子來說明這種情境,以及如何用 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 這個 union 做 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 最初實作按鍵綁定動作的方式。
為何需要 Union 子集
最近,我需要新增一個按鍵綁定動作,它的適用範圍不是針對單一終端機,而是整個 Ghostty 應用程式。作為重構的一部分,我想把 performAction 拆開,讓終端機專屬的動作由終端機結構來處理,而應用程式層級的動作則由應用程式結構來處理。
問題在於,Action 這個 union 同時包含了終端機專屬和應用程式層級的動作。一開始,我的做法大概像這樣:
pub fn performAppAction(action: Action) void {
switch (action) {
.quit => ...,
.close_all_windows => ...,
.open_config => ...,
.reload_config => ...,
else => {},
}
}這樣寫可以運作,但你有看到那個 else 嗎?那是一個 catch-all 分支,一旦有了 catch-all,之後就算我在 Action union 中新增了動作,卻忘記在 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;有人可能會問:「為什麼不直接分成兩個獨立的 union 就好?」答案是,因為程式碼庫的其他部分需要處理單一的
Action。舉例來說,設定檔的解析器就必須能夠解析所有按鍵綁定的動作。
用 Comptime 建立 Union 子集
我們可以用 Zig 的 comptime 來建立這些子集型別。
我們其實也可以反過來,先建立好子集型別,再用 comptime 來組合出完整的 union 型別。留給讀者當作練習,鼓勵你試試看。這種做法同樣可行,只是會遇到不同的邊界情況與使用體驗上的取捨。
首先,我們需要知道每個動作屬於哪個範圍。為此,我們可以新增一個函式來判斷:
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,然後用它來產生一個只包含該範圍內動作的新 union。
const all_fields = @typeInfo(Action).Union.fields;@typeInfo 這個內建函式3會在 comptime 回傳一個型別的結構化資訊。我們在 Action 上呼叫它,來取出 union 中的所有欄位。
// 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 union 中的所有欄位。對於每個欄位,我們判斷它是否屬於我們感興趣的範圍,如果是,就把它儲存起來。
@unionInit 是一個內建函式,會用一個在 comptime 就已知的欄位名稱和值來建立 union 的值。我們傳入的值是 undefined(未初始化的記憶體),因為我們根本不在乎值本身;我們的 scope() 函式只會看 tag。如果我們在 comptime 期間真的去存取了 undefined 的記憶體,編譯器就會報錯,所以這樣做是安全的。
我們需要用指定的型別建立一個值,這樣才能在它上面呼叫 scope()。注意,上面那個 scope() 函式根本不知道自己是在 comptime 還是 runtime 被執行的。它也不在乎,它就只是一個普通的函式。Comptime 就是這樣他媽的酷。
// Build our union
return @Type(.{ .Union = .{
.layout = .auto,
.tag_type = null,
.fields = fields[0..i],
.decls = &. {},
} });@Type 這個內建函式會具現化一個型別(也就是建立一個新類型)。在這個例子中,我們用剛剛收集到的欄位——也就是符合我們所需範圍的那些欄位——來建立一個 union。
有趣的是,Zig 編譯器中許多型別具現化的邏輯就是 我實作的。如果你有用過
@Type,很有可能就執行到了我寫的某行程式碼。
最後,經過這一切,我們就得到了一個只包含原始 union 子集的新型別。長這樣:
pub fn performAppAction(action: ScopedAction(.app)) void {
switch (action) {
.quit => ...,
.close_all_windows => ...,
.open_config => ...,
.reload_config => ...,
}
}漂亮。我們現在有了一個簡潔的 switch,編譯器會強制檢查是否處理了所有分支,而且函式簽名本身就清楚說明它只接受應用程式層級的動作。當我們在 Action union 中新增動作時,編譯器會提醒我們去更新 scope() 函式,之後其他部分就會自動正常運作。
另外,雖然這應該很明顯:所有這些邏輯都是在編譯時期發生的。做這些事情完全沒有任何 runtime 成本。因為我們只在編譯時期執行這些邏輯,它也不會產生任何額外的 runtime 程式碼。
轉換為子集
最後一個挑戰:給定一個完整的 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) {這裡有幾個巧妙的地方。首先,這個函式同時接收 runtime 和 comptime 參數。編譯器會強制要求 comptime 參數必須在編譯時期就已知。
scope 參數必須在 comptime 就已知,因為我們要用它來決定回傳值。注意我們在回傳型別 ?ScopedAction(s) 中使用了 s。如果 s 前面沒有加上 comptime,編譯器就會報錯,因為我們不能用一個 runtime 的值來產生必須在編譯時期就已知的資訊(型別)。
回傳型別中的 ? 表示這是一個可選的(optional)回傳值,代表回傳值可能是 null。對這個函式來說,如果 self 這個動作不屬於我們要找的範圍,就會回傳 null。
switch (self) {
inline else => |v, tag| {inline else 是 Zig 中的一個特殊寫法。它是一個 catch-all 分支,會為每個沒有被明確處理的分支產生對應的 runtime 程式碼。因為它是 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 已知的 tag 來建立一個 union 的值。接著我們在它上面呼叫 scope(),來判斷它是否屬於我們要找的範圍。如果不是,就回傳 null。由於這個條件判斷是在 comptime 進行的,它會條件式地把區塊的其餘部分編譯掉,這樣我們就不會因為嘗試為無效的 tag 初始化 scoped action 而得到型別錯誤。
我曾寫過一篇專門講解 如何用 comptime 條件式地停用程式碼 的部落格文章,其中解釋了這個模式。
// Initialize our app action
return @unionInit(
ScopedAction(s),
@tagName(tag),
v,
);最後,我們回傳初始化好的 scoped action。
相較於前一節,這個函式確實會有一些 runtime 成本。這個函式使用了一個 runtime 的 switch 陳述式來過濾掉不屬於目標範圍的動作。我沒有特別去反組譯程式碼來看它最終是如何被降低(lower)的,但我猜編譯器會把其中大部分都最佳化掉,因為轉換前後的 union 具有相同的記憶體佈局。我曾在一個模擬情境中分析過這個函式——在 Ghostty 中用一個成本很低的鍵盤快捷鍵做無限迴圈——它並沒有出現在效能分析結果中,所以我並不擔心。
結論
Zig 的 comptime 是一個非常強大的功能,讓你能做到許多令人驚艷的事。在這篇文章中,我結合了 Zig 的多項 comptime 功能來解決一個實際問題:在 comptime 產生型別、在 comptime 呼叫對 comptime 無感知的函式、inline else、@unionInit 等等。
這篇文章展示的,坦白說,是 Zig comptime 比較進階的用法。對於剛接觸 Zig 的人來說,這裡用到的技巧和語言特性可能會讓人感到資訊量過大。我自己也花了一段時間才熟悉這些功能,但一旦掌握之後,就會發現它們非常強大。
我是個型別務實主義者。我相信善用型別系統來確保正確性是一個強大的工具。同時,我也認為有些語言在這方面做得太過頭,反而讓型別系統變成一種負擔。對我來說,Zig 在這之間取得了很好的平衡,我相信這篇文章正好展現了這一點。
註解
以
@開頭的函式在 Zig 中是編譯器內建的函式。↩
隨機一篇部落格
留言
登入後參與討論