Day 058 — マクロ① macro_rules! ③ テストが全部通る共通マクロの PR をレビューする:6つの欠陥と1つのおとり

2026-09-28 🟣 上級者 / Phase 3 デバッグ・コードレビュー マクロ①(宣言的マクロ macro_rules!)

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

Day 056 で展開結果を予想し、Day 057 で count!・hashmap!・newtype! を書きました。今日は他人が書いたマクロをレビューする側に回ります。

マクロのレビューが関数のレビューより難しい理由は、不具合が「定義」ではなく「呼ばれ方」で出ることです。関数なら引数の型が呼び出し方を縛りますが、macro_rules! はトークンの並びを受け取って貼り付けるだけなので、作者が想定しなかった呼び出し(負の数、副作用のある式、0個の引数、別のモジュール、2回目の呼び出し)で初めて壊れます。そして作者のテストは、作者が想定した呼び出ししか書いていません。

今日のレビュー対象は、次の性質を持つ PR です。

  • cargo run は動く。cargo test は 6 passed。cargo clippy --all-targets の警告は 0 件(すべて出題者の環境で実測)
  • それでも、6か所に「呼ばれ方しだいで壊れる」欠陥がある
  • さらに、一見あやしいが実は正しい箇所が1つある(おとり)。レビューでは「直さなくてよいものを直せと言わない」ことも品質のうち

昨日までの知識で、欠陥の見当はつくはずです。今日の新しい論点は2つです。

論点内容
follow-set 制限$x:expr の直後に置けるトークンは => , ; の3つだけ。$lo:expr .. $hi:expr は定義の時点でエラーになる。これを tt で逃げると別の壊れ方をする
「マクロをやめる」という修正欠陥の一部は、マクロの書き方を直すより関数や型に置き換える方が根本的に直る。レビューでどちらを求めるか

📝 問題

同僚が PR #412「共通マクロを追加」を出しました。cargo new macro_day058(edition 2024、外部クレートなし)の src/main.rs に相当するコードです。

// ===== レビュー対象: src/macros.rs 相当(PR #412「共通マクロを追加」) =====
use std::collections::HashSet;

/// 集合リテラル。`hashset!{1, 2, 3}`
#[macro_export]
macro_rules! hashset {
    ($($x:expr),+ $(,)?) => {{
        let mut set = HashSet::new();
        $( set.insert($x); )+
        set
    }};
}

/// 範囲検査。`check_range!(v, 0..100)` → Ok(v) / Err(メッセージ)
/// `$lo:expr ..` は書けなかったので tt で受けている
macro_rules! check_range {
    ($v:expr, $lo:tt .. $hi:tt) => {
        if $v < $lo || $v >= $hi {
            Err(format!("{} is out of range {}..{}", $v, $lo, $hi))
        } else {
            Ok($v)
        }
    };
}

/// 最大値。`max_of!(3, 9, 4)` → 9
macro_rules! max_of {
    ($($x:expr),* $(,)?) => {{
        let xs = [$($x),*];
        let mut m = xs[0];
        for &x in &xs[1..] {
            if x > m { m = x; }
        }
        m
    }};
}

/// 合計。`sum_all!(1, 2, 3)` → 6
macro_rules! sum_all {
    ($x:expr) => { $x };
    ($x:expr, $($rest:expr),+ $(,)?) => { $x + sum_all!($($rest),+) };
}

/// 名前付きカウンタ。`define_counter!(REQUESTS);` → static REQUESTS と bump()
macro_rules! define_counter {
    ($name:ident) => {
        static $name: ::std::sync::atomic::AtomicU64 = ::std::sync::atomic::AtomicU64::new(0);
        fn bump() -> u64 {
            $name.fetch_add(1, ::std::sync::atomic::Ordering::Relaxed) + 1
        }
    };
}

/// 処理時間を測って標準エラーに出す。`timed!("load", { ... })`
macro_rules! timed {
    ($label:expr, $body:block) => {{
        let start = ::std::time::Instant::now();
        let r = $body;
        eprintln!("[timed] {} took {:?}", $label, start.elapsed());
        r
    }};
}

// ===== 利用側 =====
mod requests {
    define_counter!(REQUESTS);
    pub fn handle() -> u64 { bump() }
}

fn main() {
    let s = hashset! {1, 2, 3, 2};
    println!("hashset len = {}", s.len());
    println!("check_range!(42, 0..100) = {:?}", check_range!(42, 0..100));
    println!("check_range!(150, 0..100) = {:?}", check_range!(150, 0..100));
    println!("max_of!(3, 9, 4) = {}", max_of!(3, 9, 4));
    println!("sum_all!(1, 2, 3) = {}", sum_all!(1, 2, 3));
    requests::handle();
    println!("requests = {}", requests::handle());
    let start = 10;
    let v = timed!("add", { start + 1 });
    println!("timed = {v}");
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test] fn hashset_dedups() { assert_eq!(hashset!{1, 2, 2}.len(), 2); }
    #[test] fn check_range_ok_and_err() {
        assert_eq!(check_range!(5, 0..10), Ok(5));
        assert!(check_range!(10, 0..10).is_err());
    }
    #[test] fn max_of_works() { assert_eq!(max_of!(3, 9, 4), 9); assert_eq!(max_of!(7), 7); }
    #[test] fn sum_all_works() { assert_eq!(sum_all!(1, 2, 3, 4), 10); }
    #[test] fn counter_counts() { let a = requests::handle(); let b = requests::handle(); assert_eq!(b, a + 1); }
    #[test] fn timed_returns_value() { assert_eq!(timed!("t", { 1 + 1 }), 2); }
}

cargo run -q の出力(実測):

hashset len = 3
check_range!(42, 0..100) = Ok(42)
check_range!(150, 0..100) = Err("150 is out of range 0..100")
max_of!(3, 9, 4) = 9
sum_all!(1, 2, 3) = 6
requests = 2
[timed] add took 41ns
timed = 11

