Tagged Union Subsets with Comptime in Zig

Mitchell Hashimoto

ZigのcomptimeでTagged Unionのサブセットを作る

原文は Mitchell Hashimoto により に公開されました。 このブログを購読する

これはZig comptimeのユースケースに関する連載の一部です。

Zigはtagged unionをサポートしており1、tagged unionをswitchする際にすべてのケースを処理していないと、Zigコンパイラはエラーを出します。これはunionに新しいケースが追加されたときのバグを防ぐのに役立つ素晴らしい機能です。多くの言語が同様の機能を備えています。

しかし、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 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;

「なぜ単に2つの別々の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を受け取り、そのスコープを持つアクションのみを含む新しいaction 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()関数はタグだけを見るからです。もしcomptimeにundefinedなメモリへアクセスしようとすればコンパイラエラーになるので、これは安全です。

与えられた型の値を作成してscope()を呼び出せるようにする必要があります。上記のscope()関数は、それがcomptimeに実行されているのかランタイムに実行されているのかをまったく意識していないことに注目してください。気にする必要もありません。ただの普通の関数です。comptimeがクソかっこいいのはそういうところです。

  // Build our union
  return @Type(.{ .Union = .{
    .layout = .auto,
    .tag_type = null,
    .fields = fields[0..i],
    .decls = &. {},
  } });

@Typeビルトインは型を具現化(新しい型を作成)します。ここでは、蓄積したフィールド、つまり探しているスコープを持つフィールドを使ってunionを作成しています。

余談ですが、Zigコンパイラの型の具現化ロジックの多くは私が実装しました。@Typeを使えば、おそらく私が書いたコードの少なくとも1行は実行されています。

最後に、こうした処理を経て、元のunionのサブセットのみを含む新しい型が得られます。次のようになります。

pub fn performAppAction(action: ScopedAction(.app)) void {
  switch (action) {
    .quit => ...,
    .close_all_windows => ...,
    .open_config => ...,
    .reload_config => ...,
  }
}

素晴らしい。コンパイラによって網羅性が保証されたシンプルなswitchが得られ、関数シグネチャ自体がアプリケーション全体のアクションのみを受け付けることを自己文書化しています。Action unionに新しいアクションを追加したときは、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を使っていることに注目してください。もしscomptimeプレフィックスがなければ、コンパイル時に既知でなければならない情報(型)を生成するためにランタイム値を使うことはできないので、コンパイラがエラーを出します。

戻り値の型にある?は、オプショナルな戻り値を表します。つまり、戻り値が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で既知のtagからunionの値を作成します。次にそれに対してscope()を呼び出して、探しているスコープに属するかどうかを判定します。属さない場合はnullを返します。この条件分岐はcomptimeなので、無効なtagに対してscoped actionを初期化した際の型エラーを避けるため、ブロックの残りが条件付きでコンパイルから除外されます。

このパターンについては、comptimeで条件付きにコードを無効化する方法に特化したブログ記事を書いています。

      // Initialize our app action
      return @unionInit(
        ScopedAction(s),
        @tagName(tag),
        v,
      );

最後に、初期化したscoped actionを返します。

前のセクションとは異なり、この関数には多少のランタイムコストがあります。この関数はランタイムの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」を使用して翻訳されました。

コメント