コンパイル時メタプログラミング

このページの目次

メタプログラミングにより、プログラムは他のプログラムの生成や変換に関与します。コンパイル時メタプログラミングは作業の一部をビルドフェーズに移動しますが、異なるメカニズムは異なる対象を操作します。あるものはプリプロセストークンを処理し、あるものは構文データを処理し、あるものは定数を計算し、あるものはバイトコードを変更します。

理解する際は、まず以下の3つの問いを立ててください。メタプログラムが受け取るのは構文なのか、型チェック済みの値なのか。それはどのフェーズで実行されるのか。生成物はコードなのか、データなのか、それとも追加の振る舞いなのか。

まず入出力を区別する:構文の生成、評価、振る舞いの織り込み トークン / 構文データ マクロ展開 新しい構文 → 後続のチェック 定数式 コンパイル時評価 値 / 静的データ プログラム + マッチルール ウィービング 追加の振る舞いを含むプログラム 変換はビルドフェーズで発生しますが、生成された振る舞いやデータにランタイムコストがかからないわけではありません。

なぜ通常の関数では不十分なのか

通常の関数はランタイム計算の抽象化に適しており、通常、呼び出し元のフィールド名を知る必要はなく、新しい宣言を自由に生成することもできません。「構造体の各フィールドに対してシリアライズコードを生成する」や「型パラメータに基づいて新しい型を構築する」といったニーズがある場合、通常の関数呼び出しの能力を超えたものが必要になります。

しかし、メタプログラミングは展開、診断、ビルドの複雑さを増大させます。通常の関数、ジェネリクス、または組み合わせで要件が明確に表現できる場合、それらは通常、デバッグが容易です。選択基準は、どのメカニズムが強そうに見えるかではなく、どのような情報を操作する必要があるかであるべきです。

Cマクロ:プリプロセストークンの展開

Cプリプロセッサはプリプロセストークンのルールに従って置換を行います。これは無規則な文字列置換ではなく、後続の完全な式の意味や型を理解するわけではありません。条件付きコンパイル、文字列化、トークンの連結などの用途をサポートしています。

以下の x、y、z のステートメントは関数体内に配置されています。これはマクロ展開のローカルな例です。

#define PICK_MAX(a, b) ((a) > (b) ? (a) : (b))

int x = 3;
int y = 2;
int z = PICK_MAX(x++, y); /* x は2回評価され、最終的に x=5, z=4 となる */

括弧は一部の問題を解決しますが、繰り返し評価の問題は解決しません。比較で x++ が1回使用され、選択された分岐でもう一度 x++ が実行されます。引数に関数呼び出しが含まれている場合、副作用が重複して発生する可能性があります。固定型の比較については、通常のインライン関数により、引数の評価が1回に抑えられ、型チェックも行われます。展開の境界については GCC マクロの落とし穴 を参照してください。

同じ呼び出し元で「2回展開されること」と「引数の評価が1回」を比較する

以下を evaluate-once.c として保存します。2つのテストはどちらも x=3 から始まり、マクロでは x=5、結果 4 になります。関数では x=4、結果 3 になります。この例では、未定義の順序で x を複数回変更しないようにし、繰り返し評価を未定義の動作で説明することを避けています。

#include <stdio.h>
#define PICK_MAX(a, b) ((a) > (b) ? (a) : (b))
static inline int max_int(int a, int b) { return a > b ? a : b; }
int main(void) {
    int x = 3, y = 2;
    int by_macro = PICK_MAX(x++, y);
    if (x != 5 || by_macro != 4) return 1;
    x = 3;
    int by_function = max_int(x++, y);
    if (x != 4 || by_function != 3) return 1;
    printf("macro=%d function=%d\n", by_macro, by_function);
    return 0;
}

cc -Wall -Wextra evaluate-once.c -o evaluate-once を実行すると、出力は macro=4 function=3 となります。cc -E で展開を確認すると、x++ がどの2つの分岐位置に現れるかを見ることができます。インライン最適化により関数呼び出しのオーバーヘッドは消除されますが、関数の引数は依然として言語のセマンティクスに従って評価され、マクロのような繰り返し評価にはなりません。GCC 副作用の重複

1回の評価であっても、複数の引数間に固定された評価順序があるわけではありません。2番目の引数も x を読み書きする場合、依然として C の評価順序の規則に従う必要があります。順序を明確にしたい場合は、まず独立したステートメントで一時値を計算し、その後で関数を呼び出してください。この固定型のマクロを関数で置き換えることで解決するのは、引数のコピーの問題であり、すべての式に対して自動的に順序を付けるわけではありません。

括弧は正しく書いているのに、なぜ x はさらに1回増加するのか?

