プロシージャルマクロ

18 分で読了
このページの目次

プロシージャルマクロはRustプログラムを用いてトークンを処理し、トークンを出力します。宣言的マクロでは表現しにくい構造化された解析やコード生成に適しています。ホスト側のコンパイル環境で実行され、その出力のみがターゲットのcrateでコンパイルされます。任意の型やトレイト実装を照会できる汎用のコンパイラプラグインではありません。

呼び出し元のTokenStreamderiveの入力型;attributeの入力属性と注釈付き項目1構文構造を解析・検証synがASTを提供;ここではまだ任意の型推論結果はない2TokenStreamを生成quoteによる補間、ジェネリクス、制約、診断位置を保持3呼び出し側でコンパイル継続生成された項目は名前解決、型チェック、借用チェックに参加4プロシージャルマクロはホスト側で実行され、出力コードはターゲットcrateに入るマクロの実行環境と最終プログラムのターゲットプラットフォームは異なる場合があります。

3つのマクロが呼び出し位置に与える影響

カテゴリ入力出力動作
#[derive(MyTrait)]派生対象のstruct、enum、またはunion元の定義の直後に生成された項目を追加し、元の型定義を保持
#[my_attr(...)]属性引数と注釈付き項目の2つのトークンストリーム注釈付き項目を置き換え、複数の項目を出力可能
my_macro!(...)区切り子内のトークンストリームマクロ呼び出しを置き換え、構文上の位置に従う必要がある

attributeマクロで元の関数の出力を忘れると、本当に元の関数が削除されてしまいます。deriveは同名の新しい構造体を出力して元のフィールドを変更することはできません。マクロはエラーを返したり、パニックしたり、無限実行に陥る可能性もあるため、エラー入力に対する動作をインターフェース設計の一部として扱うべきです。Rustプロシージャルマクロのルール

ジェネリクスを保持するderiveの実装例

ワークスペースのルートにあるCargo.tomlを作成します:

[workspace]
members = ["hello-derive", "demo"]
resolver = "3"

hello-derive/Cargo.toml:

[package]
name = "hello-derive"
version = "0.1.0"
edition = "2024"

[lib]
proc-macro = true

[dependencies]
syn = "2"
quote = "1"

hello-derive/src/lib.rs:

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(Hello)]
pub fn derive_hello(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    if matches!(input.data, syn::Data::Union(_)) {
        return syn::Error::new_spanned(&input.ident, "Hello does not support unions")
            .to_compile_error().into();
    }
    let name = &input.ident;
    let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl();
    let expanded = quote! {
        impl #impl_generics crate::Hello for #name #type_generics #where_clause {
            fn hello_name() -> &'static str {
                ::core::stringify!(#name)
            }
        }
    };
    expanded.into()
}

demo/Cargo.toml:

[package]
name = "demo"
version = "0.1.0"
edition = "2024"

[dependencies]
hello-derive = { path = "../hello-derive" }

demo/src/main.rs:

trait Hello {
    fn hello_name() -> &'static str;
}

#[derive(hello_derive::Hello)]
struct Example<T> where T: Copy {
    value: T,
}

fn main() {
    let value = Example { value: 42_u32 };
    assert_eq!(value.value, 42);
    assert_eq!(Example::<u32>::hello_name(), "Example");
    println!("{}", Example::<u32>::hello_name());
}

cargo run -p demo を実行すると、Example が出力されるはずです。この教学用マクロは、呼び出し元crateのルートに Hello トレイトが存在することを明示的に要求しています。正式な公開ライブラリでは、安定したトレイトパスまたは設定可能なパスを設計し、依存関係がリネームされる場合も考慮すべきです。この規約をすべてのユーザーに暗黙的に課さないでください。

なぜジェネリクスを3つに分割するのか

impl<T>、Example<T>、および where T: Copy は、出現位置と構文上の役割が異なるため、generics オブジェクト全体をすべての位置で繰り返し補間することはできません。split_for_impl はそれぞれに対応する形式を提供し、impl 内でデフォルト引数を繰り返さないなどの詳細を処理します。syn Generics

生成されたメソッドがフィールドを読み取り、Display などのトレイトを要求する場合は、適切な制約を追加する必要があります。利便性のためにすべてのジェネリックパラメータに同じ境界を一律に付与しないでください。フィールドは PhantomData、関連型、またはそのパラメータに関係なく使用される可能性があります。生成コードの実際のニーズに基づいて設計してください。

例では expanded を変数名として使用しています。Rust 2024 では gen は予約語であるため、旧いチュートリアルの let gen は、原始識別子 r#gen としない限り、そのエディションではそのまま使用できません。gen キーワード

同じderiveで、3種類のジェネリクスとデフォルト引数を検証する

hello-derive を変更せずに、demo/src/main.rs を以下の完全なプログラムに置き換えます。この例は、ライフタイム、型パラメータ、constパラメータ、およびwhere節を同時に検証し、サブモジュール内でマクロを使用します。

