Rust

Rustのマクロの書き方|macro_rules!と手続き型マクロ(proc macro)をsyn 3で実装

Rustのマクロの書き方|macro_rules!と手続き型マクロ(proc macro)をsyn 3で実装

Rustのマクロは、コンパイル時にコードを生成する仕組みです。println!やvec!のようにmacro_rules!で定義する宣言的マクロと、#[derive(Serialize)]や#[tokio::main]の裏で動く手続き型マクロ(procedural macro、proc macro)の2系統があります。

この記事では両方を実装し、rustc 1.99.1(2026年10月15日公開)とsyn 3.0.9でビルドした出力を載せます。macro_rules!は再帰・繰り返し・他クレートへの公開まで、手続き型マクロは派生・属性・関数風の3種類と、2026年7月公開のsyn 3で壊れる箇所を扱います。Rustそのものが初めての方は、先にRust入門|環境構築から所有権・基本文法、受託開発での採用判断までで所有権と基本文法を押さえてください。

まとめ:Rustのマクロの要点

  • 選ぶ順番は、関数・ジェネリクス → macro_rules! → 手続き型マクロです。前の手段で書けるなら次へ進みません。
  • macro_rules!は「パターン => 展開内容」の対応表です。引数の個数を変えたい、構造体やimplを並べて生成したいときに使い、追加の依存はありません。
  • 他のクレートへ公開するには#[macro_export]を付け、マクロの中から自分のクレートの項目を指すときは$crateを使います。同じクレート内で名前だけで呼ぶ場合は、定義より前の行では見えません。
  • edition 2024ではexpr指定子がconst { ... }と_にも一致するようになりました。2021までの挙動はexpr_2021で残せます。
  • 手続き型マクロはproc-macro = trueの専用クレートに書き、入力の解析をsyn、出力の組み立てをquoteに任せます。2026年11月8日時点の最新はsyn 3.0.9・quote 1.0.47です。
  • syn 3ではItemFnにmodifiersフィールドが増え、フィールドを列挙して分解するコードはE0027で止まります。
  • エラーはpanic!でなくsyn::Errorで返し、trybuildでエラー文面を固定し、cargo expandで展開結果を確かめます。proc-macro-errorとそのforkのproc-macro-error2は、どちらも保守終了の勧告が出ています。

Rustのマクロの種類と関数との違い

宣言的マクロはパターンとテンプレートの対応表で、コンパイラがパターン照合して展開します。手続き型マクロは普通のRustコードとしてコンパイルされたプログラムで、コンパイラがそれを呼び出し、戻り値のトークン列を展開結果として使います。

観点 関数 macro_rules! 手続き型マクロ
呼び出し形式 f(x) 名前!(…) derive・#[名前]・名前!(…)
引数の個数 固定 可変 可変
受け取るもの 型の付いた値 トークン列(パターン照合) トークン列(任意のコードで解析)
structやimplの生成 不可 可 可
フィールド名の走査 不可 困難 synで可
定義場所 どこでも 同じクレート内で可 proc-macro専用クレート
追加の依存 なし なし 通常syn・quote・proc-macro2
衛生性 - ローカル変数は衛生的 非衛生的

関数・ジェネリクスとマクロの選択基準

関数で書けないのは、引数の個数を呼び出しごとに変えたい場合、構造体・impl・関数といった項目そのものを生成したい場合、構造体のフィールド名など構文の中身を読みたい場合の3つです。前の2つはmacro_rules!で足り、3つ目で初めて手続き型マクロが要ります。

次の場合はマクロ以外を選びます。

  • 型ごとに同じ処理を書きたいだけ:ジェネリクスとトレイトの既定実装で済みます。エラーも通常の型エラーとして出ます。ジェネリクスの実行コストはゼロコスト抽象化とは?Rustの単相化とdynの差をアセンブリと実測で解説で扱っています。
  • 生成元が外部ファイル(.protoやSQLスキーマ):ビルドスクリプト(build.rs)で生成してinclude!で取り込む方が、生成物をそのまま読めます。

C言語の#defineは字句をそのまま置き換えますが、Rustのマクロはトークン木を受け取り、展開結果も構文として検査されます。C側の置き換えの性質はC言語のインライン関数とは?inline・static inlineの使い方とマクロとの違い、言語ごとのコード生成の比較はメタプログラミングとは?仕組みとRuby・Python・JavaScript・C++・Rustの実装例にまとめています。

macro_rules!の基本構文と実装例

max!の実装:複数アームと再帰