設問

  • (Q1) 欠陥を6つ見つける。 それぞれについて次の4点を書いてください
    1. どのマクロの、どの行か
    2. 壊れる呼び出しを1行(再現コード)
    3. 何が起きるか(コンパイルエラー/誤った値/警告のどれか。エラーならエラーの種類)
    4. 既存の6つのテストがなぜ見逃したか
  • (Q2) おとり。 timed! の中の let start は、呼び出し側の let start = 10; とぶつかるように見えます。これは欠陥ですか。根拠を Rust の衛生性(hygiene)の規則で答えてください
  • (Q3) 修正方針の設計判断。 6つのうち、check_range! と define_counter! は「マクロのまま直す」か「関数・型に置き換える」かを選び、採らなかった案とその理由を書いてください
  • (Q4) 修正後のコードと回帰テスト。 6つの欠陥それぞれに、修正前なら失敗し修正後に通るテストを1つ以上書いてください(コンパイルエラーになる欠陥は、修正後にコンパイルが通ることをもってテストとしてよい)

💡 ヒント

ヒント1(方向性)
  • 6つの欠陥は、昨日の次回予告どおり 評価回数・絶対パス・follow-set・0要素・再帰上限・衛生性(項目) の6分類に1つずつ対応します
  • 各マクロについて「作者が試していない呼ばれ方」を列挙してください。副作用のある式・2トークン以上の式(-1、n + 1)・0個の引数・use していない別のモジュール・129個の引数・同じモジュールで2回
  • テストが見逃した理由は、テストのコードの「書かれていないもの」を探すと見つかります。特に mod tests の1行目
ヒント2(アプローチ)
  • 評価回数: check_range! の展開結果に $v が何回残るか数えてください。Iterator::next() を渡すと値そのものが変わります
  • follow-set: Rust Reference の「Follow-set Ambiguity Restrictions」に、expr の後に置けるトークンの一覧があります。tt はトークン木1個なので、-1 は - と 1 の2個です
  • 0要素: max_of!() の展開結果は let xs = []; let mut m = xs[0]; ... です。型注釈がある場合とない場合で別のエラーになります
  • 再帰上限: sum_all! は1要素ずつ剥がして自分を呼びます。既定の recursion_limit は 128
  • 衛生性: macro_rules! の衛生性はローカル変数・ラベル・$crate にしか効きません。fn bump は項目です
  • 修正で match $v { v => ... } の形を使うのは、std の assert_eq! と同じ書き方です
ヒント3(コード骨格)
// 評価回数 + follow-set: `..` を自分で解析せず、範囲は1つの式として受ける
macro_rules! check_range {
    ($v:expr, $range:expr $(,)?) => {
        match ($v, $range) {
            (v, range) => if range.contains(&v) { Ok(v) } else { Err(format!(/* ... */)) }
        }
    };
}

// 0要素: 1個以上を文法で要求する。1個だけの規則を先に置く
macro_rules! max_of {
    ($only:expr $(,)?) => { $only };
    ($first:expr $(, $rest:expr)+ $(,)?) => {{ let mut m = $first; $( /* ... */ )+ m }};
}

// 再帰上限: 再帰せずに `+` を並べる
macro_rules! sum_all {
    ($first:expr $(, $rest:expr)* $(,)?) => { $first $(+ $rest)* };
}

// 衛生性(項目): そもそもマクロにしない
pub struct Counter(::std::sync::atomic::AtomicU64);
// impl Counter { pub const fn new() -> Self { ... } pub fn bump(&self) -> u64 { ... } }

✅ 模範解答

Q1 の回答: 6つの欠陥(すべて出題者の環境で再現を確認)

#分類場所壊れる呼び出し何が起きるか既存テストが見逃した理由
1評価回数check_range! の $v が展開結果に4か所check_range!(it.next().unwrap(), 0..10)(it は [5, 50, 7] の反復子)誤った値。範囲内の 5 を渡したのに Err("7 is out of range 0..10")テストの $v がリテラル(5・10)だけで、副作用が無い
2絶対パスhashset! の HashSet::new()mod report { pub fn f() -> usize { crate::hashset!{"a"}.len() } }E0433 cannot find type HashSet in this scopeファイル先頭の use std::collections::HashSet; と、テストの use super::*; が HashSet を持ち込んで隠していた
3follow-setcheck_range! の $lo:tt .. $hi:ttcheck_range!(-5, -1..10) / check_range!(99, 0..MAX - 1) / check_range!(5, 0..n + 1)コンパイルエラー no rules expected '1'(- と + でも同様)テストの境界値が 0 と 10 の1トークンだけ
40要素max_of! の $(...),*(0回を許す)と xs[0]let m: i32 = max_of!();コンパイルエラー this operation will panic at runtime(型注釈なしなら E0282)テストが max_of! を1個以上でしか呼んでいない
5再帰上限sum_all! の自己再帰sum_all!(1, 1, ..., 1)(129個)コンパイルエラー recursion limit reached while expanding sum_all!テストの要素数が4個
6衛生性(項目)define_counter! の fn bump同じモジュールで define_counter!(REQUESTS); define_counter!(ERRORS);E0428 the name bump is defined multiple timesテストも利用側も、1つのモジュールでカウンタを1個しか作っていない

加点ポイント(6つとは別に見つけられたら): max_of! は xs[0] と for &x in で要素をコピーするので、String のような Copy でない型では E0508・E0507 になります(実測)。修正版はこれも直ります。

欠陥1 の再現(実測)

let calls = Cell::new(0);
let read = || { calls.set(calls.get() + 1); 42 };
check_range!(read(), 0..100);   // read() が3回呼ばれる
ok: Ok(42), read() called 3 times
err: Err("42 is out of range 0..10"), read() called 6 times total
iter: Err("7 is out of range 0..10")