trait Hello { fn hello_name() -> &'static str; }
mod model {
    #[derive(hello_derive::Hello)]
    pub struct Record<'a, T = u8, const N: usize = 2>
    where T: Copy,
    {
        pub name: &'a str,
        pub values: [T; N],
    }
}
fn main() {
    let name = String::from("sample");
    let record: model::Record<'_> = model::Record {
        name: &name, values: [1, 2],
    };
    assert_eq!(record.name, "sample");
    assert_eq!(record.values, [1, 2]);
    assert_eq!(<model::Record<'_, u8, 2> as Hello>::hello_name(), "Record");
}

生成された実装のヘッダは構文的に impl<'a, T, const N: usize> crate::Hello for Record<'a, T, N> where T: Copy に相当します。impl 宣言箇所ではデフォルト引数を繰り返さず、型適用箇所では T と N を渡す必要があります。where節は型の後に続きます。これら3つの位置のトークンは同一の構文ではないため、宣言全体をそのまま繰り返し補間すると無効なコードが生成される可能性があります。

ここでは、ルートトレイトはサブモジュール内でも crate::Hello によって解決されます。トレイトを別の場所に移動した場合は、生成パスを同期して変更するか、設定可能なエントリポイントを設計する必要があります。「サブモジュールでの呼び出しが成功した」からといって、「任意の依存関係レイアウトに自動的に適応する」わけではありません。

生成コードがフィールドを使用していない場合、フィールドの制約を課すべきではない

以下も置き換え可能な demo/src/main.rs です。Hello は型名のみを返し、フィールドを読み取ったりコピーしたりしないため、T が Clone、Copy、または Display を持っていなくても、実装は成立するはずです。

trait Hello { fn hello_name() -> &'static str; }
struct NoTraits;
#[derive(hello_derive::Hello)]
struct Wrapper<T>(std::marker::PhantomData<T>);
fn main() {
    let _value = Wrapper::<NoTraits>(std::marker::PhantomData);
    assert_eq!(Wrapper::<NoTraits>::hello_name(), "Wrapper");
}

将来フィールドの印刷機能を追加する場合、実際にフォーマットされるフィールドの型に制約を課す必要があります。すべての T に機械的に Display を追加してはいけません。例えばフィールドが <T as Trait>::Item の場合、必要な能力は関連型に属している可能性があります。フィールドが PhantomData<T> の場合、T は同じ能力を持つ必要がないかもしれません。マクロの作者は、生成されたメソッドが実際に実行する操作から逆算して制約を導き出す必要があります。

同じジェネリックを 3 部分に分ける理由

既定値はコピーの誤りを見つけやすい箇所です。struct では T=u8 と宣言できても impl には既定値を繰り返さず、Record の適用では T だけを使います。切り替えて、split_for_impl が行うのは位置ごとの構文整理であり、フィールドの型検査ではないと確認します。

図を準備しています
同じジェネリックを 3 部分に分ける理由

impl はパラメータを宣言し、型適用は引数を渡し、where は制約を保持します。既定値は impl に繰り返しません。

syn と quote は型チェッカーではない

syn は構文木を解析し、quote は補間を通じてトークンを構築します。フィールドの型エイリアス Alias を見ても、マクロがそれが最終的にどの型に解決されるかを照会できるわけではありません。トークン文字列から型を推測することも、パス、ジェネリクス、フォーマットの変化の影響を受けやすいため容易ではありません。

プロシージャルマクロ全体として不衛生(unhygienic)であり、生成された名前やパスは呼び出し環境の影響を受けます。適切な修飾パスを使用してユーザーの項目との衝突を避け、入力の span を使用してエラーを関連する宣言に指向してください。TokenStream を文字列に変換して文字列置換を行うと、構造と診断の品質が失われることがほとんどです。

ヘルパー属性にも完全な構文契約が必要

derive が #[hello(...)] を受け付ける場合、#[proc_macro_derive(Hello, attributes(hello))] 内で対応するヘルパー属性を登録し、解析コードでそれを処理する必要があります。登録はコンパイラにその属性の存在を許可するだけで、設定を自動的に実行するわけでも、マクロがその位置や内容を検証するわけでもありません。

例えば #[hello(trait_path = "crate::api::Hello")] のサポートを準備する場合、以下の動作を定義する必要があります。前述の最小限の Hello 実装にはこのオプションはまだ提供されていません。

入力マクロが明確に決定すべき結果
パスが未設定ドキュメントで規定されたデフォルトパスを使用
有効なパス文字列が設定syn::Path に解析し、生成コードに使用
不明なキー名そのキーを指し、サポートされていないオプションを報告
パスが重複指定重複を拒否するか、明確な上書きルールを定義し、最後の値が偶然取られるのを防ぐ
パスの構文が無効入力文字列の位置で解析エラーを報告
サポートされていない位置に属性を記述明確に拒否し、静かに無視されないようにする

syn の parse_nested_meta は一般的なキー値/ネスト属性構文に適しており、LitStr はさらに Path に解析できます。カスタムDSLの場合は Parse を実装します。属性全体を一度文字列に変換し、カンマとイコールで分割するのは避けてください。括弧のグループ化、エスケープ、ネストがその解析方法を壊す可能性があるためです。syn 2 属性解析

属性構文エラーキー名、重複キー、パラメータ形式サポートされない構造例えば Hello は union を拒否生成コードの型エラー対象 crate で型検査入力のspanを優先して保持し、具体的な理由と修正可能な位置を示すエラーはそれを最もよく理解している段階に残し、入力位置を指し示すすべての失敗を「proc macro panicked」に変えるのではなく、文字列で型を推測しないこれら3つのチェックの責任は異なります。マクロが構文を解析できるからといって、型チェッカーの全情報を持っているわけではありません。

crate の依存関係の方向

マクロ定義は proc-macro crate のルートに配置する必要があり、それを定義する同じcrate内では直接使用できません。独立したユーザーやインテグレーションテストなどから呼び出すことができます。このcrateにはプライベートな補助関数やモジュールを含めることができますが、通常のライブラリのように任意の公開ランタイムAPIをエクスポートすることはできません。

ワークスペースメンバーは当然、別のメンバーが提供するプロシージャルマクロに依存できます。制限は、依存関係グラフが循環してはならないことです。一般的な構造は、ランタイムインターフェースライブラリとderiveライブラリが役割を分担し、必要に応じてインターフェースライブラリがマクロを再エクスポートすることです。生成されたコードが某パスを参照しているからといって、マクロcrate自身がそのランタイムcrateに依存しているわけではありません。

マクロの実行はホストのファイルシステムなどのリソースにアクセスできるため、ビルドは外部入力を制御し、偶発的な現在のディレクトリや追跡不可能な環境変化への依存を避けるべきです。クロスコンパイル時、マクロ内の cfg はホストの設定を見ます。ターゲット条件が必要な場合は、通常、ターゲットコード内の cfg を生成するか、明示的に渡されたビルド情報を使用する必要があります。

生成されるターゲットパスとマクロ自身のビルド依存関係

マクロが ::runtime_api::Trait を出力する場合、これはユーザーが生成コードをコンパイルする際にそのパスに解決できることを要求するものであり、マクロ自身が runtime_api のコードを呼び出しているわけではありません。マクロcrateはトークンを生成するために syn/quote のみを依存関係として持つことができます。ランタイムAPI crate が derive を再エクスポートしても、それによって逆方向の依存関係ループが生じるわけではありません。

依存関係のリネームは、使用側から見えるcrate名を変更します。公開マクロはパス設定を提供するか、検証済みの依存関係名解決スキームを採用できます。単に絶対に見えるパスをハードコードしても、一部の名前衝突は解決できますが、リネームの問題は解決されません。プロシージャルマクロ内で宣言的マクロ専用の $crate を汎用的なパスパッチとして使用することはできません。

診断と展開結果の使い方

予期されるエラー入力については、syn::Error 等を使用して compile_error! を生成し、位置と明確なメッセージを保持し、panic をすべてのエラー報告手段として使用しないでください。上記の union は型名箇所で拒否理由を示します。

cargo expand は追加でインストールするツールであり、生成構造を確認するのに役立ちます。テキスト化された展開結果を表示しますが、衛生性などのコンパイラ内部情報を完全に保持するものではないため、出力をコピーしても動作が完全に同等である保証はなく、最終的なアセンブリでもありません。cargo-expand

プロシージャルマクロを検証する際は、少なくとも単純な型、ジェネリクスとwhere節、モジュール位置、およびサポートされない入力を確認してください。サンプルコードの展開が「implのように見える」からといって、生成されたライブラリが実際の呼び出し元に適していることを証明するものではありません。

エラーサンプルはコンパイル失敗を、成功サンプルは実際に呼び出されることを確認

ユースケース確認すべき動作
通常の struct生成された実装が呼び出可能で、元のフィールドが残っている
ライフタイム + 型 + const ジェネリクス実装ヘッダと型適用パラメータが合法
デフォルトジェネリックパラメータデフォルト値が impl ジェネリック宣言にコピーされない
サブモジュール内の型生成パスが規約通りに解決される
ジェネリックパラメータに追加のトレイトがないマクロの生成により無関係な制約が増えない
union「Hello does not support unions」を明確に報告
トレイトが存在しない、またはリネームされている生成コードが名前チェックを通過したふりをしない

専用のコンパイルテストフレームワークを使用してパス/フェイル用例を管理することも、小規模なワークスペースで独立したサンプルを個別にコンパイルすることもできます。診断アサーションは、マクロが提供するコアメッセージと重要な位置に焦点を当てるべきです。完全な rustc の出力はバージョンによって変化する可能性があるため、スナップショットを更新する際は変更理由を確認してください。

cargo expand は「何が生成されたか」を確認するのに役立ち、サンプルを実行して「インターフェースが期待通りに動作しているか」を確認し、失敗サンプルで「どの入力が明確にサポートされていないか」を確認します。これら3つの証拠は互いに補完し合います。美しいトークンを出力しただけでは、ジェネリクス、パス、および診断が実際の呼び出し元で成立する保証にはなりません。