Day 062 — マクロ① macro_rules! ⑦ 「要素数を数える count!」4案の設計判断:再帰の上限・コンパイル時間・空入力・カンマの罠

2026-10-01(バックフィル) 🟣 上級者 / Phase 3 設計・アーキテクチャ マクロ①(宣言的マクロ macro_rules!)
フェーズ: 上級者 (Phase 3) | 問題タイプ: 設計・アーキテクチャ | テーマ: マクロ①(宣言的マクロ macro_rules!)
10/1 のバックフィル分です。番号は出題済みの Day 061 の続きで 062 を使っています(日付と番号の順序が逆になるのは仕様)。state の次回予告(「マクロにするか、関数・型にするか」)は今日分ではなく、別の1本として残してあります。

📚 背景知識(読んでから問題へ)

Day 057 の hashmap!、Day 059 の fsm! は、どちらも途中で「入力の要素が何個あるか」を数えていました(容量の確保、状態数の定数)。**数えるだけの小さなマクロ count! ですが、書き方が複数あり、どれを選ぶかで「何個まで通るか」「コンパイルがどれだけ遅くなるか」が桁違いに変わります。 今日は実装そのものより、4案を実際に測って比べ、公開 API に採る1案を理由つきで決める**ことが問題です。

数え方の4案は次のとおりです(どれも入力は $($t:tt)*、つまりトークン木の並び)。

案書き方発想
A 再帰() => 0; ($h:tt $($t:tt)*) => 1 + count!($($t)*)1 個食べて残りを再帰で渡す。いちばん素朴
C 2個ずつ再帰($a:tt $b:tt $($t:tt)*) => 2 + count!($($t)*)再帰の深さを半分にする
B 配列の長さ<[()]>::len(&[$(replace!($t ())),*])各トークンを () に置き換えて配列を作り、長さを取る。再帰しない
D 文字列化[$(stringify!($t)),*].len()各トークンを文字列にして配列の長さを取る。再帰しない

マクロの展開には再帰の深さの上限(recursion_limit、既定 128)があります。上限は #![recursion_limit = "N"] で上げられますが、利用者のクレートではなくマクロを書く側がクレート属性を強制することはできないため、公開マクロでは「上限に当たらない書き方」が効きます。

