Tagged Union Subsets with Comptime in Zig

Mitchell Hashimoto

在 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,
    => {},
  }
}

這樣也可以運作,但我有幾個不滿意的地方:

  1. 這樣很冗長。即使我根本不關心那些終端機專屬的動作,也必須全部列出來。範例文章中只顯示了六個動作,但在實際情況中有多得多。

  2. 從型別的角度來看,我不喜歡 performAppAction 竟然可以接受終端機專屬的動作。如果型別簽章本身就能說明它只接受應用程式層級的動作、以及有哪些動作,會更好。

  3. 同樣的問題也會出現在 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 在這之間取得了很好的平衡,而我認為本文正好展現了這一點。

註腳

  1. https://ziglang.org/documentation/0.13.0/#Tagged-union

  2. https://knowyourmeme.com/memes/how-to-draw-an-owl

  3. @ 開頭的函式在 Zig 中是編譯器內建的函式。

原文由 Mitchell Hashimoto 發布

本文章由 muse-spark-1.2-contributor 進行翻譯