macro_rules!の本体は「(パターン) => { 展開内容 }」を;で並べたものです。呼び出しは上のアームから順に照合され、最初に一致したアームが展開されます。次のmax!は、引数が1つならそれを返し、2つ以上なら先頭と残りの最大値を比べます。残りの最大値はmax!自身を呼び直して求めます。

macro_rules! max {
    ($x:expr) => { $x };
    ($x:expr, $($rest:expr),+) => {{
        let a = $x;
        let b = max!($($rest),+);
        if a > b { a } else { b }
    }};
}

fn main() {
    println!("{}", max!(3, 9, 4)); // 9
}

$x:exprの$xをメタ変数、exprをフラグメント指定子と呼びます。展開内容を{{ ... }}と二重の波括弧にしているのは、外側がマクロの区切り、内側が式としてのブロックだからです。再帰の深さには上限があり、Rust Referenceのlimits属性の章によるとrustcの既定は128です。超える場合はクレートの先頭に#![recursion_limit = "256"]のように書いて引き上げます。

フラグメント指定子の一覧とedition 2024での変更

Rust Referenceのmacros by exampleの章は、次の15種類の指定子を定めています。

指定子 一致するもの 例
block 波括弧のブロック { a + 1 }
expr 式 a + 1
expr_2021 トップレベルのconstブロック式・_式を除く式 a + 1
ident 識別子 port
item 関数・構造体などの項目 struct S;
lifetime ライフタイム ‘a
literal リテラル 42、”a”
meta 属性の中身 cfg(test)
pat パターン(A | B を含む) Some(x) | None
pat_param トップレベルの | を含まないパターン Some(x)
path パス std::io::Result
stmt 文(原則として末尾の ; を除く) let x = 1、struct S;
tt トークン木1つ x、(a, b)
ty 型 Vec<u8>
vis 可視性(空も可) pub(crate)

指定子の後ろに置けるトークンには制限があります。exprとstmtの直後に置けるのは=>・,・;だけです。$a:expr - $b:exprのように書くとマクロの定義時点でエラーになるため、区切りにはカンマを使います。

exprの範囲はeditionで変わりました。Edition Guideのmacro fragment specifiersの節によると、edition 2024のexprはconst { ... }と_にも一致し、従来の挙動のためにexpr_2021が追加されています。次のコードで差が出ます。

macro_rules! which {
    ($e:expr) => { "expr" };
    (const $b:block) => { "const block" };
}
macro_rules! which21 {
    ($e:expr_2021) => { "expr" };
    (const $b:block) => { "const block" };
}
fn main() {
    println!("{} {}", which!(const { 1 }), which21!(const { 1 }));
}

rustc --edition 2024ではexpr const block、--edition 2021ではconst block const blockと表示されます。edition 2024では1つ目のアームがconst { 1 }を先に取ってしまい、2つ目のアームに届きません。constで始まる入力を別扱いするアームを持つマクロは、edition移行時にexpr_2021へ書き換えるか、アームの順番を入れ替えます。

繰り返し$(…),*による構造体とゲッターの生成

可変個の入力は$( ... ),*で受けます。区切り文字(ここでは,)の後ろの*は0回以上、+は1回以上、?は0回か1回で、区切り文字は指定できません。展開内容の側でも同じ$( ... )*を書くと、受け取った回数だけ繰り返して出力します。

macro_rules! getters {
    ($name:ident { $($field:ident : $ty:ty),* $(,)? }) => {
        struct $name { $($field: $ty),* }
        impl $name {
            $( fn $field(&self) -> &$ty { &self.$field } )*
        }
    };
}

getters!(Config { host: String, port: u16 });

fn main() {
    let c = Config { host: "localhost".into(), port: 8080 };
    println!("{}:{}", c.host(), c.port()); // localhost:8080
}

パターン末尾の$(,)?は、最後の要素の後ろのカンマを許すための書き方です。これがないとgetters!(Config { host: String, })は一致しません。1つのマクロ呼び出しから構造体とimplの2つの項目を生成している点が、関数では書けない部分です。

macro_rules!の衛生性とローカル変数の名前解決

Rust Referenceはmacro_rules!の衛生性を「mixed-site hygiene」と呼び、ローカル変数とループ・ブロックのラベルはマクロの定義側で、それ以外の名前は呼び出し側で解決すると定めています。

macro_rules! add_ten {
    ($e:expr) => {{ let base = 10; base + $e }};
}

fn main() {
    let base = 1;
    println!("{}", add_ten!(base)); // 11
}

表示は11です。字句を置き換えるだけのマクロならlet base = 10; base + baseとなって20になるところを、マクロ内のbaseと呼び出し側のbaseは別の変数として扱われます。一方、関数名や型名は呼び出し側で解決されるため、マクロ内でHashMap::new()と書くと、呼び出し側でuseしていなければ見つかりません。標準ライブラリの項目は::std::collections::HashMapのように先頭からの完全なパスで書き、自分のクレートの項目は次に述べる$crateで指します。