$v は展開結果の $v < $lo・$v >= $hi・format!(... $v ...)・Ok($v) の4か所に残ります。|| の短絡評価と if/else の分岐で、実際に評価されるのは毎回3回です。it.next().unwrap() の例では、1回目 5(5 < 0 は偽)→ 2回目 50(50 >= 10 は真)→ 3回目 7(メッセージに使われる)となり、範囲内の値を渡したのに、別の値で失敗を報告するという最悪の壊れ方をします。

Q2 の回答: timed! の let start は欠陥ではない

macro_rules! は、マクロの本体で導入したローカル変数を、呼び出し側の同名の変数と別物として扱います(衛生性)。展開後も、マクロ内の start は「定義の場所」の文脈を持ち、$body の中の start は「呼び出しの場所」の文脈を持つので、名前の解決が混ざりません。

let start = 10; let r = 5;
let v = timed!("x", { start + r });   // 実測: v = 15(呼び出し側の start = 10 を使った)

一方、欠陥6の fn bump は項目なので衛生性が効きません(Rust Reference: macro_rules! の衛生性は「mixed site」で、ローカル変数・ラベル・$crate だけが定義側で解決される)。同じ「マクロの中で名前を導入する」でも、let と fn で結果が逆になる、が Q2 の要点です。レビューで timed! の start を __start のような名前に変えさせるのは、C のマクロの習慣を持ち込んだ不要な指摘です。

Q3 の回答: 修正方針の判断

check_range! → 関数に置き換える(推奨)。マクロを残す場合も範囲を1つの式で受ける

案呼び出し-1..100..MAX - 10..=100評価回数判定
A: 元の $lo:tt .. $hi:ttcheck_range!(v, 0..10)✗✗✗3回却下
B: $lo:literal .. $hi:literal同上○(-1 は1個の literal に一致する)✗✗直せば1回却下: 定数・式が書けない
C: ($v:expr, $lo:expr, $hi:expr)check_range!(v, 0, 10)○○✗直せば1回却下: .. か ..= かが呼び出しから読めない
D: ($v:expr, $range:expr) + matchcheck_range!(v, 0..10)○○○1回マクロを残すならこれ
E: 関数 check_range(v, range)check_range(v, 0..10)○○○1回(構造的に保証)採用
  • 採った理由: 案 D と E は、.. の解析を言語の式の文法に任せる点で同じです。0..MAX - 1 は Rust の式として1つの Range<i32> になり、RangeBounds::contains で検査できます。そうなるともうマクロでしかできないこと(可変個の引数・新しい構文)が残っていないので、型検査・エラーメッセージ・IDE 補完が効く関数にします
  • 案 D を残す条件: エラーメッセージに呼び出し側のソースの字面(stringify!($v) で "user.age" など)を入れたい場合。これはマクロにしかできません。std の assert_eq! がマクロなのは、まさに字面と file!()/line!() を埋め込むためです
  • 前提が変わったら: check_range を const の文脈で使いたくなったら、RangeBounds::contains は const fn ではない(2026-09 時点の stable)ので、const fn で下限・上限を比べる関数を別に用意する

define_counter! → 型に置き換える

案内容判定
A: 生成する関数名を呼び出し側から受け取る define_counter!(REQUESTS, bump_requests)衝突は避けられるが、呼び出しのたびに名前を2つ考えさせる却下
B: paste クレートで bump_ と $name を連結した識別子を作る外部の手続き的マクロ依存が1つ増える。macro_rules! だけでは識別子の連結ができない(stable)却下
C: pub mod $name { ... } を生成して名前空間を分けるモジュール名が REQUESTS になり non_snake_case の警告。呼び出しが REQUESTS::bump() という見慣れない形却下
D: pub struct Counter(AtomicU64) と const fn new。利用側は static REQUESTS: Counter = Counter::new();マクロが不要。何個作っても衝突しない。AtomicU64::new が const fn なので static の初期化に使える採用
  • 採った理由: 元のマクロが生成していたのは「static 1個と、それを操作する関数1個」です。これは型とメソッドそのもので、マクロで書く理由がありません。Day 057 の「マクロは型システムで書けない部分だけ」の原則の適用です
  • 前提が変わったら: カウンタごとに名前付きのメトリクスとして登録(Prometheus 等への登録)が必要になり、名前の文字列と static を必ず対にしたいなら、register!(REQUESTS, "http_requests_total") のようなマクロを改めて検討する(stringify! で名前を取り出せるのはマクロだけ)

Q4 の回答: 修正後のコードと回帰テスト

cargo new macro_day058 の src/main.rs をこれで置き換えます。Cargo.toml は生成されたまま(edition = "2024"、依存なし)です。

修正後のコード全体を開く(自分で直してから)
use std::cell::Cell;
use std::fmt::{Debug, Display};
use std::ops::RangeBounds;
use std::sync::atomic::{AtomicU64, Ordering};

// ---------- 欠陥2 の修正: 絶対パス ----------
#[macro_export]
macro_rules! hashset {
    ($($x:expr),+ $(,)?) => {{
        let mut set = ::std::collections::HashSet::new();
        $( set.insert($x); )+
        set
    }};
}

// ---------- 欠陥1・3 の修正(推奨): マクロをやめて関数にする ----------
/// `..` の解析は言語の式の文法に任せる。`-1..10`・`0..MAX - 1`・`0..=100` がすべて1つの式
pub fn check_range<T, R>(v: T, range: R) -> Result<T, String>
where
    T: PartialOrd + Display,
    R: RangeBounds<T> + Debug,
{
    if range.contains(&v) { Ok(v) } else { Err(format!("{v} is out of range {range:?}")) }
}

// ---------- 欠陥1・3 の修正(マクロを残す場合) ----------
macro_rules! check_range {
    ($v:expr, $range:expr $(,)?) => {
        // match で1回だけ評価して束縛する(std の assert_eq! と同じ形)
        match ($v, $range) {
            (v, range) => {
                if range.contains(&v) {
                    Ok(v)
                } else {
                    Err(format!("{} is out of range {:?}", v, range))
                }
            }
        }
    };
}

