このページの目次

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

プログラムがコンパイル時に自身を生成または変換する——マクロ(Lisp/Rust/C)、AOP コンパイル時ウィービング(AspectJ)、コンパイル時実行(constexpr/comptime)の3つのアプローチ。コンパイラ自体がメタプログラムの実行エンジンとなり、ASTの読み書き、ASTの生成・変更を行い、全プロセスがコンパイル時に完了する。

概要

コンパイル時に自身を生成または変換するコード——これがコンパイル時メタプログラミング(compile-time metaprogramming)⁠である。この概念には以下の3つが含まれる:マクロ(マクロ——構文レベルでのコード生成)、AOP コンパイル時ウィービング(ASTへの横断的注入)、そして現代言語のコンパイル時実行(constexpr/comptime)。これらに共通するのは、⁠コンパイラ自体がメタプログラム(meta-program)の実行エンジンとなるということだ。メタプログラムはASTを読み書きし、新しいASTを生成したり既存のASTを変更したりし、その全プロセスがコンパイル時に完了するため、ランタイムには影響を与えない。ここでは、マクロ(コード生成)、AOPウィービング(コード注入)、コンパイル時評価(コンパイル時のコード実行)という3つのアプローチをそれぞれ詳しく解説する。

マクロ:構文レベルでのコード生成

マクロは最も古くからあるコンパイル時メタプログラミングの形式であり、Lispのdefmacro(1960年代)にまで遡ることができる。その核心メカニズムは、⁠構文解析後、意味解析前に、ASTのノードを別のノードに置き換えることである。

Lispマクロ:ソースコードはASTそのもの

Lispはコードの形式とASTの形式が一致している(s-expression)ため、マクロのメカニズムは極めて簡潔である:

(defmacro when (condition &body body)
  `(if ,condition (progn ,@body)))

; 使用例: (when (> x 0) (print "positive"))
; 展開後: (if (> x 0) (progn (print "positive")))

マクロは一種の関数であり、入力はASTの部分木(ソースコード、実はこれもAST)、出力は新しいASTの部分木である。prognはLispにおける複数文のシーケンスを表す。マクロの入力と出力がともにs-expressionであるため、展開は純粋なASTの書き換えであり、⁠複雑な構文や型情報を維持する必要がない⁠。

これがLispの「コードはデータである」という思想の実現であり、マクロと関数は同じAST操作プリミティブ(quote/unquote/splice)を共有しており、習得コストが極めて低い。

Cプリプロセッサ:最も弱くとも最も普及したマクロ

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

MAX(x++, y)  →  ((x++) > (y) ? (x++) : (y))    ← 二重評価バグ

Cプリプロセッサは純粋なテキスト置換であり、ASTにも型にも触れず、式と文の違いさえ知らない。その結果、(1) 引数が複数回評価される可能性がある、(2) マクロ内のローカル変数名が呼び出し元と衝突する、(3) 展開後の構文エラーが展開後のコードではなくマクロ定義の行に報告される——といった問題が生じる。エラーメッセージは災難レベルである。Cプリプロセッサが現在も存在する唯一の理由は、コンパイラのサポートを必要とせず、あらゆる言語(C、C++、Obj-C、アセンブリ)で使用できることである。

Rust proc macro:型付きのAST変換

Rustのプロシージャルマクロは、コンパイル時にトークンストリーム(字句解析済みのトークン列)を受け取り、新しいトークンストリームを出力する:

#[proc_macro_derive(Serialize)]
pub fn derive_serialize(input: TokenStream) -> TokenStream {
    let ast: DeriveInput = syn::parse(input).unwrap();        ← ASTにパース
    let expanded = generate_serialize_impl(&ast);             ← implのトークンストリームを生成
    expanded.into()
}

メカニズム:コンパイラのマクロ展開フェーズにおいて、.soファイルとしてproc macro crateがロードされ、それが実行され、返されたトークンストリームが元のAST内のマクロ呼び出し位置に挿入される。重要なのは、⁠マクロはコンパイル時に実行され、それ自体がRustプログラムである⁠(標準ライブラリの任意の関数を呼び出せる)ということだが、入出力はトークンストリームである点である。

Rustのマクロは衛生的(hygienic)⁠である:マクロ内部で定義された変数名は、呼び出し元の変数名と衝突しない。コンパイラは展開時にマクロ内の名前に独立した構文コンテキスト(syntax context)を割り当て、名前解決フェーズで区別する。

AOPコンパイル時ウィービング:横断的抽象の注入

AOP(Aspect-Oriented Programming)の核心思想は、⁠横断的関心事(cross-cutting concern)——ログ、パフォーマンス統計、権限チェック——は個別に記述し、コンパイル時に必要なすべての箇所に注入するべきであり、各箇所に繰り返し記述すべきではないということである。

AspectJ:コンパイル時ウィービングの古典的実装

// アスペクト定義 (Aspect):
aspect Logging {
    pointcut serviceCall():
        execution(* com.example.Service.*(..));     ← すべてのServiceメソッド

    before(): serviceCall() {
        System.out.println("Calling: " + thisJoinPoint.getSignature());
    }
}

コンパイル時、AspectJのコンパイラ(Ajc)はJavaソースコードに対して以下の処理を行う:

  1. Javaソースコードを解析しASTを構築する。
  2. アスペクト定義を解析し、各ポイントカットがどの呼び出しサイト(call site)にマッチするかを決定する。
  3. マッチした各呼び出しサイトに対して、アドバイス(before()本体)のASTを、元のAST内のその呼び出しサイトの直前に注入する。
  4. 変更後のJava ASTを出力し、通常のコンパイルを続行する。

これは本質的にASTに対する精密な外科手術である。AspectJのポイントカットは「クエリ式」であり、アドバイスは「注入されるロジック」である。ウィービング全体がコンパイル時に完了するため、ランタイムで目にするのはウィービング後のクラスファイルのみであり、リフレクションやプロキシを必要としない。

コンパイル時ウィービング vs ランタイムプロキシ

コンパイル時ウィービング (AspectJ)ランタイムプロキシ (Spring AOP)
時期コンパイル時にASTを変更ランタイムで動的プロキシを使用してオブジェクトをラップ
パフォーマンスランタイムオーバーヘッドゼロ(バイトコードに既に織り込み済み)呼び出しごとにプロキシチェーンのオーバーヘッドが発生
カバレッジすべてのメソッド呼び出し(this.self-callを含む)プロキシ経由の呼び出しのみをカバー
柔軟性アスペクトの切り替えには再コンパイルが必要アスペクトの切り替えは設定変更のみで可能

トレードオフはジェネリクスの単一化(monomorphization)対消去(erasure)と同じ種類の判断である:⁠コンパイル時で最適化を極める(オーバーヘッドゼロだが柔軟性に欠ける)、それともランタイムで余地を残す(柔軟性はあるがパフォーマンスコストがかかる)⁠か。

コンパイル時実行:コードを引数としてコンパイル時に実行する

constexpr(C++)、comptime(Zig)、const fn(Rust)は別のアプローチである。新しいコードを生成するのではなく、⁠通常の関数をコンパイル時に実行し、その結果でランタイムの計算を置き換える⁠。

// Zig: comptime でこのコードブロックをコンパイル時に実行
const lookup_table = comptime blk: {
    var table: [256]u32 = undefined;
    for (0..256) |i| table[i] = compute_sin(i);    ← コンパイル時にテーブル全体を計算
    break :blk table;
};
// ランタイムでは lookup_table は既に埋められた静的データである

コンパイル時実行の制限として、コンパイル時に既知の値(リテラル、型、コンパイル時定数)にのみアクセスできる。ファイル読み込み、ネットワークIO、乱数の取得はできない。そのため、これは本質的に事前計算に適している——テーブル生成、型推論、コンパイル時定数畳み込みなど。

マクロとの違いは、⁠コンパイル時実行は「同じ関数をコンパイル時に実行する」ことであり、マクロは「トークンストリームからトークンストリームを生成する」——前者は双対性(関数がコンパイル時とランタイムでどう関係するか)であり、後者は合成(プログラムがプログラムを生成する)である。

選択基準:どのメタプログラミングをどの場面で使うか

シナリオツール
定型コードの回避——シリアライズ/デシリアライズ、deriveマクロ(Rust derive, Lisp defmacro)
複数のモジュール/階層にまたがる横断的ロジックAOP コンパイル時ウィービング
事前計算——ルックアップテーブル、数学定数constexpr / comptime / const fn
コンパイル時型チェック + コード生成Rust proc macro, C++ テンプレート
迅速なプロトタイピング、コードが複雑でない場合C プリプロセッサ(非推奨だが、生態系の慣性により価値がある)

参考文献

  • Rust Reference: Macros — proc macroの完全な仕様
  • Paul Graham: "On Lisp" — Lispマクロの哲学と実践
  • Kiczales et al.: "Aspect-Oriented Programming" (1997) — AOPとAspectJの原論文
  • Zig: https://ziglang.org/documentation/master/#comptime — comptimeの完全なセマンティクス

Keywords: metaprogramming, macro, Lisp defmacro, C preprocessor, Rust proc macro, hygiene, hygienic macro, syntax context, token stream, AOP, aspect, pointcut, advice, compile-time weaving, AspectJ, constexpr, comptime, const fn, compile-time evaluation, code generation, AST transformation, template metaprogramming