#[macro_export]による公開と$crateによる定義元参照

ライブラリから配るマクロには#[macro_export]を付けます。マクロはクレートのルートに公開され、利用者はuse decl_lib::hashmap;のようにパスで取り込めます。マクロの中で自分のクレートの関数を呼ぶときは、クレート名を直書きせず$crateを使います。利用者が依存の名前を変えていても正しいクレートを指します。

// decl_lib/src/lib.rs
#[doc(hidden)]
pub fn __new_map<K, V>() -> std::collections::HashMap<K, V> {
    std::collections::HashMap::new()
}

/// キーと値の組から HashMap を作る(末尾カンマ可)
#[macro_export]
macro_rules! hashmap {
    ($($key:expr => $value:expr),* $(,)?) => {{
        let mut map = $crate::__new_map();
        $( map.insert($key, $value); )*
        map
    }};
}

Rust Referenceは、$crateは定義元のクレートを指すだけで、参照先の可視性の制限は外さないと明記しています。マクロから呼ぶ補助関数はpubにする必要があり、利用者に見せたくなければ#[doc(hidden)]で文書から隠します。別のクレートからhashmap! { "http" => 80, "https" => 443, }と呼ぶと、展開後は::decl_lib::__new_map()になります。

同じクレートの中では順番に注意が要ります。macro_rules!を名前だけで呼ぶと、定義した行より後ろの範囲(テキストスコープ)から探されるため、定義の前で呼ぶと次のエラーになります。

error: cannot find macro `sq` in this scope
 --> order.rs:1:28
  |
1 | fn main() { println!("{}", sq!(3)); }
  |                            ^^ consider moving the definition of `sq` before this call

#[macro_export]を付けたマクロはクレートのルートにも置かれるため、crate::sq!(3)のようにパスで書けば定義より前の行からでも呼べます。モジュールの中で定義したマクロを名前だけで外のモジュールから使うには、mod宣言に#[macro_use]を付けて見える範囲を広げます。

安定版ではまだ使えない宣言マクロの機能

macro_rules!で属性マクロや派生マクロを書けるようにするRFC 3697・3698と、繰り返し回数を数える${count(...)}などのメタ変数式は、rustc 1.99.1ではまだ使えません。rustcの未安定機能の一覧で、それぞれmacro_attr・macro_derive・macro_metavar_exprという機能ゲートのまま残っています。安定版でattr()のアームを書くと次のエラーになります。

error[E0658]: `macro_rules!` attributes are unstable
 --> attr.rs:1:1
  |
1 | / macro_rules! noop_attr {
2 | |     attr() ($($item:tt)*) => { $($item)* };
3 | | }
  | |_^
  |
  = note: see issue #143547 <https://github.com/rust-lang/rust/issues/143547> for more information

派生マクロのderive()は#143549、${count(...)}は#83527で同じE0658になります。安定版で#[名前]や#[derive(名前)]の形のマクロを作るなら、今も手続き型マクロを書くしかありません。

手続き型マクロ3種類の定義方法と使い分け

3種類は付ける属性と関数の引数の数が違います。戻り値はどれもproc_macro::TokenStreamです。

種類 定義側の属性 引数 呼び出し例 出力の扱い
派生マクロ #[proc_macro_derive(名前)] 1つ(対象の型) #[derive(FieldNames)] 元の型の後ろに追加
属性マクロ #[proc_macro_attribute] 2つ(属性の引数、対象) #[timed] 対象を置き換え
関数風マクロ #[proc_macro] 1つ(括弧の中身) upper!(“…”) 呼び出し箇所を置き換え

使い分けは「元のコードを残すかどうか」で決まります。派生マクロは型定義に手を加えられず、implブロックなどを後ろに足すことしかできません。トレイト実装の自動生成はこれで足り、SerdeのSerializeやDeserializeが代表例です(使う側の書き方はSerdeの読み方と使い方|Rustのderive・rename_all・defaultを実行結果で解説で扱っています)。関数の中身を包む、構造体にフィールドを足すなど元のコードを書き換えたいときは属性マクロを使います。#[tokio::main]はasync fn mainをランタイム起動コード付きの同期関数に置き換える属性マクロです。

派生マクロには補助属性を登録できます。#[proc_macro_derive(Builder, attributes(builder))]と宣言すると、利用者はフィールドに#[builder(default)]のような属性を書けるようになり、マクロ側はそれを読んで生成内容を変えられます。#[serde(rename = "...")]はこの仕組みです。補助属性の解析を宣言的に書きたいときはdarling(最新0.24.1、syn 3対応済み)が使えます。