// ---------- 欠陥4 の修正: 1個以上を文法で要求し、添字を使わない ----------
macro_rules! max_of {
    // 1個だけの規則を先に置く。無いと `let mut m` が変更されず unused_mut 警告(実測)
    ($only:expr $(,)?) => { $only };
    ($first:expr $(, $rest:expr)+ $(,)?) => {{
        let mut m = $first;
        $(
            let x = $rest;
            if x > m { m = x; }
        )+
        m
    }};
}

// ---------- 欠陥5 の修正: 再帰しない ----------
macro_rules! sum_all {
    ($first:expr $(, $rest:expr)* $(,)?) => { $first $(+ $rest)* };
}

// ---------- 欠陥6 の修正: マクロをやめて型にする ----------
pub struct Counter(AtomicU64);
impl Counter {
    pub const fn new() -> Self { Self(AtomicU64::new(0)) }
    pub fn bump(&self) -> u64 { self.0.fetch_add(1, Ordering::Relaxed) + 1 }
    pub fn get(&self) -> u64 { self.0.load(Ordering::Relaxed) }
}
impl Default for Counter { fn default() -> Self { Self::new() } }

// ---------- 問題なし(おとり): timed! は元のまま ----------
macro_rules! timed {
    ($label:expr, $body:block) => {{
        let start = ::std::time::Instant::now();
        let r = $body;
        eprintln!("[timed] {} took {:?}", $label, start.elapsed());
        r
    }};
}

mod metrics {
    use super::Counter;
    pub static REQUESTS: Counter = Counter::new();
    pub static ERRORS: Counter = Counter::new(); // 2つ目も衝突しない
}

mod report {
    // HashSet を use していないモジュール
    pub fn tags() -> usize { crate::hashset! {"a", "b", "a"}.len() }
}

const MAX: i32 = 100;

fn main() {
    let calls = Cell::new(0);
    let read = || { calls.set(calls.get() + 1); 42 };
    println!("check_range!(read(), 0..100) = {:?}, read() calls = {}", check_range!(read(), 0..100), calls.get());
    let mut it = [5, 50, 7].into_iter();
    println!("check_range!(it.next().unwrap(), 0..10) = {:?}", check_range!(it.next().unwrap(), 0..10));
    println!("check_range(-5, -1..10) = {:?}", check_range(-5, -1..10));
    println!("check_range(99, 0..MAX - 1) = {:?}", check_range(99, 0..MAX - 1));
    println!("check_range(100, 0..=MAX) = {:?}", check_range(100, 0..=MAX));
    println!("report::tags() = {}", report::tags());
    println!("max_of!(3, 9, 4) = {}, strings = {}", max_of!(3, 9, 4), max_of!(String::from("a"), String::from("b")));
    println!("sum_all!(1, 2, 3) = {}", sum_all!(1, 2, 3));
    metrics::REQUESTS.bump();
    metrics::REQUESTS.bump();
    metrics::ERRORS.bump();
    println!("requests = {}, errors = {}", metrics::REQUESTS.get(), metrics::ERRORS.get());
    let start = 10;
    println!("timed = {}", timed!("add", { start + 1 }));
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test] fn d1_value_evaluated_once() {
        let calls = Cell::new(0);
        let read = || { calls.set(calls.get() + 1); 42 };
        assert_eq!(check_range!(read(), 0..10), Err("42 is out of range 0..10".to_string()));
        assert_eq!(calls.get(), 1);
        let mut it = [5, 50, 7].into_iter();
        assert_eq!(check_range!(it.next().unwrap(), 0..10), Ok(5));
    }
    #[test] fn d3_ranges_with_expressions() {
        assert_eq!(check_range(-5, -1..10), Err("-5 is out of range -1..10".to_string()));
        assert_eq!(check_range(99, 0..MAX - 1), Err("99 is out of range 0..99".to_string()));
        assert_eq!(check_range(100, 0..=MAX), Ok(100));
        let n = 3;
        assert_eq!(check_range!(3, 0..n + 1), Ok(3));
    }
    #[test] fn d4_max_of_non_copy() {
        assert_eq!(max_of!(String::from("a"), String::from("c"), String::from("b")), "c");
        assert_eq!(max_of!(7), 7);
    }
    #[test] fn d5_sum_200_terms() {
        let s: u32 = sum_all!(1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1,
            1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1,
            1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1,
            1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1);
        assert_eq!(s, 200);
    }
    #[test] fn d6_two_counters_in_one_module() {
        let c1 = Counter::new();
        let c2 = Counter::new();
        c1.bump(); c1.bump(); c2.bump();
        assert_eq!((c1.get(), c2.get()), (2, 1));
    }
    #[test] fn decoy_timed_uses_callers_start() {
        let start = 10;
        assert_eq!(timed!("t", { start + 1 }), 11);
    }
    #[test] fn precedence_is_preserved() {
        // $first $(+ $rest)* でも expr は不透明な塊なので 2*3 + 4*5
        assert_eq!(sum_all!(2 * 3, 4 * 5), 26);
    }
}

// use super::* を書かない = 呼び出し側が HashSet を import していない状況
#[cfg(test)]
mod d2_no_import {
    #[test] fn hashset_without_use() {
        assert_eq!(crate::hashset! {"a", "b", "a"}.len(), 2);
        assert_eq!(crate::report::tags(), 2);
    }
}

実測(出題者の環境: rustc 1.96.0 / cargo 1.96.0 / edition 2024 / Apple M1 8GB / macOS 26.5.1、2026-09-28)

cargo run -q の出力(実測):