世界の実物では、macro_rules! の数え上げの定番として、この「置き換えて配列の長さを取る」形(案 B)が広く紹介されています(The Little Book of Rust Macros の Counting の章が再帰版・置換版・配列版などを並べて解説。一次資料は末尾。今回は取得し直して照合していません)。標準ライブラリ側の解決は、nightly の「メタ変数式」${count($t)}(tracking issue #83527)で、今日の rustc 1.96.0 でも stable では使えないことを実測で確認しています(下の答え合わせの末尾)。

実務では、hashmap! の with_capacity、固定長配列 [T; N] の N、enum の variant 数(const COUNT)、プロトコルのフィールド数の定数などで数え上げが要ります。利用者が variant を 200 個書く日が来ると、再帰版はマクロの作者ではなく利用者のビルドが突然落ちます。

📝 問題

Part 1: 4案の挙動を予想する(実行する前に答える)

環境は rustc 1.96.0、edition 2021、rustc を直接呼ぶ(-O なし)。N は入力のトークン数です。

#問いあなたの予想
P1案 A(再帰)で、recursion_limit を指定しないとき、N = 127 と N = 128 は通るか
P2案 A で #![recursion_limit = "256"] にしたとき、通る最大の N は
P3案 C(2個ずつ)で、既定の上限のまま N = 254, 255, 256 は通るか
P4count!(a b c) / count!(a, b, c) / count!(a (b c) [d e] {f}) は、案 B でそれぞれいくつか
P5空入力 count!() は、A・B・C・D でそれぞれ通るか。通るなら 0 か
P6[0u8; count!(a b c)] のように配列の長さの位置に置けるのは A〜D のどれか
P7N = 10000 のコンパイル時間の順位(A・B・C・D)。A は B の何倍くらいか(桁で)
P8N = 100000 は A・B・D のどれが通るか

Part 2: 実装

案 B を使った公開用の count! を、次の条件で書いてください。

  1. 再帰しない(recursion_limit に依存しない)
  2. 空入力で 0 を返す
  3. const と配列の長さの位置で使える
  4. 補助マクロは #[doc(hidden)] #[macro_export] にして $crate:: で呼ぶ(Day 060 の復習。別クレートから使われても壊れない)
  5. 再帰版との一致と、130 個の入力が通ることをテストで示す(#[test] 5 本程度)

Part 3: 設計の設問(本題)

mini-fsm(Day 059 の fsm! を切り出した、利用者が自分のクレートから呼ぶ公開クレート)が数え上げに使う1案を選び、判断記録(ADR)を 15 行以内で書いてください。次を必ず含めること。

  • (a) 4案のうち採る案と、採らない案それぞれの却下理由(数値つき。「なんとなく遅そう」は不可)
  • (b) 「前提が変わったら判断がどう変わるか」を2つ以上(例: msrv、利用者の入力規模、macro_metavar_expr の安定化、手続き型マクロへの移行)
  • (c) 今日の実測で測れなかったことを1つ以上

💡 ヒント

ヒント1(方向性)

再帰は「1 個ごとに展開が 1 段深くなる」ので、深さ = 入力の個数です。再帰しない形はないか、$(...),* の繰り返しが 1 回の展開で配列の要素を並べられることに注目してください。

ヒント2(アプローチ)

各トークンを「型が決まった値」に置き換えます。macro_rules! replace { ($_t:tt $sub:expr) => ($sub); } で $t を () に化けさせ、<[()]>::len(&[...]) と書くと、要素が 0 個でも型が [()] に決まります。[...].len() だけだと空配列の要素型が決まらず E0282 になります。

ヒント3(コード骨格)
#[doc(hidden)]
#[macro_export]
macro_rules! __count_replace { ($_t:tt $sub:expr) => { $sub }; }

#[macro_export]
macro_rules! count {
    ($($t:tt)*) => { <[()]>::len(&[$($crate::__count_replace!($t ())),*]) };
}

130 個の入力は手で書くと長いので、テスト用に「トークン列を 10 回繰り返して別のマクロへ渡す」補助マクロ(rep10!)を #[cfg(test)] で作ると楽です。

✅ 模範解答

Part 2 の答え(cargo test で 5 本通過を確認)

cargo new cnt の src/main.rs にそのまま貼れば cargo test で検証できます(依存クレートなし、edition 2024 でも 2021 でも可)。

/// トークン木(tt)の個数を、再帰せずに数える。
/// - 展開の深さは 1 段なので recursion_limit に依存しない
/// - `const` や配列の長さの位置でも使える
/// - 空入力でも 0 になる(型が `[()]` に固定されるため)
#[doc(hidden)]
#[macro_export]
macro_rules! __count_replace {
    ($_t:tt $sub:expr) => {
        $sub
    };
}

#[macro_export]
macro_rules! count {
    ($($t:tt)*) => {
        <[()]>::len(&[$($crate::__count_replace!($t ())),*])
    };
}

/// 比較用: 再帰版(既定の recursion_limit = 128 で何個まで通るかを試す)
#[cfg(test)]
macro_rules! count_rec {
    () => (0usize);
    ($_h:tt $($t:tt)*) => (1usize + count_rec!($($t)*));
}

const THREE: usize = count!(a b c);
const BUF: [u8; count!(a b c d)] = [0; 4];

fn main() {
    println!("{} {}", THREE, BUF.len());
}

/// テスト用: 入力のトークン列を 10 回繰り返して $m!(...) に渡す
#[cfg(test)]
macro_rules! rep10 {
    ($m:ident, $($t:tt)*) => {
        $m!($($t)* $($t)* $($t)* $($t)* $($t)* $($t)* $($t)* $($t)* $($t)* $($t)*)
    };
}

#[cfg(test)]
mod tests {
    #[test]
    fn empty_is_zero() {
        assert_eq!(count!(), 0);
    }

    #[test]
    fn counts_token_trees_not_words() {
        assert_eq!(count!(a b c), 3);
        assert_eq!(count!(a, b, c), 5); // カンマも 1 トークン
        assert_eq!(count!(a (b c) [d e] {f}), 4); // 括弧の中身は 1 つにまとまる
    }

    #[test]
    fn usable_in_const_and_array_length() {
        const N: usize = count!(a b c);
        let buf = [0u8; count!(a b c d)];
        assert_eq!(N, 3);
        assert_eq!(buf.len(), 4);
    }

    #[test]
    fn beyond_default_recursion_limit() {
        // x を 13 個 × 10 = 130 個。再帰版は既定の上限 128 で落ちる
        assert_eq!(rep10!(count, x x x x x x x x x x x x x), 130);
    }

    #[test]
    fn recursive_version_agrees_below_limit() {
        assert_eq!(count_rec!(a b c), count!(a b c));
        assert_eq!(count_rec!(), count!());
    }
}

Part 1 の答え(すべて今回の実測。rustc 1.96.0、edition 2021、rustc 直呼び、ビルド時間は 3 回の最小値)

#結果出所
P1N=127 は通る、N=128 は recursion limit reached while expanding count!実測
P2limit=256 で N=255 は通り、N=256 は落ちる。**通る最大は limit - 1**実測
P3N=254 通る、N=255 通る(最後の1個は再帰せず ($a:tt) の規則で終わる)、N=256 は落ちる。案 C は上限の約 2 倍まで実測
P43 / 5 / 4。a, b, c はカンマが 1 トークンなので 5。(b c) は括弧ごと 1 つなので数が減る。A・B・C・D で同じ結果実測
P5A・B・C は 0。**D だけ E0282 type annotations needed**([].len() の要素型が決まらない)。B は <[()]>::len で型を固定しているので通る実測
P6A〜D すべて配列の長さの位置に置ける(const N でも [0u8; count!(..)] でも通った)実測
P7下の表。N=10000 で A は B の約 70 倍実測(下のビルド時間表)
P8B・D は通る。A は未実行(N=10000 で 19 秒かかった形の 10 倍の入力なので、現実的でないと判断。導出ではなく未測定)B・D 実測/A 未測定

ビルド時間(rustc --edition 2021 b.rs -o b、-O なし、3 回の最小値、開発機は他のプロセスと同居していてぶれが大きい):

NA 再帰(limit を N+100 に上げた)C 2個ずつ(limit を N/2+100)B 配列D 文字列化
1,0000.19 秒0.14 秒0.14 秒0.17 秒
10,00019.37 秒5.47 秒0.27 秒0.30 秒
100,000実行せず実行せず2.17〜9.21 秒(3 回のばらつき)3.73〜7.92 秒(3 回のばらつき)

補足の実測: A は N=5,000 で 4.05 秒(1 回測定)、N=10,000 で 19〜33 秒(負荷によりぶれ)。N が 2 倍で時間が 5 倍前後なので、線形ではなく超線形(理由は未調査。入力トークン列を再帰のたびに作り直しているためと推定したが、-Z time-passes は未実施)。

メタ変数式 ${count($t)} は rustc 1.96.0 の stable で error[E0658]: meta-variable expressions are unstable(実測)。

Part 3 の答え(判断記録の例)

ADR-062 mini-fsm の数え上げは案 B(置換して配列の長さ)を採る
決定: <[()]>::len(&[$($crate::__count_replace!($t ())),*]) を count! として公開する。
採らなかった案:
  A 再帰   : 既定の上限で N<=127 までしか通らない(実測)。上限を上げても N=10,000 で 19 秒(実測)。
  C 2個ずつ: 上限は 2 倍の N<=255(実測)。N=10,000 で 5.5 秒でまだ遅い。
  D 文字列化: 速度は B と同等(N=10,000 で 0.30 秒 対 0.27 秒)だが、空入力が E0282 で落ちる(実測)。
  ${count()} : stable で使えない(実測 E0658)。
前提が変わったら: ①msrv を `macro_metavar_expr` の安定化版へ上げられるなら ${count($t)} に置き換える
  ②利用者が 1 万要素を超えるなら、手続き型マクロ(proc-macro)への移行を測ってから検討する
  ③配列ではなく型レベルの数が要るなら typenum 系のクレートに任せる。
測れていないこと: N=100,000 は 3 回で 2〜9 秒とぶれて B と D の差は判別できない。release ビルド(-O)、
  別バージョンの rustc、proc-macro 版との時間比較は未実施。

🪜 Step-by-Step 解説

Step 1: 「再帰の深さ = 入力の個数」になる案 A は、公開 API に向かない

案 A は 1 個ごとに 1 段深くなるので、既定の上限 128 から 1 を引いた 127 個が限界です(実測: 127 は通り、128 は落ちる)。利用者のクレートに #![recursion_limit] を書かせることは、マクロの作者にはできません。自分のテストが 10 要素で通っていても、利用者が 200 要素を書いた日に壊れるので、これは「実装の質」ではなく「API の約束」の問題です。

Step 2: 再帰を半分にしても桁は変わらない(案 C)

2 個ずつ食べると上限は約 2 倍(255)になりますが、N=10,000 で 5.47 秒かかり、案 B の約 20 倍です。「上限を上げるより、上限に当たらない形に変える」のが本質です。

Step 3: 案 B は「1 回の展開で配列を作る」

$(...),* は 1 回の展開で N 個の要素を並べます。展開の深さは __count_replace! の 1 段だけなので、上限に依存しません。N=10,000 で 0.27 秒(案 A の約 70 分の 1)。

Step 4: <[()]>::len の <[()]> は空入力のための型固定

案 D は [stringify!(a), stringify!(b)].len() で数えますが、要素が 0 個だと [] の要素型が決まらず E0282 になります(実測)。案 B は <[()]>::len(&[...]) と型を書いて、0 個でも 0 を返します。「例を 3 つ試して通った」だけでは見つからず、空入力という境界を自分で足して初めて落ちる差です。

Step 5: $crate:: で補助マクロを呼ぶ

count! が __count_replace! を素の名前で呼ぶと、別クレートから use mini_fsm::count; だけで使ったとき、補助マクロが見えず失敗します(Day 060 で実測した内容)。#[doc(hidden)] #[macro_export] で公開し $crate::__count_replace! と書けば、利用者が何を use していても動きます。

Step 6: トークン木を数えるのであって、単語を数えるのではない

count!(a, b, c) は 5 です。「要素数」が欲しいなら、入力の書式(カンマ区切りか空白区切りか)を先に決め、カンマを数えない設計にする($($x:expr),* を受けて置換する)か、呼び出し側で「これは tt の数」と文書化します。今日のモデル解答は後者(tt の数)です。

🧠 設計思想・なぜこう書くのか

Rust のマクロは、コンパイラが展開するコンパイル時の計算です。再帰は関数型言語では自然ですが、macro_rules! では「展開の深さ」「トークン列のコピー」がコストになります。実行時の計算量ではなくコンパイル時間と上限に表れるのが、通常のコードとの違いです。

迷った他案と採らなかった理由

案採らなかった理由
A 再帰上限 127 個(実測)。上げても N=10,000 で 19 秒(実測)で、利用者のビルドを遅くする
C 2個ずつ再帰上限は 255 個(実測)。N=10,000 で 5.5 秒(実測)
D 文字列化速度は B と同等だが、空入力で E0282(実測)。境界が 1 つ多く、直す理由がない
${count($t)}(メタ変数式)stable で使えない(実測 E0658)。標準的な解になるまで待つ
手続き型マクロ(syn/quote)別クレートが要り、依存のコンパイル時間が数える対象より大きい。未測定(今日の範囲外)
+ の連鎖(0 + {..} + {..})今日は動作のみ確認(count_e!(a b c) = 3)。Day 057 以前に、10,000 要素の + 連鎖が 10.7 秒かかった記録があり、型推論が原因と推定。今日は再測定していない

前提が変わったら: macro_metavar_expr が stable になれば ${count($t)} が第一候補になり、案 B は msrv が低い版の互換用に回ります。利用者が数万要素を書くなら、proc-macro を測ってから決めます。

🛠️ コンパイルエラーが出た場合(デバッグ問題では必須)

今日の問題はデバッグ問題ではありませんが、予想の答え合わせに出る実際のエラーを残します(すべて rustc 1.96.0 の実出力の要点)。

error: recursion limit reached while expanding `count!`

案 A で N=128(既定の上限)や、limit=256 で N=256 のとき。読み方: 「展開が深くなりすぎた」。直し方: 再帰しない案 B にする(上限を上げるのは応急処置)。

error[E0282]: type annotations needed --> m.rs:5:40 | 5 | macro_rules! count_d { ($($t:tt)*) => ([$(stringify!($t)),*].len()); } | ^^^^^^^^^^^^^^^^^^^^^ cannot infer type

案 D に空入力 count_d!() を渡したとき。読み方: 要素が無い配列の型が決まらない。直し方: <[&str]>::len(&[...]) のように型を書く。

error[E0658]: meta-variable expressions are unstable --> n.rs:1:38 | 1 | macro_rules! cnt { ($($t:tt)*) => { ${count($t)} }; } | ^^^^^^^^^^^

${count($t)} を stable で書いたとき(tracking issue #83527)。

🔀 他言語との比較(該当する場合)

観点Rust macro_rules!Rust ${count()}(nightly)C のプリプロセッサGo
要素数の数え方置換して配列の長さ(案 B)が定番言語が提供(未 stable)__VA_ARGS__ を数えるには引数の個数を判定するマクロの技(上限は手書きの段数で決まる)可変長引数 len(xs) は実行時。コンパイル時に数える機構は不要
再帰の上限既定 128(作者が強制変更できない)無関係再帰マクロ自体を禁止なし
失敗の出方利用者のビルドが落ちる同左展開ミスは静かに誤動作しやすいなし

🏭 実務での使いどころ

  • 公開クレートの宣言的マクロ(bitflags・hashbrown 系の初期化マクロなど)は、入力規模の上限を利用者に課さないことが基本的な設計方針になります(具体的な実装はクレートごとに異なります。ここでは取得して確認していません)。
  • enum の variant 数を const COUNT にして [T; COUNT] の配列のサイズに使う、という使い方は典型的です。
  • CI で cargo build --timings を取り、マクロ展開が支配的な箇所を見つけるときの観点にもなります(-Z time-passes は今日未使用)。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「recursion_limit を上げれば再帰版で足りる」動くので十分と感じる上限を上げても N=10,000 で 19 秒(実測)。しかも作者が利用者のクレートの属性を決められない
「count!(a, b, c) は 3」人間の目は単語を数えるtt の数なので 5(実測)。カンマも 1 つ
「[...].len() で十分」3 要素の例では動く空入力で E0282(実測)。型を固定する
「速い方が常に正解」数値を見ると決めたくなる今日の B と D の差は 3 回のばらつきの範囲(N=100,000 で 2〜9 秒)。判断を分けたのは速度ではなく空入力

📖 一次資料(参照日: 2026-10-03。今回は取得し直して照合していません。記述は要旨で、原文の引用ではありません)

🚧 今日扱わなかった範囲(次回以降)

扱わなかったこと理由・次の予定
release ビルド(-O)での時間今日は展開コスト(フロントエンド)を見たかったので debug のみ。未測定
proc-macro(syn/quote)版との比較依存のビルド時間込みで測る必要があり、1 日に収まらない。マクロ② で扱う
-Z time-passes で A が超線形になる理由の特定nightly が要る。推定のまま
別バージョンの rustc での上限・時間1.96.0 のみ。上限 128 が変わる可能性は未確認
+ 連鎖(案 E)の時間動作確認のみ(3 になる)。測っていない
実在クレート(hashbrown・bitflags 等)が何を使っているかの確認一次資料を取得し直していない。今日は「定番として紹介されている」ことまで

3 問の自己点検(/ai-smell-audit 相当)

  1. 同じ指示を AI に出せば数時間で出るか — count! の書き方 4 案自体は出る。ただし「N=127 は通り 128 は落ちる」「案 D だけ空入力で落ちる」「N=10,000 で 19 秒対 0.27 秒」は、実際にコンパイルしないと出ない。
  2. AI に指示するだけでは出ない部分 — 4 案を同じ入力で実測した表(P1〜P8)、案 D の空入力の落とし穴、ADR の「前提が変わったら」。
  3. 世界の実物と比べて何割か — 数え上げの手法は The Little Book の解説の中の 4 つ程度に絞った。${count()} は安定していないので表の外。実在クレートの実装の照合は未実施(上の表に明記)。

➡️ 次のステップ

  • 発展: fsm! と hashmap! の既存コードを案 B に置き換え、テストが変わらないことを確かめる(今日は置き換えていない)
  • 次回予告: (state の次回予告のまま)マクロ① の「マクロにするか、関数・型にするか」の設計判断

🎯 自己評価(解いた後に記入)

自分の回答

気づき・メモ