syn 3とquoteによる手続き型マクロの実装

3種類のマクロを1つのクレートにまとめて実装します。構成は、マクロを定義するmy_macrosと、それを使うappの2クレートを持つワークスペースです。

# Cargo.toml(ワークスペースのルート)
[workspace]
members = ["my_macros", "app"]
resolver = "3"

# app/Cargo.toml
[package]
name = "app"
version = "0.1.0"
edition = "2024"

[dependencies]
my_macros = { path = "../my_macros" }

Cargo.tomlのproc-macro指定と依存クレート

マクロ側のCargo.tomlでは[lib]にproc-macro = trueを書きます。Rust Referenceの手続き型マクロの章は、手続き型マクロはcrate typeがproc-macroのクレートのルートに定義し、定義したクレート自身からは使えないと定めています。マクロとそれを使うコードが別クレートになるのはこのためです。クレートの追加やワークスペースの操作はCargo(Rust)とは?インストールとcargo installの使い方・主要コマンド一覧を参照してください。

# my_macros/Cargo.toml
[package]
name = "my_macros"
version = "0.1.0"
edition = "2024"

[lib]
proc-macro = true

[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "3", features = ["full"] }

[dev-dependencies]
trybuild = "1"

synのfull機能は、関数や文など型定義以外の構文を扱うときに必要です。既定機能でもDeriveInputや型・式の多くは扱えますが、関数全体を表すItemFnやブロックを表すBlockはfullに含まれます。fullを外したままItemFnを使うとerror[E0432]: unresolved import `syn::ItemFn`で止まります。

派生マクロ:構造体のフィールド名一覧を生成

構造体にfield_names()という関連関数を生やす派生マクロです。parse_macro_input!で入力をDeriveInputに変換し、フィールドを走査して名前の配列を作り、quote!でimplブロックを出力します。

// my_macros/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields, ItemFn, LitStr};

/// 構造体のフィールド名一覧を返す関連関数を生やす派生マクロ
#[proc_macro_derive(FieldNames)]
pub fn derive_field_names(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;

    let fields = match &input.data {
        Data::Struct(s) => match &s.fields {
            Fields::Named(f) => &f.named,
            _ => {
                return syn::Error::new_spanned(name, "FieldNames は名前付きフィールドの構造体にだけ使えます")
                    .to_compile_error()
                    .into();
            }
        },
        _ => {
            return syn::Error::new_spanned(name, "FieldNames は構造体にだけ使えます")
                .to_compile_error()
                .into();
        }
    };
    let names = fields.iter().map(|f| f.ident.as_ref().unwrap().to_string());

    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
    quote! {
        impl #impl_generics #name #ty_generics #where_clause {
            pub fn field_names() -> &'static [&'static str] {
                &[#(#names),*]
            }
        }
    }
    .into()
}

quote!の中では#変数で値を埋め込み、#(#names),*でイテレータの要素をカンマ区切りで展開します。macro_rules!の$( ... ),*と同じ考え方です。split_for_impl()は、struct Wrapper<T: Clone>のようなジェネリクス付きの型にも正しいimpl<T: Clone> Wrapper<T>を組み立てるための関数で、省くとジェネリクス付きの型に付けた時点でコンパイルエラーになります。

属性マクロ:関数の実行時間を計測

通常の同期関数と非同期関数が正常終了するまでの経過時間を標準エラーへ出す#[timed]です。非同期関数では待機時間も含み、panicやFutureの途中破棄では出力されません。const fnは対象外です。受け取った関数の本体を、計測コードで包んだ新しいブロックに差し替えます。