check_range!(read(), 0..100) = Ok(42), read() calls = 1
check_range!(it.next().unwrap(), 0..10) = Ok(5)
check_range(-5, -1..10) = Err("-5 is out of range -1..10")
check_range(99, 0..MAX - 1) = Err("99 is out of range 0..99")
check_range(100, 0..=MAX) = Ok(100)
report::tags() = 2
max_of!(3, 9, 4) = 9, strings = b
sum_all!(1, 2, 3) = 6
requests = 2, errors = 1
[timed] add took 83ns
timed = 11

cargo test -q → test result: ok. 8 passed; 0 failed(実測)。cargo clippy -q --all-targets の出力は 0 行(実測)。

修正前の PR も 6 passed・clippy 0 行でした。テストの件数と clippy の結果は、どちらの版でも「問題なし」を示していたことに注意してください。差を作ったのは、欠陥ごとに足した「作者が試していない呼ばれ方」のテストだけです。

計測1: sum_all! の再帰版は 129 個で壊れる(実測。rustc --edition 2024 で単体ファイルをビルド)

要素数修正前(再帰)修正後($first $(+ $rest)*)
126126—
127127—
128128(通る)—
129エラー: recursion limit reached—
1,000既定ではエラー(#![recursion_limit = "4096"] を付ければ通る)1000
10,000(試していない)10000

Day 057 の count_rec! は 128 個で壊れましたが、sum_all! は 129 個で壊れます。count_rec! は ()(0個)まで再帰するのに対し、sum_all! は1個の規則 ($x:expr) => { $x } で止まるので、再帰が1段少ないからです。「上限は 128」と覚えるより「再帰の段数が 128 を超えたら」と覚える方が正確です。

計測2: 再帰をやめても、10,000 要素ではコンパイル時間が伸びる(実測、各3回、real 秒)

修正後の sum_all! を大きな入力で測ったところ、予想していなかった結果が出ました。

書き方1,000 要素(3回)10,000 要素(3回)
修正前(再帰、recursion_limit = "4096")0.78 / 0.72 / 0.77(試していない)
修正後 $first $(+ $rest)*、引数 10.22 / 0.20 / 0.2010.71 / 10.79 / 10.59
修正後、引数を 1u32(型付きリテラル)—0.53 / 0.35 / 0.31
比較: 文を並べる let mut s = $first; $( s += $rest; )* s0.75 / 0.22 / 0.204.79 / 4.67 / 4.69
比較: 配列で [$first $(, $rest)*].into_iter().sum()0.18 / 0.11 / 0.090.11 / 0.11 / 0.11
比較: 修正後、-C overflow-checks=off—10.21 / 10.18
  • 要素数 10 倍で 0.2 秒 → 10.7 秒(約 50 倍)。再帰上限は消えたが、展開結果の後の段階(型推論)で非線形に遅くなる
  • 引数を 1u32 にすると 0.3 秒に戻り、overflow-checks を切っても 10.2 秒のまま。ここから、原因は整数リテラルの型推論(1 の型を u32 に決めるまでに 10,000 個の未確定の整数型をつなぐ)と推定します(導出。stable では -Z time-passes が使えず、コンパイラ内部の内訳は測っていない)
  • 配列で .sum() する版は、要素数によらず 0.1 秒でした

判断: 修正後の sum_all! はどちらの書き方にするか

$first $(+ $rest)*[...].into_iter().sum()
const で使えるか使える(+ は const 文脈で評価できる)使えない(Iterator::sum は const fn ではない)
10,000 要素(型なしリテラル)10.7 秒0.11 秒
型が Add しか実装しない場合使えるSum の実装が要る
  • 採った案: $first $(+ $rest)*。この PR の用途(設定値の重みの合計など、数十〜数百個)では 1,000 要素でも 0.2 秒で、const TOTAL: u32 = sum_all!(...) と書ける利点の方が大きいため
  • 前提が変わったら: 数千個以上のデータ表を扱うなら、そもそもマクロ引数に並べず build.rs でファイルから生成するか、const 配列 + const fn のループで合計する(while ループは const fn の中で使える)

🪜 Step-by-Step 解説

Step 1: レビューの手順 —「展開結果に何回・どこに残るか」を1つずつ書き出す

マクロのレビューは、各メタ変数について次の表を作ると漏れが減ります。

メタ変数展開結果に残る回数評価される回数判定
check_range! の $v43(分岐による)欠陥1
check_range! の $lo $hi各2各1〜21トークンなら副作用は無いが、tt の制約が別の欠陥(欠陥3)を生む
max_of! の $x1(配列の中)1問題なし(欠陥は0要素側)
timed! の $label $body各1各1問題なし

$lo $hi が2回ずつ現れても実害が無いのは、tt が1トークン(リテラルか識別子)しか受け取れないので副作用のある式が入らないからです。欠陥3を $lo:expr に直すと、今度は $lo の2回評価が表に出てくるので、欠陥1と欠陥3は一緒に直す必要があります。これが案 D・E で範囲を1つの値として受ける理由の1つです。

Step 2: 欠陥3 — follow-set を tt で逃げると何が起きるか

作者のコメント「$lo:expr .. は書けなかった」は本当です(実測)。

error: `$lo:expr` is followed by `..`, which is not allowed for `expr` fragments --> fs.rs:1:48 | 1 | macro_rules! check_range2 { ($v:expr, $lo:expr .. $hi:expr) => { () }; } | ^^ not allowed after `expr` fragments | = note: allowed there are: `=>`, `,` or `;`

これはマクロの定義の時点で出るエラーです。expr は「どこまでが式か」をパーサが決めるので、.. のように式の一部にもなれるトークンが後ろに来ると、将来の文法の拡張で意味が変わるおそれがあります。Rust はこれを防ぐため、expr の直後に置けるトークンを => , ; の3つに限っています(Rust Reference「Follow-set Ambiguity Restrictions」)。

作者は tt に替えて定義を通しましたが、tt はトークン木1個です。-1 は - と 1 の2トークン、MAX - 1 は3トークンなので一致しません。

書いた範囲トークン$lo:tt .. $hi:tt に一致するか
0..100 .. 10○
0..MAX0 .. MAX○
-1..10- 1 .. 10✗(1 で失敗)
0..MAX - 10 .. MAX - 1✗(- で失敗)
0..(MAX - 1)0 .. (...)○(括弧で囲むと1個の木になる)

最後の行のとおり、呼び出し側が括弧を付ければ動くので、作者は「使い方の問題」と言うかもしれません。レビューでは「-1 を括弧で囲めというルールを利用者全員に覚えさせるのか」と問うのが正しい指摘です。

Step 3: 欠陥1 の修正 — let ではなく match で束縛する理由

評価を1回にするには、$v を一度だけ変数に束縛します。

match ($v, $range) {
    (v, range) => { /* v と range を何度使ってもよい */ }
}

{ let v = $v; ... } でもこの問題では動きますが、std の assert_eq! は match (&$left, &$right) { (left_val, right_val) => ... } と書いています(library/core/src/macros/mod.rs)。match の対象の式の中で作られた一時値は、match 全体が終わるまで生きるので、&$left のように一時値への参照を取っても安全です。let で参照を束縛すると、一時値はその文の終わりで破棄されます(ただし let x = &temp(); の形は一時値の寿命が延長される規則があり、差が出るのは入れ子の場合)。マクロは呼び出し側がどんな式を渡すか分からないので、std は常に安全な match を選んでいる、と理解してください。

Step 4: 欠陥2 — テストの use super::* が不具合を隠す

欠陥2で一番学ぶべきなのは、不具合そのものよりなぜテストが通ったかです。

  • ファイル先頭の use std::collections::HashSet; により、クレートのルートで HashSet が見える
  • mod tests { use super::*; ... } が、ルートの use をまとめて持ち込む
  • そのため、テストの中の hashset! の展開結果 HashSet::new() は解決できてしまう

macro_rules! で型名や関数名は呼び出し側のスコープで解決される(衛生的でない)ので、テストが「呼び出し側がたまたま use していた」状態で走っていたのです。回帰テストは use super::* を書かないモジュール(模範解答の mod d2_no_import)に置きます。実務では、#[macro_export] するマクロのテストは別クレート(tests/ ディレクトリの結合テスト)から呼ぶのが確実です。結合テストは別クレートとしてコンパイルされるので、$crate と絶対パスの誤りがそのまま出ます。

コンパイラの help が use std::collections::HashSet; を呼び出し側に足せと言う点も、Day 057 のエラー3と同じ罠です(下の「コンパイルエラー」)。

Step 5: 欠陥4 — 0要素のエラーは2種類ある

呼び出し出るもの
let m = max_of!();E0282 type annotations needed([] の要素型が決まらない)
let m: i32 = max_of!();error: this operation will panic at runtime(xs[0] が長さ0の配列の添字 0。unconditional_panic は既定で deny)

どちらのエラーも、利用者から見ると「0個で呼んではいけない」とは一言も言っていません。修正版は規則を $first:expr から始めて1個以上を文法で要求するので、max_of!() は次の1つのエラーになります(実測)。

error: unexpected end of macro invocation --> f_d4.rs:81:26 | 44 | macro_rules! max_of { | ------------------- when calling this macro ... 81 | fn main() { let m: i32 = max_of!(); } | ^^^^^^^^^ missing tokens in macro arguments | note: while trying to match meta-variable `$only:expr`

missing tokens in macro arguments と、マクロの呼び出しそのものを指すエラーになりました。マクロの誤用は、展開結果の中ではなく呼び出しの形の段階で弾くのが、利用者に親切な設計です。

修正の途中でもう1つ実測で見つかったことがあります。1個以上を受ける規則だけにすると、max_of!(7) の展開結果 { let mut m = 7; m } で m が一度も変更されず、unused_mut 警告が呼び出し側に出ました(clippy で実測)。1個だけの規則 ($only:expr $(,)?) => { $only }; を先に置いて解消しています。Day 057 の hashmap!{} と同じ「繰り返しが0回のとき展開結果に何が残るか」の問題です。

Step 6: 欠陥6 — 項目は衛生的でない

define_counter! を同じモジュールで2回呼ぶと、fn bump が2つできて E0428 になります。

static $name の方は呼び出し側が名前を渡すので衝突しませんが、fn bump はマクロの中で固定の名前を付けています。let の変数なら衛生性で守られますが、fn・struct・static・impl などの項目は、定義側の名前がそのまま呼び出し側のモジュールに置かれます。

作者が気づかなかったのは、mod requests の中で1回しか呼ばなかったからです。「このマクロは1モジュールに1回しか呼べない」という暗黙の制約は、ドキュメントにも型にも表れないので、レビューで指摘しないと次の利用者が踏みます。

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

6つの欠陥は「マクロが型に守られていない」ことの6つの現れ

欠陥関数なら起きない理由
1. 評価回数関数の引数は呼び出し前に1回だけ評価される
2. 絶対パス関数の本体の名前は定義側のスコープで解決される
3. follow-set関数の引数は1つの式として構文解析される。.. も普通の演算子
4. 0要素引数の個数は関数のシグネチャで固定。可変長なら &[T] で受けて空を型で扱う
5. 再帰上限関数の再帰には展開の上限が無い(実行時のスタックだけ)
6. 項目の衝突関数を何回呼んでも新しい項目は作られない

macro_rules! の欠陥は、どれも「関数なら言語が保証してくれることを、マクロの作者が自分で保証しなければならない」ところから生まれています。だから今日の修正のうち2つ(check_range・Counter)は、マクロを直すのではなくマクロをやめることで根本的に直しました。

判断の記録: 今日の問題の作り方

判断内容
採った案6つの欠陥と1つのおとりを6つのマクロに分けて置いた。修正前も cargo test 6 passed・clippy 0 件になるよう作り、「テストと lint が通っていてもマクロは壊れている」を実測で見せた
迷った案1: 1つの大きなマクロに6つの欠陥を詰める実務の PR に近いが、欠陥どうしが干渉して再現コードが複雑になる。1マクロ1欠陥(check_range! だけ2つ)にした。欠陥1と3が同じマクロにあるのは、Step 1 のとおり片方だけ直すともう片方が表に出る関係を見せたかったため
迷った案2: おとりを入れない入れないと「全部あやしい」と言えば満点になる。実務のレビューでは誤った指摘にも修正の手間がかかるので、衛生性で守られる let start をおとりにした
迷った案3: trybuild でコンパイルエラーのテストを書くtrybuild は「このコードはコンパイルに失敗すべき」を自動テストにできる実物のクレート(serde・thiserror などが使う)。ただ外部依存が増え、今日の論点がマクロから外れるので、コンパイルエラーの確認は単体の rustc で行った
出題者が実装して初めて分かったこと① sum_all! は 128 個ではなく 129 個で壊れる(Day 057 の count_rec! と1個ずれる)。② 再帰をやめても 10,000 要素では型推論で 10.7 秒かかる(計測2)。③ max_of!(7) で unused_mut 警告(Step 5)。どれも本文に取り込んだ
前提が変わったら利用者が別クレートにいる前提なら、欠陥2の確認は結合テスト(tests/)で行う。今日は単一クレート内の mod で代用した(Day 057 の宿題「$crate の別クレート実測」は今日も未消化)

🚨 コンパイルエラーが出た場合

以下はすべて出題者の環境(rustc 1.96.0、edition 2024、aarch64 macOS)で実際に出た出力です(色コードは除去。一部の行を省略)。

欠陥2: E0433 — help に従ってはいけない例

error[E0433]: cannot find type `HashSet` in this scope --> r_d2.rs:8:23 | 8 | let mut set = HashSet::new(); | ^^^^^^^ use of undeclared type `HashSet` ... 64 | mod report { pub fn tags() -> usize { crate::hashset!{"a", "b"}.len() } } | ------------------------- in this macro invocation | = note: this error originates in the macro `crate::hashset` (in Nightly builds, run with -Z macro-backtrace for more info) help: consider importing one of these structs | 64 | mod report { use crate::HashSet; | +++++++++++++++++++ 64 | mod report { use std::collections::HashSet; | ++++++++++++++++++++++++++++++

読み方: 1つ目の --> はマクロ定義の中(8行目)、in this macro invocation は呼び出し(64行目)を指します。help は呼び出し側 mod report に use を足せと2案出しますが、どちらも利用者に内部事情を背負わせる案です。1案目の use crate::HashSet; は、クレートのルートの use を再利用するという欠陥2を隠していた仕組みそのものです。直すのはマクロ側の ::std::collections::HashSet です。

欠陥3: no rules expected — どのトークンで照合に失敗したか

error: no rules expected `1` --> r_d3a.rs:64:48 | 16 | macro_rules! check_range { | ------------------------ when calling this macro ... 64 | fn main() { println!("{:?}", check_range!(-5, -1..10)); } | ^ no rules expected this token in macro call | note: while trying to match `..` --> r_d3a.rs:17:22 | 17 | ($v:expr, $lo:tt .. $hi:tt) => { | ^^

読み方: note: while trying to match が規則のどこまで一致したかを示します。$lo:tt は - に一致し、次に .. を期待したところに 1 が来た、と読めます。0..MAX - 1 では note: while trying to match meta-variable $hi:tt が出て、$hi が MAX を取った後の - で失敗したと分かります(実測)。

欠陥4: 0要素(型注釈あり)

error: this operation will panic at runtime --> r_d4b.rs:31:21 | 31 | let mut m = xs[0]; | ^^^^^ index out of bounds: the length is 0 but the index is 0 ... 65 | fn main() { let m: i32 = max_of!(); println!("{m}"); } | --------- in this macro invocation | = note: `#[deny(unconditional_panic)]` on by default

読み方: 「実行時に panic する」と言いながらコンパイルエラーです。コンパイラが定数の添字と長さ0の配列を見て、必ず panic すると判定したためです(unconditional_panic リントは既定で deny)。長さが実行時に決まる Vec なら、この検査は働かず実行時の panic になります。

欠陥5: 再帰上限 — help の数字を上げるのは最後の手段

error: recursion limit reached while expanding `sum_all!` --> s_129.rs:42:48 | 42 | ...expr),+ $(,)?) => { $x + sum_all!($($rest),+) }; | ^^^^^^^^^^^^^^^^^^^^ ... = help: consider increasing the recursion limit by adding a `#![recursion_limit = "256"]` attribute to your crate (`s_129`)

読み方: help は利用者のクレート s_129 に属性を足せと言います。上限は展開が起きるクレートの属性で決まるので、マクロの作者が直すことはできません。Day 057 の D1 と同じく、マクロの作者は再帰しない形に書き換えるべきです。

欠陥6: E0428

error[E0428]: the name `bump` is defined multiple times --> r_d6.rs:49:9 | 49 | fn bump() -> u64 { | ^^^^^^^^^^^^^^^^ | | | `bump` redefined here | previous definition of the value `bump` here ... 67 | define_counter!(ERRORS); | ----------------------- in this macro invocation | = note: `bump` must be defined only once in the value namespace of this module

読み方: redefined here と previous definition が同じ行(49行目)を指しています。同じマクロの定義行から2回生成された、というマクロ特有の見え方です。2つの呼び出しのうち、2回目の呼び出し(67行目)だけが示されます。

加点: 非 Copy 型で E0508

error[E0508]: cannot move out of type `[String; 2]`, a non-copy array --> r_d4d.rs:31:21 | 31 | let mut m = xs[0]; | ^^^^^ | | | cannot move out of here | move occurs because `xs[_]` has type `String`, which does not implement the `Copy` trait

help は &xs[0] か xs[0].clone() を提案しますが、前者は戻り値の型が変わり、後者は不要な複製です。修正版は配列を作らず let mut m = $first; と所有権を順に移すので、String でも複製なしで動きます。

🌐 他言語との比較

観点C プリプロセッサC++GoTypeScriptRust macro_rules!
評価回数#define MAX(a,b) ((a)>(b)?(a):(b)) は2回評価(古典的な不具合)テンプレート関数・constexpr で置き換えるのが定石マクロが無い(関数・ジェネリクス)マクロが無い展開結果に残った回数だけ評価。match で束縛して防ぐ
名前の衝突(衛生性)衛生性なし。マクロ内の tmp が呼び出し側の tmp を捕まえる同左(プリプロセッサ)——ローカル変数・ラベル・$crate は衛生的。項目は衛生的でない(欠陥6)
引数の構文の制約トークン列をそのまま貼る。制約は , の分割だけ同左——フラグメントの種類で受け、follow-set で将来の文法拡張と衝突しないことを保証(欠陥3)
再帰自己参照は展開しない(再帰できない)テンプレートの再帰に深さの上限(処理系依存)—型レベルの再帰に深さの上限あり(型の条件分岐)recursion_limit(既定 128)

実物の例として、Linux カーネルの include/linux/minmax.h の min()/max() は、C の2回評価を避けるために GCC 拡張の文の式 ({ ... }) と typeof・__UNIQUE_ID で一時変数を作り、その名前が呼び出し側と衝突しないよう一意の名前を生成しています。Rust の macro_rules! は、この2つ(1回だけの束縛と、衝突しない一時変数名)を match と衛生性で言語の側が提供している、と比べると違いが分かります。

🏭 実務での使いどころ

  • 共通マクロの PR レビューのチェックリスト(今日の6分類をそのまま使える)
    1. 各メタ変数は展開結果に何回残るか(2回以上なら match で束縛)
    2. #[macro_export] するマクロの中のパスは ::std::...・$crate::... か
    3. tt で受けている箇所は、2トークン以上の入力(負の数・演算)で壊れないか
    4. 繰り返しが0回・1回のとき、展開結果がエラー・警告を出さないか
    5. 自己再帰していないか。しているなら要素数の上限をドキュメントに書いたか
    6. マクロ内で固定の名前の項目(fn・struct・static)を作っていないか
    7. そもそも関数か型で書けないか
  • テストの置き場所: #[macro_export] するマクロのテストは tests/ の結合テストに置く。use super::* の単体テストは欠陥2を隠す
  • コンパイルエラーの回帰テスト: 実務では trybuild(dtolnay 作。serde・thiserror などが採用)で「max_of!() はこのエラーで失敗する」を自動テストにする

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
テストが全部通り clippy も 0 件なのでマクロは安全関数ならテストが呼び出し方をある程度代表するマクロは呼ばれ方の数だけ別のコードになる。今日の PR は 6 passed・clippy 0 件のまま6つの欠陥を持っていた(実測)
follow-set エラーは tt に替えれば解決定義のエラーが消えるので直ったように見えるtt は1トークン。-1・MAX - 1 で呼び出し側が壊れる(欠陥3)。範囲を $range:expr で1つの式として受ける
マクロ内の変数名は __tmp のように珍しい名前にすべきC のマクロの習慣let の変数は衛生性で守られる(Q2 のおとり)。守られないのは項目だけ
再帰上限は 128 個Day 057 の count_rec! が 128 個で壊れた上限は再帰の段数に掛かる。sum_all! は 129 個で壊れた(実測)。基底の規則の書き方で1個ずれる
再帰をやめればコンパイル時間の問題も消える再帰の上限だけを見ている10,000 要素の + の連鎖は型推論で 10.7 秒(実測)。型付きリテラルなら 0.3 秒
コンパイラの help に従えば直るふだんは help が正しいマクロ由来のエラーでは、help は呼び出し側を直す案を出しがち(E0433 の use 追加、再帰上限の属性追加)。直すべきはマクロの定義

🔗 一次資料(参照日: 2026-09-28。記述は各資料の要旨で、原文の引用ではありません)

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

扱わなかったもの理由・予定
別クレートからの #[macro_export] と $crate の実測Day 057 から持ち越し。今日も単一クレート内の mod で代用した。ワークスペースで2クレートにする回にまとめる
trybuild でのコンパイルエラーの自動テスト外部依存が増えるため、今日は単体の rustc で出力を確かめた。導入するなら欠陥3・4・5・6の4本が対象
format!・Ok・Err の上書き(呼び出し側が macro_rules! format や独自の Ok を定義した場合)std は $crate::__export::format_args! のように内部の再エクスポートで防いでいる。自作マクロでどこまで防ぐかは Phase 4 #8(DSL 設計)で
10,000 要素でのコンパイル時間の内訳stable では -Z time-passes が使えない。型推論が原因というのは、型付きリテラルで 0.3 秒に戻ったことからの推定
stringify! でエラーメッセージに呼び出しの字面を入れる版の check_range!Q3 で「マクロを残す条件」として挙げただけ。次の実装回の題材にする
tt マンチャ・内部規則を使った DSLマクロ① の後半

➡️ 次のステップ

  • 発展: 修正版の check_range! に stringify!($v) を足して、Err("user.age = 150 is out of range 0..100") のように呼び出し側の式の字面をメッセージに含める。関数版では絶対に書けない、マクロを残す理由になる機能です
  • 次回予告: マクロ① の4問目 — tt マンチャと内部規則(@parse など)で、小さな DSL(例: 状態遷移表 Idle => Running on Start;)を解析するマクロを書く。pat・literal フラグメントも使う

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

見つけた欠陥: 0 / 6 (+加点・おとり判定は別枠。進級には影響しません)

自分の回答(Q1〜Q4 の要点)

気づき・メモ