注目点は戻り値だけでなく、x++ を読み取るたびに x の古い値と新しい値にも向けられます。初期状態で x>y の場合、比較で1回読み取られ、選択された分岐でもう1回読み取られます。x≤y の場合、2番目の x++ の分岐は実行されません。関数バージョンでは、常に最初に引数の値を1つ取得し、それを関数内で再利用します。この対照は、C の条件演算子の順序の境界を維持しており、未定義の動作に頼って差異を生じさせていません。

図を準備しています
括弧は正しく書いているのに、なぜ x はさらに1回増加するのか?

マクロがコピーするのは評価されていない式であり、比較の後に選択された x++ はまだ実行されます。関数はまず引数を値に評価し、その値を関数内で再利用します。分岐が選択されなくても、2回目の実行が条件なしで行われるわけではありません。

Lispマクロ:入力は未評価のコード表現

Lisp のコード表現は、リストなどのデータとして操作できます。マクロは、通常の関数の引数のルールに従って評価されていない表現を受け取り、新しいコード表現を生成します。その後、言語がその展開結果を処理します。

(defmacro unless-demo (condition &body body)
  `(if (not ,condition) (progn ,@body)));

ここで condition と body は、生成された if 式に配置されます。それらが表すランタイムの動作は、マクロ展開時に実行されるわけではありません。テンプレート展開、アンクォート、連結はコードの構築に役立ちますが、マクロの作者は依然として変数キャプチャ、評価回数、展開環境、エラー位置を考慮する必要があります。コードがデータだからといって、スコープやセマンティクスの問題が消えるわけではありません。

Rustプロシージャルマクロ:TokenStream は型付き AST ではない

Rust のプロシージャルマクロは、コンパイル時に TokenStream を消費して生成します。マクロは syn などのライブラリを使用して構文構造に解析できますが、これは rustc が型推論を完了した後の内部 AST を取得したことを意味するものではなく、式が推論された型を普遍的に照会できるわけでもありません。

プロシージャルマクロには主に function-like、derive、attribute の3種類があり、展開の挿入や置換ルールが異なります。例えば derive は追加のエントリを生成し、impl の生成によく使われます。attribute は注釈付きのエントリを置き換えることができます。インターフェースとルールについては Rust リファレンス を参照してください。

struct Record<T> { value: T } に対してシリアライズ実装を生成すると仮定します。マクロはジェネリックパラメータと where 制約を保持し、有効なパスと境界を生成する必要があります。その後、後続のコンパイルフェーズで実装が型として正しいかどうかをチェックします。「マクロがフィールドの構文を知っていること」と「マクロがフィールドの型がすべての制約を満たしていることを証明すること」は異なります。

衛生性:展開後の名前が誰を指すか

衛生性は、生成された名前と呼び出し元の名前の予期せぬキャプチャを解決します。Rust の macro_rules! には、混合された位置の衛生ルールがあります。プロシージャルマクロは「本質的に完全に衛生である」と一般化することはできません。プロシージャルマクロの作者は、名前衝突やパス解決を処理する必要があります。例えば、適切な絶対パスを使用し、span を保持し、衝突しやすい補助的な名前を生成しないようにします。

衛生性は、1回の評価、借用の正しさ、または良好なエラー情報を自動的に保証するものではありません。マクロが生成するコードのこれらの性質は、個別に設計する必要があります。

生成された tmp が、呼び出し元の tmp を奪うことはないか?

同じスペルの tmp が、異なる宣言環境から来る可能性があります。下の図では、呼び出し元の宣言 C とマクロが生成した宣言 M を保持し、挿入された式と補助的な参照をそれぞれ追跡します。衛生ポリシーをオフにすると、呼び出し元も tmp と呼んでいる場合にのみキャプチャが発生します。名前の変更はこの例の衝突を回避できますが、すべてのマクロ呼び出しを安全にするための一般的な解決策ではありません。衛生ルールは、単に目立たない変数名を探すだけでなく、束縛のソースを維持すべきです。

図を準備しています
生成された tmp が、呼び出し元の tmp を奪うことはないか?

衛生性は、挿入された式が持つ宣言のアイデンティティを保持しつつ、生成された補助的な参照が生成された宣言を指すようにします。展開後のテキストに基づいて最近の名前検索を行うと、呼び出し元の変数を補助変数に誤って束縛する可能性があります。

コンパイル時評価:値の計算

Rust の const fn は、条件を満たす定数コンテキストで実行できます。通常の呼び出し位置ではランタイムで実行することもでき、関数宣言に const があるからといって、毎回呼び出しがコンパイル時に行われるとは限りません。

const fn square_table() -> [u32; 4] {
    let mut out = [0; 4];
    let mut i = 0;
    while i < 4 {
        out[i] = (i * i) as u32;
        i += 1;
    }
    out
}

const SQUARES: [u32; 4] = square_table();

fn main() {
    assert_eq!(SQUARES, [0, 1, 4, 9]);
}

定数コンテキストでは、有効な定数結果を得る必要があり、言語は実行可能な操作を制限します。配列の範囲外アクセスなどは、評価が必要な場合にコンパイルエラーとなります。許可される構文や参照の動作は、言語とバージョンの規定に従います。Rust 定数評価

C++ の constexpr、consteval、Zig の comptime の具体的な能力は異なり、同じインタプリタとして一律に記述すべきではありません。const 評価とビルドスクリプトやプロシージャルマクロの権限を混同しないでください。後者はビルド環境やファイルシステムにアクセスする可能性があります。

事前計算により計算時間がビルドフェーズに移動し、結果を生成物に配置できる可能性があります。また、バイナリのサイズ、コンパイル時のメモリ、キャッシュの負荷を増加させる可能性もあります。「コンパイル時の実行」は、すべてのコストがゼロになることを意味しません。

同じ const fn でも、呼び出しコンテキストによってエラーが露呈する段階が決まる

以下では、有効なペイロードの上限を 1024、固定ヘッダーを 8 バイトと定義します。定数 PACKET_SIZE の初期化はコンパイル時の評価で行う必要があります。main 内の args からの長さは、通常のランタイム入力に属します。

const fn packet_size(payload: usize) -> usize {
    assert!(payload <= 1024, "payload too large");
    payload + 8
}

const PACKET_SIZE: usize = packet_size(16);

fn main() {
    assert_eq!(PACKET_SIZE, 24);
    let runtime_input = std::env::args().count();
    assert_eq!(packet_size(runtime_input), runtime_input + 8);
}

引数なしで直接コンパイルして実行すると、通過します。定数初期化を packet_size(1025) に変更すると、コンパイラはこの定数を拒否しなければなりません。main の通常のパスで 1025 を渡す場合、プログラムはアサーションに到達した時点でパニックします。const fn の関数本体が2つのロジックに変わるわけではありません。変化するのは、現在のコンテキストが有効な定数結果を要求しているかどうかです。定数コンテキスト

使用位置フェーズの義務これに基づいて推論できないこと
const 初期化定数評価規則の下で完了できる必要があるデータに生成物のスペースコストがない
通常のランタイム呼び出し通常の実行セマンティクスに従って値を生成するか失敗するコンパイラが定数畳み込みを全く行わない
ビルドスクリプトによるファイル読み取りビルド時に独立したプログラムとして実行ファイルの内容が自動的に宣言された依存関係になる
プロシージャルマクロによる式の生成未コンパイルの構文を生成する式がすでに型チェックを完了している

同じ const fn でも、エラーはどの段階で発生するのか?

フェーズの義務と最適化の機会は分離されるべきです。const 初期化では、コンパイルフェーズで有効な評価を完了する必要があります。通常のパスでは、実際に実行されるかどうかによってランタイムの結果が決まります。以下では、同じ packet_size 関数本体を保持し、呼び出しコンテキストと入力のみを変更し、エラーがどこから発生するかを観察しやすくしています。通常の呼び出しがオプティマイザによって事前計算されたとしても、それによって無条件に定数コンテキストの診断ルールに変更されるわけではありません。

図を準備しています
同じ const fn でも、エラーはどの段階で発生するのか?

const 初期化では有効な定数を得る必要があります。通常の呼び出しは、関数名に const が含まれているからといって、コンパイル時の評価が必須になるわけではありません。通常のパスが実行されない場合、その呼び出しのランタイムパニックが突然発生することはありません。

テンプレートとジェネリクス:パラメータがプログラムの構造を決定することもできる

ジェネリクスの型パラメータはチェックに参加します。一部の言語では値パラメータ、型レベルの計算、またはパラメータに基づくコードのインスタンス化もサポートしています。単一化により、異なるインスタンスが専用の実装を得ることができますが、インスタンスの数とコンパイルコストが増加します。型消去や辞書渡しは、異なるランタイム表現と呼び出し方法を採用します。

これらはマクロと組み合わせることができます。マクロがまずジェネリクスを含む実装を生成し、コンパイラがチェックを行い、戦略に基づいてコードを生成します。すべてのジェネリクスをマクロと呼んだり、すべてのメタプログラミングを AST 置換と呼んだりすると、フェーズの違いが隠蔽されてしまいます。

AOPウィービング:プログラム内のジョイントポイントを選択して振る舞いを追加する

AspectJ は pointcut を通じてジョイントポイントの集合を記述し、advice を通じて実行する振る舞いを指定します。call と execution がマッチする対象は異なり、メソッド実行のジョイントポイントを一律に呼び出し前の AST 挿入として描画してはいけません。

AspectJ はソースコードのコンパイルと、既存のクラスファイルへのウィービングなどのパスをサポートしており、生成物は振る舞いが織り込まれたバイトコードになります。ロード時ウィービングも存在します。これは「コンパイラが Java AST を修正するだけ」という概念に限定されるものではありません。AspectJ コンパイラとウィーバー

事前ウィービングにより、いくつかのプロキシの境界を回避できますが、advice 内のログ、タイマー、チェックは依然としてランタイムで実行され、動的なジョイントポイントの条件にはコストがかかる可能性があります。セルフ呼び出しがインターセプトされるかどうかは、メカニズム、マッチルール、ウィービングの範囲に依存し、「すべての呼び出しをカバーし、ランタイムオーバーヘッドがゼロである」という一般的な結論を出すことはできません。

生成結果の再現性は、出力に影響を与えるすべての入力に依存する 明示的な入力 スキーマ + ジェネレータのバージョン + オプション 決定論的生成 固定されたフィールド順序と命名ルール 生成コード 後続の解析、型チェック、コンパイル 暗黙的な入力:現在のディレクトリ、環境変数、時刻、または非順序の走査 出力を変更する可能性がある場合、ビルドモデルに組み込むか、生成ロジックから排除する必要がある 生成の成功と生成物の正しさは別のステップである。コンパイルが通った後、生成されたインターフェースの実際のセマンティクスを検証する必要がある。

ビルドキャッシュはジェネレータの実際の依存関係を見える化する必要がある

例えば、スキーマから Record のシリアライズ実装を生成する場合、フィールドの順序はスキーマから、命名ルールはジェネレータのバージョンから、出力モードは設定スイッチから来ます。ジェネレータのソースファイルのみを依存関係としてリストすると、スキーマを変更した後に古い出力を使い続ける可能性があります。スキーマのみを依存関係としてリストすると、命名ルールをアップグレードしても再ビルドされない可能性があります。

逆に、ジェネレータが毎回実行時に無条件に現在の時刻を追加する場合、すべてのビジネス入力が同じであっても、生成結果は変化し、キャッシュとコードレビューにノイズをもたらします。まず、時刻が生成物のセマンティクスに属するかどうかを決定する必要があります。単なるログであれば、ビルドログに残しておくだけで十分です。

変化合理的な期待確認が必要な点
フィールドの追加対応する生成宣言の変化入力依存関係によりジェネレータが再実行されるか
ジェネレータルールのアップグレード同じスキーマが新しいルールで生成されるツールのバージョンがキャッシュキーに含まれるか
ビジネス入力の変化なし生成内容は安定している順序、乱数、暗黙的な環境
無効な型の組み合わせが生成されるコンパイル診断がドメイン入力に指し示すソース位置または生成マニフェストが保持されているか

コンパイル時の計算にも規模の限界があります。N 個のジェネリックインスタンスそれぞれが B バイトのコードを生成する場合、コードスペースはインスタンス数に応じて増加する可能性がありますが、リンカの折りたたみ、最適化、共有戦略により最終的なサイズは変化します。生成物とビルドリソースを確認する必要があり、ソースコードで何行少なく書けたかだけで、コンパイル時メタプログラミングが総コストを必ず削減すると認定してはいけません。

エンジニアリングレビュークイックチェック

要件検討可能なメカニズム確認すべき点
少量の重複計算関数、ジェネリクスすでに十分に表現できているか
フィールドごとの実装生成derive / プロシージャルマクロジェネリクス、パス、エラー span、生成コードのチェック可能性
事前生成された定数テーブル定数評価許可される操作、コンパイルコスト、生成物のサイズ
型と値に基づく専用構造の生成テンプレートまたは型レベルのメカニズムインスタンスの膨張、制約の診断
横断的な振る舞いウィービングまたはプロキシジョイントポイントのセマンティクス、カバー範囲、ランタイムの副作用

検証では、通常の展開だけでなく、無効な入力、同名の変数、副作用を持つ引数、ジェネリックの境界、および変更後のインクリメンタルビルドが正しいかどうかを失効させるかどうかを確認する必要があります。生成結果には追跡可能な位置と安定した順序があり、ビルド環境の暗黙的な入力が再現困難な生成物をもたらすことを避けるべきです。

参考

Keywords: metaprogramming, macro, TokenStream, hygiene, const fn, constexpr, templates, AspectJ, weaving