- Zigの**コンパイル時(comptime)**メタプログラミングは、ランタイムコードのように読める一方で、構造体フィールドの合計のような反復コードを、手書きのC関数のように効率的な実行コードへと変換できる
- 別個のジェネリクス構文の代わりに、
comptime T: typeとanytypeを活用してジェネリックプログラミングを扱う点が、Zig comptimeの中核的な用途になっている
- ランタイム、comptime、ビルドシステムが同じZig言語を共有しており、Fizz Buzzのような関数もコンパイル中に実行して、あらかじめ計算済みの配列にできる
- コンパイラは可能な部分を先に評価し、ランタイム値が必要な部分だけを、あとでマシンコードになる出力バイトコードとして残す
- Zig comptimeはテキストベースのコード生成と似た問題を解けるが、Cマクロのようにキーワードを任意に置換しないため、コードの可読性やデバッグ負荷を下げられる
Zig comptimeを読む視点
- Zigのcomptimeはメタプログラミング機能だが、最終的な動作を読むときには、コンパイル時とランタイムの区別をそれほど強く意識しなくてよい
zig zenの3番目の項目である「Favor reading code over writing code」が重要な基準になる
- マクロやコード生成ベースのメタプログラミングは、元コードと展開後コードという二層構造を作りうる
- こうした間接層は、コードの読解、デバッグ、動作変更を難しくする
- 配列
[3]i64 = .{1,2,3} の合計を求めるランタイムコードと、構造体 MyStruct のフィールド a、b、c を合計するcomptimeコードが並べて比較される
inline for (comptime std.meta.fieldNames(MyStruct)) でフィールド名を走査する
@field(my_struct, field_name) で、コンパイル時に分かっているフィールド名を使ってフィールドへアクセスする
- 構造体フィールド合計の例はランタイムリフレクションのように見えるが、実行ファイルには、その構造体型専用の合計関数を手書きしたのと同じような効率的コードが残る
- 例の変換過程はZig実装をそのまま再現したものではなく、概念的説明に近い
- Zigコアチームは、comptimeとランタイムが混在したコードを例のように段階実行できるデバッガを開発中である
ジェネリクスをcomptimeで扱う
- Zigには別個のジェネリクス機能がない
- 型をジェネリックにしたい場合は、型を受け取って型を返す関数として書く
- 例:
pub fn GenericMyStruct(comptime T: type) type
- 返された
structのフィールド a、b、c はすべて T 型を使う
- ジェネリック関数も同じ方法で書ける
- 例:
fn quadratic(comptime T: type, a: T, b: T, c: T, x: T) T
quadratic(f32, ...) や quadratic(i64, ...) のように型引数を明示できる
- 引数型が関数シグネチャのほかの部分で重要でないなら、特殊な型
anytype で推論できる
- この方法だけでcomptime全体を説明できるわけではないが、Zigでよくあるジェネリック処理を理解する出発点になる
同じZigコードをコンパイル時に実行する
- Zigはランタイム、comptime、ビルドシステムで同じ言語を使う
- Fizz Buzzの例では、
fizzBuzz(writer) 関数をまず通常のランタイム出力関数として書く
1 から 100 までを走査する
3 と 5 の倍数では fizzbuzz、3 の倍数では fizz、5 の倍数では buzz、それ以外は数字を出力する
- 同じ
fizzBuzz 関数を comptime ブロック内で実行すると、出力全体を事前計算できる
std.io.countingWriter(std.io.null_writer) で必要なバイト数を先に数える
- 計算された長さで
buffer: [cw.bytes_written]u8 配列を作る
std.io.fixedBufferStream(&buffer) に再度出力して full_fizzbuzz 値を作る
- 核心部分だけを測定すると、事前計算版は約9倍高速に実行される
- ただし例自体が小さいため、全体の実行時間はほかの要因により大きく左右される
- comptimeとruntimeでは、アクセス可能な値や機能に差がある
comptime_int、comptime_float、type 値はcomptimeでのみアクセス可能
- 一部の関数はcomptime引数しか受け取れず、事実上comptime専用になる
- システムコールやそれを利用する機能はランタイムでのみアクセス可能
- こうした専用機能を使わないコードは、comptimeでもruntimeでも同じように動作する
部分評価として理解するcomptime
- comptimeは、コンパイル過程で起こる**部分評価(partial evaluation)**として見なせる
- 部分評価とは、関数に一部の引数だけが与えられた状態で、すでに分かっている値だけを使う式を先に置換・計算し、残りの未知の引数だけを受け取る新しい関数を作る方法である
- 配列合計の例は、その評価過程を段階的に示す
for ループを各反復の個別文へ分解する
array[0]、array[1]、array[2] をそれぞれ 1、2、3 に置換する
sum を 0 → 1 → 3 → 6 と更新する
- 最終的には
std.debug.print(..., .{6}) の形まで単純化される
- 構造体フィールド合計の例も同じように読める
inline for はcomptimeで実行されるため、フィールド "a"、"b"、"c" に対する個別文へ展開される
@field(my_struct, "a") は my_struct.a に置き換わる
- 最終関数は
sum += my_struct.a、sum += my_struct.b、sum += my_struct.c を直接持つ形になる
- この方法により、意図を直接コードで表現しつつ、フィールドが変わるたびに合計関数を手動更新せよというコメントを残す必要を減らせる
コンパイル時評価とランタイムコード生成
- Zig comptimeは、コンパイル時評価とランタイムコード生成の組み合わせとして見ることもできる
- 入力コードはコンパイラによって実行される
- コンパイル時に分かる文はその場で評価される
- ランタイム値が必要な文は出力コードに追加される
- この見方は、Zigコンパイラの実際の処理方式に最も近い
- Zigはまず構文をパースする
- コードを仮想マシン用バイトコードに変換する
- VMが可能な部分を評価し、ランタイム処理が必要な部分は新たなバイトコードとして出力する
- 新しいバイトコードはその後マシンコードへ変換される
- ランタイム入力を持つ
if のような条件文は、両方の経路をともに出力する
- デッドコードは意味解析を受けない
- 間違った関数を書いても、実際に使うまではコンパイルエラーにならないことがある
- この性質はコンパイルをより効率的にし、
#ifdef なしで自然な条件付きコンパイルを可能にする
- Zigの型名も、comptimeで
type 値を評価する式である
- 関数引数型のように単純に見える型名も、comptime評価の結果である
- この性質が前述のジェネリクス例を可能にしている
- 必要なら、より複雑な式で型を計算することもできる
- Zigの静的解析は、ほかの多くの静的型付き言語より複雑である
- すべての型を把握するには、コンパイラのかなりの部分を実行しなければならない
- ツールが追いつくまでは、コード補完のようなエディタ機能が常にうまく動くとは限らない
テキストコード生成との対応関係
- Zig comptimeはテキストコード生成とは形が異なるが、似た問題を解ける
- コード生成関数
writeSumFn は、writer.print で sumFields 関数のソースコードを出力する
- 生成器で実行されるコードはZig comptime部分に対応する
- 生成器が出力するコードはランタイムコードに対応する
- Zigのcomptime版は、同じロジックをより直接的に表現する
std.meta.fieldNames(MyStruct) でフィールド名を取得する
inline for と @field でフィールドアクセスコードを構成する
- 型情報を入力として使うコード生成は、Zigではより単純になる
- テキスト生成方式では、型名やフィールド名の情報をどこから取得するかを別途解決する必要がある
- Zigでは型そのものとフィールド情報をcomptimeで直接扱える
- 外部仕様のような入力を使う場合は、
@embedFile でファイル内容を取り込んだうえで、通常どおりパースできる
- ジェネリック構造体の例では、comptimeはテキストコード生成よりも型名参照をより直接的に扱う
- テキスト生成では
MyStruct_{s} のような名前を文字列連結で一貫して合わせる必要がある
- comptimeでは
GenericMyStruct(T) のように関数を直接使う
- 例外もある
- フィールド名がcomptimeで決まる型を作ることはできる
- この場合、フィールド定義一覧を入れた仕様で組み込み関数を呼ぶ必要がある
- そのような型にはメソッドのような宣言を定義できない
- コード表現力そのものを制限するわけではないが、ほかのコードに公開できるAPIの形は制限される
- Cのようなテキストマクロで可能な合理的作業の多くはcomptimeでも可能だが、コードの形はほぼ確実に異なる
- Zigは、テキストマクロのようにキーワードを別名へ置換する機能は提供しない
さらに読むものと標準ライブラリの例
- Zig自体はcomptimeだけに依存する言語ではなく、公式ウェブサイトでさらに学べる
- 言語リファレンス ではcomptimeの具体的機能を扱っている
- Zig標準ライブラリにはcomptime利用例がある
std.debug.print が使うフォーマット関数 は強力なジェネリック関数である
- 多くの言語ではフォーマット文字列をランタイムにパースし、早期エラー検出のために別個のバリデータを追加することがある
- Zigではフォーマット文字列をcomptimeでパースして効率的な出力コードを作り、すべての検証もコンパイル時に行う
ArrayList は比較的単純だが完全な機能を持つジェネリックコンテナである
- Zigの
main 関数は複数の戻り値型を取ることができ、これはコンパイラの魔法ではなく、一般的なcomptimeコード で処理されている
1件のコメント
Hacker News のコメント
コンパイル時プログラミングで既に見つかっている問題を無批判に称賛するのではなく、もっと深く扱ってほしかった
ステージドプログラミングは新しいものではなく、Lisp 系でも数十年にわたって似たことが行われてきたが、その過程で多くの設計上のトレードオフや問題が明らかになっている。たとえば、この方式のジェネリクスは**パラメトリシティ(parametricity)**を壊し、関数の型シグネチャだけを見て推論しにくくなる。再帰的なジェネリック型を Zig がどう扱うのかも不明確で、型検査とコンパイル時計算がいつ、どの順序で相互作用するのかも重要な選択肢だ。記事ではコンパイル時コードが値だけでなくコードも生成できるとしているが、衛生性(hygiene)については扱っていない。関連する議論として https://typesanitizer.com/blog/zig-generics.html が良い
ただし別の状況では、Zig コードで
comptimeを見ると「ああ……」と思うことがある。Lisp マクロのように、本当は存在しない問題、あるいはコード構造をよりうまく整えれば消える問題を避けるために使われがちだ。元記事の、構造体フィールドを巡回して値を足す例は、実際に人々がcomptimeを使う方法の典型だが、多くの場合はstd.enums.EnumArrayのような実際に走査可能なデータ構造を使うほうがよい理論的には懸念が明らかにあるが、実用言語でどれほど重要かは別問題だ。C++ テンプレートもテンプレート特殊化によってパラメトリシティを壊すが、実務では大きな問題になりにくく、むしろ最適化を可能にする。たとえば
std::vector<bool>のように各要素を実際に 1 ビットで保存する実装は、こうした柔軟性があってこそ可能になるZig 式の
comptimeは、より静的に定義された関数シグネチャと比べて、推論可能性と表現力の直接的なトレードオフだと見ている。型でそうした推論をするコードにだけ影響し、必要に応じて選べる部分だ。型シグネチャだけでは不十分なときでも、ソースを数十行読めば使い方はだいたい分かり、長く混乱したことはない。再帰的ジェネリック型の例では、宣言中の名前を使うと “dependency loop detected” が出るが、回避方法はあり、記事のジェネリック例も自分自身を参照している。型検査はcomptimeの途中で起こり、たとえば最初の@compileLog("Hi")は実行されるが、その後のconst a: u32 = "42";で型エラーが出ると、2つ目のログまでは進まない。comptimeとランタイムの間の型検査で微妙な問題が生じることはあり得るが、かなり難解なコードでしか見られず簡単に解決でき、コアチームも把握しているので 1.0 前に扱われると期待している。衛生性が正確に何を意味するのかは、さらに説明が必要だ関数に明確な論理的目的があり、名前と引数名がうまく付けられていれば、宣言だけでも推論できる。引数のラベル、型名など、プログラマが指定できる部分も名前の一部と見なせる。リンク先の記事はタイトルの結論には同意するが、意味のある論証というより、点を取りにいく討論部的な文章に近い。より良い構図は柔軟性と複雑性だ。固定されたジェネリックシステムは、うまく設計されていればプログラム可能なものより単純だが、柔軟性は低い。Zig の長所は、メタプログラミング言語が通常の言語とほぼ同じであり、その通常の言語自体も単純なので、追加の複雑さのコストを抑えられる点にある。ただしコンパイル時コードとランタイムコードが混ざり、手がかりが少ないため、プログラマが何がいつ実行されるのかを区別しにくくなる複雑さは生じる。言語サーバーやエディタプラグインが
comptimeコードに別の背景色を付ける「comptime シェーダー」を提供したらどうだろうと思うRust の経験からすると、人々が const generics でやろうとする多くのことは、
comptimeのような機能があればもっと簡単になりそうだ。パラメトリシティを保ちながら const generics に対する算術を許すのは実装が難しく、実際に欲しいものが「出力サイズが N のハッシュ関数トレイト」程度なら、その目的のためにパラメトリシティを捨て、N からトレイトを前段のコード生成ステップにするのも悪くないかもしれない。だが Rust のマクロは、そのように使うには柔軟すぎて面倒だ。それでも、パラメトリック多相を素朴なコード生成機能に置き換えた瞬間、苦労の道が開けるD には17年前からあった。D の機能は着実に他の言語へ移植されていっている
Zig は
comptimeキーワードでコンパイル中に実行されるブロックを示すが、D ではキーワードではなく定数式かどうかが実行を決める。たとえばint s = sum(3, 4);は実行時に実行され、enum e = sum(3, 4);はコンパイル時点で実行される。非定数のグローバル、入出力、malloc()のようなシステム関数呼び出しを避ければ、かなり多くの関数が変更なしでコンパイル時点に実行できる。D の自動メモリ管理のおかげでメモリ割り当ても可能以前は静的テーブルを作るために別プログラムを書いていたが、コンパイル時点の関数実行によりその必要がなくなった。配列
tytabの初期化値は、配列を計算して返すラムダから来ている。完全な例は https://github.com/dlang/dmd/blob/master/compiler/src/dmd/ba... にある。CTFE のもう一つのよくある用途は DSL を作ることコンパイラが動くマシンとプログラムが実行されるマシンが違う場合、D コンパイラはどうやって正しさを保証するのか。たとえば
int s = sum(100000, 1000000)がすべての x86 マシンで同じ値になることを、コンパイラはどうやって知るのか気になる。CPU 世代ごとに微妙な違いがあり得るが、実際にホストで行った計算がターゲットマシンでも同じ結果になるとどう保証するのか、あるいはアーキテクチャが同じならホストとターゲットは十分似ていると仮定するのかが気になるint sum(int a, int b) { return a + b; }を用意して_Static_assert(sum(3, 4) == 7, "look ma, check at compile time!");のように使える。C 標準がなぜこれを追加しないのか分からないし、うまく動くcomptimeコンテキストの両方で呼び出せるsquare(2)は実行時呼び出しで、comptime square(3)はコンパイル時点呼び出し。comptime呼び出しは、何かがコンパイル時点対応でなければコンパイルエラーを出すが、これは重要な機能だと思う。コンパイル時点に実行されると期待していたコードが、入力引数の変化によって偶然実行時評価へ押し出される場合の警告信号になるからZig は面白そうに見えるが、演算子オーバーロードがあればよいと思う
演算子オーバーロードへの反論の多くはあまり納得できない。内部で実際に何が起きているか分からないという主張は、
addという関数を乗算するように作ることもできるので十分ではない。C++ のiostreamsやboost::spiritは濫用例としてよく挙げられるが、演算子オーバーロードのある他の言語ではそういうことはあまり見られず、C++ 特有の問題のように見えるOCaml では演算子を再定義できるが、別のモジュールのコンテキスト内でのみ可能。たとえば
Vec3モジュールで+を再定義したなら、Vec3.(a + b + c + d)やlet open Vec3 in a + b + c + dのように書ける。ソースを読むときに「この+はどこから来たのか?」という疑問がなく、a.add(b).add(c).add(d)よりずっと良い。ただし Zig はすでに固まりつつあるので、こうした問題を解決する大きな変更が入るのは難しそう__関数群の背後で、どれだけ多くの魔法が動いているのか分からないことがあるZig のアプローチは新鮮で、コードを追える能力は数文字余分に入力する数秒よりも重要
理想的な解法は、オーバーロードであることを明確に示すユーザー定義演算子を言語が提供すること。たとえば
let c = a |+| bのような前後に記号を付ける形なら、コードを見る人は|+|演算が実際には関数呼び出しだと分かる。依然として濫用は可能だが、主な懸念の一つは減らせる言語がシェーディング言語のようにベクトル数学の構文を直接提供するならよさそう。Zig には少なくとも Clang の Vector Extension に少し似た
@Vector()型があるが、残念ながら Extended Vector Extension ではない。https://ziglang.org/documentation/master/#Vector と https://clang.llvm.org/docs/LanguageExtensions.html#vectors-... を参照そのような提案がすでにあるかはよく分からない
fieldNamesは Nim のfieldPairsにとても似ていて、ものすごく便利な仕組み効率的なシリアライズを非常に簡単にでき、最近は
fieldPairsを使って型に対するスレッド安全性のコンパイル時点チェックを20行程度で実装した。こうした機能はプログラミング言語の標準機能になるべきだと思う。Rust で最も惜しい点の一つがここにあり、最後に試したときは型なしマクロに制限されていた。制約が大きすぎる。クレートの構造体にserdeが実装されていることを願うしかなく、同じフィールドを持つ構造体をプログラム的に直接作ることもできないかなり残念。
serdeのようなものは、コンパイル時点リフレクションがあればずっと実装しやすかったはず唯一のトレードオフは、そうしたライブラリをソースコードの形で含める必要があること
ある程度完成した C パーサーライブラリを書いてみてからは、言語自体にメタプログラミングが必須だという主張は、正直よく分からない。
構造体、シリアライズ、プロパティ、計測などを生成したいなら、単にソースファイルを処理してソースファイルを出力する普通の C プログラムを書き、ビルドスクリプトで先に実行すればよい。こうしたメタプログラムをどうデバッグし、テストするのかは気になる。自分のものは普通の C プログラムなので、ほかのコードと同じデバッガやツールをそのまま使っている。
コンパイラ自体が同じ言語で書かれているため、AST に読み取り専用でアクセスし、C# ソースを出力するプラグインを入れられる。デバッグには優れたテストフレームワークを作るために少し追加作業が必要だが、その後は標準のユニットテストフレームワーク内でプラグイン付きのコンパイラを実行し、対話型デバッガで確認できる。
「C における任意のコンパイル時実行:
cl /nologo /Zi metaprogram.c && metaprogram.exe,cl /nologo /Zi program.c。コンパイル時コードはネイティブ速度で動き、デバッグ可能で、完全に手続き的かつ任意的だ。コンパイラがコードを実行してくれる必要はない。」 https://x.com/ryanjfleury/status/1875824288487571873C でそれをやるには、ソースファイルを処理する C プログラムのために完全な C パーサーを書かなければならない。
Python コードを生成する Python コードを書き、それを再びコンパイルさせていた。これは壊れやすく、ひどい混沌で、こういうことをしなければならなかったのが Python を離れた大きな理由の一つだった。すべての関数引数を文字列で渡せばよいのでは、と聞くのに似ている。文字列ベースのコードを書く人もいるが、それはまれであるべきで、言語はそれを避ける手段を提供すべきだ。
興味深いパターンの一つは、コンパイル時に構造体を生成できることだ。
PyTorch で JSON ファイルを作り、Zig の
@embedFileで読み込んだ後、特定のrunメソッドを持つ構造体を生成してニューラルネットワークを実装する実験をしてみた。理論上はコンパイラがニューラルネットワークを直接最適化できるようになる。大きな利点はまだ証明できていないが、ネットワーク全体がスタック上にあり、動的割り当てがないという点もある。これが良いことなのかは確信がない。comptimeの代わりに Python でコードジェネレータを書いてやってみた。comptimeZig が特に速いのかは確信がないし、構造体を生成する JSON パーサーを毎回走らせたくもない。興味深くはあるが、こういう場合には「D が先にやった」という類のコメントに同意したくなる。
Zig の
comptimeに驚いたなら、コンパイル時コード評価と完全な AST マクロシステムを備えた Nim もぜひ見てみる価値がある。ほかのニッチ言語と同じエコシステム上の問題があり、それに加えて好みが分かれるメンテナー、長く残らないように見える主要コントリビューターたち、暗号資産企業からの主な資金提供といった要素もある。10年前ならこういうことは気にならなかったかもしれないが、今は違う。
Nim も例外から離れようとしているようだが、例外は関数を汚染し、例外を投げる関数を使っていなくても気にしなければならなくする[1]。見えない制御フローを扱うには人生は短すぎる。 [1]: https://github.com/status-im/nim-stew
Zig は全体としてかなり良い言語で、必要なことをこなしている。
目的から外れないことは非常に重要で、だからこそ、いくつかの言語が単に使われているという理由だけでどこにでも使われるのはあまり好きではない。
Zig のメタプログラミング能力と、Rust の巨大なエコシステム、コミュニティ、安全性を組み合わせた何かがあればいいのにと思う。
言語設計だけを見ると Rust より Zig のほうがずっと好みだが、未熟な趣味プログラマーとしては、少なくとも今のところ Zig で実際に有用または信頼できるものを書ける自信がない。
コンパイラがそうした問題をコンパイル時に見つけるのを助けてくれなかったからだ。Zig がそういう約束をしていないのは分かっているが、自分にとっては決定的な欠点なので、Zig は合う言語ではないように思う。一方で
comptimeの概念は Rust のマクロより気に入っている。