Zigのcomptimeによるタグ付きユニオンのサブセット
これはZigのcomptimeのユースケースシリーズの一編です。
Zigはタグ付きユニオン1をサポートしており、タグ付きユニオンに対するswitchですべての可能なケースを処理していないと、Zigコンパイラーがエラーを出します。これは、新しいケースがユニオンに追加されたときのバグを防ぐのに役立つ、優れた機能です。同様の機能をサポートしている言語は数多くあります。
ただし、タグ付きユニオンの一部だけを扱いたいこともあります。switch文でキャッチオールケースを使ってコンパイラーの安全性を失いたくはありませんが、すべてのケースに何もしない処理を書くのも避けたいものです。
この記事では、そうした状況の実例と、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が関連付けられています。上方向が負の値、下方向が正の値です。
キーバインドアクションを実行するコードでは、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があるのに気づきましたか? これはキャッチオールケースです。キャッチオールケースがあると、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;「ユニオンを2つに分ければいいのでは?」と思う人もいるでしょう。そうしないのは、コードベースの別の部分で単一の
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,
};
}これは非常に単純な関数です。すべてのケースを含める必要があるため冗長ではありますが、1か所に集約されていて、保守も簡単です。
次に、この関数と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で未定義メモリにアクセスするとコンパイラーエラーになるため、これは安全です。
指定された型の値を作成し、それに対してscope()を呼び出す必要があります。なお、上のscope()関数は、comptimeで実行されているのかランタイムで実行されているのかを知りませんし、その違いを気にもしません。単なる普通の関数です。こういうところがcomptimeのすごいところです。
// Build our union
return @Type(.{ .Union = .{
.layout = .auto,
.tag_type = null,
.fields = fields[0..i],
.decls = &. {},
} });@Type組み込み関数は、型を実体化します(新しい型を作成します)。ここでは、集めたフィールド、つまり対象のスコープを持つフィールドでユニオンを作成しています。
ちなみに、私はZigコンパイラーの型の実体化ロジックの多くを実装しました。
@Typeを使うと、おそらく私が書いたコードを少なくとも1行は通ることになります。
こうして、すべてが終わったところで、元のユニオンのサブセットだけを含む新しい型ができました。次のようになります。
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パラメーターがコンパイル時に既知であることは、コンパイラーが保証します。
スコープのパラメーターがcomptimeで既知でなければならないのは、それを戻り値の決定に使うからです。戻り値の型?ScopedAction(s)でsを使っていることに注目してください。sにcomptime接頭辞がなければ、コンパイラーはエラーになります。ランタイム値を使って、コンパイル時に既知でなければならない情報(型)を生成することはできないからです。
戻り値の型にある?は、オプショナルな戻り値を表します。つまり、戻り値がnullになる可能性があります。この関数では、selfのアクションが対象のスコープに属さない場合にnullを返します。
switch (self) {
inline else => |v, tag| {inline elseは、Zigにおける特殊なケースです。明示的に処理されなかった各ケースについて、ランタイムコードを生成するキャッチオールケースです。inlineなので、コンパイラーはtagをコンパイル時に既知の値として提供します。アクションのスコープを判定するには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で行われるため、ブロックの残りの部分は条件付きでコンパイルから除外されます。その結果、無効なタグでスコープ付きアクションを初期化しようとして型エラーになることもありません。
このパターンについては、comptimeでコードを条件付きに無効化する方法に特化した記事を丸ごと1本書いています。
// Initialize our app action
return @unionInit(
ScopedAction(s),
@tagName(tag),
v,
);最後に、初期化済みのスコープ付きアクションを返します。
前のセクションと比べると、この関数には多少のランタイムコストがあります。対象のスコープに属さないアクションを除外するため、ランタイムのswitch文を使っているからです。これがどのようなアセンブリコードに変換されるかを確認するための逆アセンブルまではしていませんが、変換前後のユニオンはメモリレイアウトが同じなので、コンパイラーが多くの処理を最適化して取り除くのではないかと思います。Ghosttyで安価なキーボードショートカットを無限ループで実行する合成ケースを作ってこの関数をプロファイリングしたところ、プロファイルには現れませんでした。そのため、私は特に心配していません。
まとめ
Zigのcomptimeは、実に驚くべきことを可能にする強力な機能です。この記事では、Zigのcomptimeに関する複数の機能を組み合わせ、現実の問題を解決しました。comptimeでの型の生成、comptimeを意識せずに書かれた関数のcomptimeでの呼び出し、inline else、@unionInitなどです。
この記事で扱ったのは、Zigのcomptimeの、率直に言って高度な使い方です。Zigを始めたばかりの人にとって、ここで使ったテクニックや言語機能は圧倒的に感じられるでしょう。私自身、これらの機能に慣れるまでには少し時間がかかりました。しかし、いったん使いこなせるようになると、非常に強力です。
私は型については実用主義者です。型システムに正しさを保証させることは、強力な手段だと考えています。同時に、型システムを重荷にしてしまうほど、これをやりすぎる言語もあると思います。Zigは私にとってちょうどよいバランスを取っており、この記事がそれを示していると考えています。
脚注
Zigで
@から始まる関数は、コンパイラーに組み込まれた組み込み関数です。 ↩
記事をランダムに読む