Tagged Union Subsets with Comptime in Zig

Mitchell Hashimoto

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

這樣也能運作,但我對這種寫法有幾個不滿意的地方:

  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;

有人可能會問:「為什麼不直接分成兩個獨立的 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 在這之間取得了很好的平衡,我相信這篇文章正好展現了這一點。

註解

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

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

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

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

留言