/// 関数の実行時間を標準エラーへ出す属性マクロ
#[proc_macro_attribute]
pub fn timed(attr: TokenStream, item: TokenStream) -> TokenStream {
    if !attr.is_empty() {
        return syn::Error::new(proc_macro2::Span::call_site(), "timed は引数を取りません")
            .to_compile_error()
            .into();
    }
    let mut f = parse_macro_input!(item as ItemFn);
    let fn_name = f.sig.ident.to_string();
    let block = &f.block;
    // mixed_site で作った識別子は、利用者のコードにある同名の変数と衝突しない
    let start = syn::Ident::new("start", proc_macro2::Span::mixed_site());
    let ret = syn::Ident::new("ret", proc_macro2::Span::mixed_site());
    // async fn は async ブロック、同期関数はクロージャで包む
    let call: syn::Expr = if f.sig.asyncness.is_some() {
        syn::parse_quote!(async move #block.await)
    } else {
        syn::parse_quote!((move || #block)())
    };
    let new_block: syn::Block = syn::parse_quote!({
        let #start = ::std::time::Instant::now();
        let #ret = #call;
        ::std::eprintln!("{} took {:?}", #fn_name, #start.elapsed());
        #ret
    });
    f.block = Box::new(new_block);
    quote!(#f).into()
}

受け取ったItemFnを分解して組み直さず、blockだけを差し替えてquote!(#f)で丸ごと出力しています。属性や可視性、syn 3で増えたmodifiersを取りこぼさないためです。本体をクロージャやasyncブロックで包むのは、関数の途中にreturnがあっても計測コードを通すためです。async fnの分岐を省いた場合に何が起きるかは後半の失敗例で示します。

計測用のローカル変数はSpan::mixed_site()で作っています。手続き型マクロは非衛生的なので、quote!にlet __start = ...と直接書くと、fn echo(__start: u64) -> u64 { __start }のように同名の引数を持つ関数に付けた時点で本体の__startがマクロの変数を指してしまい、rustc 1.99.1ではerror[E0308]: mismatched typesになります。mixed_site(Rust 1.45.0で安定化)の識別子はmacro_rules!と同じ規則で解決されるため、上のコードは引数名がstartやretの関数に付けても正しく動きます。

関数風マクロ:文字列をコンパイル時に大文字化

関数風マクロは名前!(...)の括弧の中身をそのまま受け取ります。ここでは文字列リテラルを受け取り、大文字にしたリテラルを返します。変換はコンパイル時に終わるため、実行時のコストはありません。

/// 文字列リテラルを大文字にした &str をコンパイル時に作る関数風マクロ
#[proc_macro]
pub fn upper(input: TokenStream) -> TokenStream {
    let lit = parse_macro_input!(input as LitStr);
    let up = lit.value().to_uppercase();
    quote!(#up).into()
}

入力が文字列リテラルでなければparse_macro_input!がsynのエラーを返し、利用者にはexpected string literalと表示されます。構文の検査をsynに任せられるのが、トークンを手で読むより安全な理由です。

呼び出し側のコードと実行結果

使う側のappは、前述のCargo.tomlでmy_macrosをpath依存に指定し、通常の関数と同じようにuseで取り込みます。

// app/src/main.rs
use my_macros::{timed, upper, FieldNames};

#[derive(FieldNames)]
struct User {
    id: u64,
    name: String,
    email: String,
}

#[timed]
fn sum(n: u64) -> u64 {
    (1..=n).sum()
}

const GREETING: &str = upper!("hello, proc macro");

fn main() {
    println!("{:?}", User::field_names());
    println!("{}", sum(1_000_000));
    println!("{GREETING}");
    let _ = User { id: 1, name: String::new(), email: String::new() };
}

cargo run -p appの出力です(計測時間はデバッグビルドの値で、環境によって変わります)。

["id", "name", "email"]
sum took 11.66602ms
500000500000
HELLO, PROC MACRO

TokenStreamからコード生成までの処理の流れ

手続き型マクロの実体は、コンパイラが読み込む動的ライブラリです。cargo buildでは次の順に処理が進みます。

  1. Cargoがmy_macrosをホスト向けにビルドする(クロスコンパイル中でもビルドを実行するマシン向け)
  2. rustcがappを構文解析し、#[derive(FieldNames)]などの呼び出しを見つける
  3. 該当箇所のトークン列をproc_macro::TokenStreamとしてマクロ関数に渡す
  4. 返ってきたトークン列を元の位置に展開し、名前解決と型検査に進む

マクロが受け取るのはトークン列で、型情報はまだありません。id: u64のu64が標準の整数型なのか同名の別の型なのかは、マクロからは判別できません。型に依存する処理はトレイト境界として生成コードに書き、判定はコンパイラの型検査に任せます。

proc_macroとproc_macro2の2つが出てくるのは、proc_macroがコンパイラから呼ばれている間しか使えないためです。synとquoteはproc_macro2::TokenStreamを中心に作られており、単体テストや通常のプログラムからも動かせます。マクロの入口と出口でだけ.into()で相互に変換します。

Rust Referenceは、手続き型マクロはコンパイル中に動くためコンパイラと同じ資源(標準入出力やファイルアクセス)を持ち、ビルドスクリプトと同じセキュリティ上の懸念があると明記しています。無限ループはコンパイラに捕捉されず、ビルドが止まったままになります。依存に加える手続き型マクロのクレートは、ビルドスクリプトと同じ基準で出どころを確認してください。

syn 2からsyn 3への移行で壊れる箇所

syn 3.0.0は2026年7月18日に公開され、11月8日時点の最新は3.0.9(10月12日公開)です。対応するRustの最低バージョンはsyn 2と同じ1.71のままで、変わったのは構文木の型です。

ItemFnの分解で出るE0027

syn 2向けの属性マクロでよく見る、ItemFnをフィールド列挙で分解する書き方は、syn 3ではそのまま通りません。syn 3.0.9でビルドすると次のエラーになります。

error[E0027]: pattern does not mention field `modifiers`
  --> my_macros/src/lib.rs:83:9
   |
83 |     let ItemFn { attrs, vis, sig, block } = parse_macro_input!(item as ItemFn);
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing field `modifiers`

syn 3では、関数や型などの構文にFnModifiersをはじめとする〜Modifiers構造体が10種類加わりました。いずれもnon-exhaustive(将来フィールドが増える前提)です。直し方は、分解に..を足すか、前章の#[timed]のように分解せず必要なフィールドだけ書き換えるかの2通りです。後者なら、将来modifiersに値が入るようになっても出力から落ちません。未対応の構文を拒否したいときは、各〜Modifiersのrequire_empty()で該当箇所を指したエラーを返せます。

名前や型が変わった主なAPI

syn 3.0.0のリリースノートの破壊的変更のうち、手続き型マクロの実装で触れることが多いものです。

syn 2 syn 3
Type::BareFn Type::FnPtr に改名
BareFnArg NamedArg に改名
Signature の unsafety 3値の Safety 列挙型
Arm の guard フィールド Pat::Guard に移動
Punctuated::pop Option<T> を返す(旧挙動は pop_pair)
LitInt の From<Literal> 削除(Lit::new で生成)
Lifetime のパース キーワードを拒否(parse_any で許可)
visit 系の Span 処理 メソッド削除

DeriveInputとフィールドしか触らない派生マクロなら影響は小さく済みます。関数のシグネチャや式を細かく読む属性マクロほど、移行時の修正箇所が増えます。

主要クレートの移行状況とsynの二重ビルド

crates.ioのAPIで、各クレートの最新版が依存するsynを確認しました(2026年11月8日時点)。

クレート 最新版 依存するsyn
serde_derive 1.0.229 ^3
thiserror-impl 2.0.21 ^3
tokio-macros 2.7.2 ^3.0
clap_derive 4.6.7 ^3.0.2
pyo3-macros-backend 0.30.1 ^3
wasm-bindgen-macro-support 0.2.130 ^3.0
darling_core 0.24.1 ^3.0.0
pin-project-internal 1.1.13 ^2.0.1
derive_more-impl 2.1.1 ^2.0.45
strum_macros 0.28.0 ^2.0

synの2系と3系はメジャーバージョンが違うため、Cargoは両方を別のクレートとしてビルドします。serde(derive機能付き)とpin-projectを同時に入れたプロジェクトでcargo tree -d -e normalを実行すると、両方が並びます。

syn v2.0.119
└── pin-project-internal v1.1.13 (proc-macro)
    └── pin-project v1.1.13
        └── syn_dup v0.1.0 (/private/tmp/9463_verify/syn_dup)

syn v3.0.9
└── serde_derive v1.0.229 (proc-macro)
    └── serde v1.0.229
        └── syn_dup v0.1.0 (/private/tmp/9463_verify/syn_dup)

新しく書くマクロはsyn 3で始めてください。serde・tokio・clap・pyo3の派生マクロが3系に移っているため、2系を選ぶと利用者にsyn 2を1本余計にビルドさせることになりがちです。既存のsyn 2のマクロは、pin-projectやderive_more、strumのように2系に残るクレートもあるので、利用者の依存ツリーからsyn 2が消えるまで急いで移す理由はありません。

エラー報告の書き方:panicとsyn::Error

Rust Referenceは、手続き型マクロのエラー報告手段としてpanicとcompile_error!の2つを挙げています。syn::Error::to_compile_error()は後者を生成する関数です。enumに付けるという同じ誤用を、両方の方法で報告させました。panicで止めた場合の表示です。

error: proc-macro derive panicked
 --> app/examples/bad.rs:3:10
  |
3 | #[derive(Panicky)]
  |          ^^^^^^^
  |
  = help: message: Panicky は構造体にだけ使えます

syn::Error::new_spannedで返した場合(前章のFieldNames)の表示です。

error: FieldNames は構造体にだけ使えます
 --> tests/ui/fail_enum.rs:4:6
  |
4 | enum Color {
  |      ^^^^^

panicでは「マクロがpanicした」という見出しが先に出て、指す位置もderiveの属性です。syn::Errorなら自分のメッセージが見出しになり、問題のあるColorを直接指せます。エラーが複数あるときはsyn::Error::combineでまとめて返せば、利用者は1回のビルドで全部を確認できます。syn 3.0.2では、開始と終了のトークンで範囲を指すError::new_rangeも追加されました。

エラー報告の補助クレートとして長く使われてきたproc-macro-errorは、RustSecの情報勧告RUSTSEC-2024-0370(2024年9月5日公開、種別Unmaintained)の対象です。その保守版として作られたproc-macro-error2も、作者が保守終了を認めたとしてRUSTSEC-2026-0173(2026年6月7日公開)の対象になりました。どちらの勧告も代替としてmanyhowとproc-macro2-diagnosticsを挙げており、crates.io上の最新版(manyhow 0.14.1、proc-macro2-diagnostics 0.11.0)はどちらもsyn 3に依存しています。

テストとデバッグ:trybuildと展開結果の確認

trybuildでコンパイルエラーの文面を固定

手続き型マクロのテストでは、正しい入力でビルドが通ることに加えて、誤った入力で意図したエラーが出ることを確かめます。後者は通常の#[test]では書けないため、trybuild(最新1.0.122)を使います。

// my_macros/tests/ui.rs
#[test]
fn ui() {
    let t = trybuild::TestCases::new();
    t.pass("tests/ui/pass_struct.rs");
    t.compile_fail("tests/ui/fail_enum.rs");
}

テスト対象の2ファイルです。pass_struct.rsはビルドと実行が通ること、fail_enum.rsはコンパイルエラーになることを確かめます。

// my_macros/tests/ui/pass_struct.rs
use my_macros::FieldNames;

#[derive(FieldNames)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    assert_eq!(Point::field_names(), &["x", "y"]);
}

// my_macros/tests/ui/fail_enum.rs
use my_macros::FieldNames;

#[derive(FieldNames)]
enum Color {
    Red,
}

fn main() {}

compile_failに渡したファイルは、同名の.stderrファイルとコンパイラ出力を突き合わせます。初回はTRYBUILD=overwrite cargo testで期待値を書き出し、中身を確認してからコミットします。前章のFieldNamesのエラー表示は、このとき生成されたfail_enum.stderrの内容です。rustcのバージョンが上がると文面が変わることがあるため、CIのツールチェーンは固定しておきます。

cargo expandで展開後のコードを確認

マクロが何を生成したかは、cargo expand(最新1.0.127)で確認します。cargo install cargo-expandで導入し、cargo expand -p appのように実行します。中身は-Zunpretty=expandedを付けたrustcの呼び出しです。-Zは本来nightly専用のオプションで、安定版のrustcに直接渡すとthe option `Z` is only accepted on the nightly compilerで拒否されますが、cargo expandは環境変数RUSTC_BOOTSTRAP=1を設定して安定版でも通しています。

本記事のappに前章のmax!とadd_ten!も加えて展開した結果の一部です(後の2つはprintln!の引数の中から該当部分を抜き出し、コメントを足しています)。

impl User {
    pub fn field_names() -> &'static [&'static str] {
        &["id", "name", "email"]
    }
}
fn sum(n: u64) -> u64 {
    let start = ::std::time::Instant::now();
    let ret = (move || { (1..=n).sum() })();
    {
        ::std::io::_eprint(format_args!("{0} took {1:?}\n", "sum", start.elapsed()));
    };
    ret
}
const GREETING: &str = "HELLO, PROC MACRO";
// max!(3, 9, 4) の展開
{
    let a = 3;
    let b = {
        let a = 9;
        let b = 4;
        if a > b { a } else { b }
    };
    if a > b { a } else { b }
}
// add_ten!(base) の展開
{
    let base = 10;
    base + base
}

upper!はリテラルだけになっており、実行時に変換処理が残っていないことが分かります。max!は再帰がブロックの入れ子として展開されています。注意したいのはadd_ten!で、テキスト上はbase + baseですが、実際の実行結果は11です。衛生性による変数の区別はテキストにすると消えます。cargo expandのREADMEも、テキストへの展開は情報が落ちるデバッグ補助であり、展開結果がコンパイルできることも元と同じ動作をすることも期待しないよう注意しています。展開結果を.expanded.rsとして保存し、差分を比べるテストにしたいときはmacrotest(最新1.3.0)が使えます。

手続き型マクロで失敗しやすい場面

async fnに付けた属性マクロのE0728エラー

前章の#[timed]からasync fnの分岐を外し、本体を常に同期クロージャで包む形にすると、.awaitを含むasync fnに付けた時点で次のエラーになります。

error[E0728]: `await` is only allowed inside `async` functions and blocks
 --> app/examples/bad.rs:8:27
  |
6 | #[timed]
  | -------- this is not `async`
7 | async fn fetch() -> u32 {
8 |     std::future::ready(1).await
  |                           ^^^^^ only allowed inside `async` functions and blocks

本体を同期クロージャに移したため、.awaitが同期の文脈に入ったことが原因です。関数を包む属性マクロではf.sig.asyncnessを見て、async fnならasync moveブロックで包むか、対応しない旨をsyn::Errorで返す分岐が必須です。マクロ作者が想定していない書き方は、利用者には自分のコードのどこが悪いのか分からないエラーとして現れます。非同期関数の構造はRustのasync/await入門|Futureとpollの仕組み、Tokioでの実行とよくあるエラーで解説しています。

synのfull機能と依存クレートのビルド時間

syn・quote・proc-macro2で書いたマクロを使うと、利用者の初回ビルドやクリーンビルドにこの3クレートのコンパイルが加わります(proc_macroだけで書けば依存は増えませんが、トークンを手で読むことになります)。Intel Core i9-9880H(macOS)でsyn 3.0.9だけを依存に持つクレートをクリーンビルドすると、既定機能で8.4〜9.4秒(中央値9.3秒)、full機能付きで10.3〜11.9秒(中央値11.5秒)でした(2026年11月8日・デバッグビルド・各3回)。並走ワーカーがあり、load average 4〜5の環境での測定なので、差の約2秒は参考値です。fullが要るかはマクロの種類ではなく扱う構文で決まり、DeriveInputとフィールドの型だけを読む派生マクロなら既定機能で足ります。

利用者が型定義に1行足すだけで数十行のトレイト実装が手に入るなら、このビルド時間を払う価値があります。serde・thiserror・clapの派生マクロが広く使われているのは、この条件を満たしているからです。

Rustのマクロについてよくある質問

Rustのマクロにはどんな種類がありますか?

macro_rules!で定義する宣言的マクロ(println!・vec!・dbg!など)と、手続き型マクロの2系統です。手続き型マクロはさらに派生マクロ・属性マクロ・関数風マクロの3種類に分かれます。

#[derive(Debug)]や#[derive(Hash)]も手続き型マクロですか?

標準ライブラリのDebug・Hash・Cloneなどの派生は、proc-macroクレートではなくコンパイラ組み込みのマクロです。Rust 1.99.1のcoreのソースでは#[rustc_builtin_macro]付きのpub macro Debug・pub macro Hashとして宣言されています。書き方は同じ#[derive(...)]ですが、自作の派生マクロのような依存クレートは要りません。デバッグ出力用のdbg!はstdの中でmacro_rules!で定義された宣言的マクロです。

手続き型マクロを定義したクレート内で使えないのはなぜですか?

マクロは、それを使うコードのコンパイル中に実行されるためです。定義と利用が同じクレートにあると、マクロをビルドし終える前にそのマクロが必要になります。ライブラリとして配るときは、fooとfoo-macros(またはfoo_derive)の2クレートに分け、本体から再エクスポートする構成が一般的です。

proc_macroとproc_macro2はどう違いますか?

proc_macroはコンパイラに同梱されたクレートで、マクロとしてコンパイラから呼ばれている間しか動きません。proc_macro2(crates.io上の名前はproc-macro2、最新1.0.107)はそれを包んだクレートで、通常のプログラムや単体テストからも使えます。マクロの入口と出口で.into()により相互に変換し、中の処理はproc_macro2で書くのが定石です。

syn 2とsyn 3は同じプロジェクトに混在できますか?

混在できます。メジャーバージョンが違うためCargoは別クレートとして両方をビルドし、コンパイルエラーにはなりません。代わりにビルド時間が増えます。cargo tree -dで重複を確認し、syn 2に依存しているクレートが新版で3系に移っていないかを確かめてください。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.11.09 テックブログ エディオン不正アクセス第2報:カード番号の対象確認とログ・決済画面の改ざん対策
  2. 2026.11.09 テックブログ LANSCOPE不正アクセス第2報:製品への影響と偽連絡の確認、停止中のライセンス認証の窓口
  3. 2026.11.10 テックブログ Claude Fable 5.2とは:料金・破壊的変更4点とFable 5.1からの移行手順【2026年11月】
  4. 2026.11.09 テックブログ ステップゴルフ不正アクセス:口座情報の対象確認と偽連絡・引き落としの注意点
  5. 2026.11.10 テックブログ ニトリの情報漏洩:最大3,762万件の対象確認となりすまし連絡の見分け方

RELATED POSTS 関連記事

目次