Day 063 — 2026-10-03 | マクロ① macro_rules! ⑧ 「マクロにするか、関数・型にするか」:hashmap!・square!・ensure!・log!・newtype!・check_eq! を実測で仕分ける

2026-10-03 🟣 上級者 / Phase 3 設計・アーキテクチャ マクロ①(宣言的マクロ macro_rules!)
フェーズ: 上級者 (Phase 3) | 問題タイプ: 設計・アーキテクチャ | テーマ: マクロ①(宣言的マクロ macro_rules!)

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

Day 057〜062 で hashmap!・fsm!・count! と、マクロを書く練習を続けてきました。ここで一度立ち止まります。書けることと、書くべきことは別です。 マクロは、読む人にもツールにも高くつきます(エラーが展開後のコードを指す、引数が文字列の並び「トークン」で見えて型が見えない、IDE の補完が効きにくい)。それでも要る場面があります。今日は「関数や型で足りるのに、マクロにしてしまった」ものと、「マクロでないと書けない」ものを、実際に動かして仕分けることが問題です。

マクロでないと書けない理由は、突き詰めると次の4つに絞れます(筆者の整理。一次資料の言い回しではありません)。

理由関数で書けない理由例
1. 呼び出し元の制御フローを変える関数の return は関数自身から戻るだけensure!(ただし ? で代替できる場合がある)
2. 引数を評価しない・遅らせる関数の引数は呼ぶ前に必ず評価されるlog!(無効のとき引数を評価しない)
3. ソースの文字列・位置が要る関数は値しか受け取れないassert_eq! の stringify!、line!()
4. 型・impl などの item を作る関数は値を返すだけで item は作れないnewtype!、bitflags!

標準ライブラリはこの線引きに沿っています。vec![..] はマクロですが、HashMap::from([(k, v), ..]) は関数(Rust 1.56 以降の From<[(K, V); N]>)です。つまり Day 057 で書いた hashmap! は、今の標準ライブラリでは要らないマクロになっています(HashMap::from の存在は、今日の rustc 1.96.0 でコンパイルして確認)。

実務では、マクロの「追加」はレビューで必ず聞かれます。「これ関数じゃだめ?」に、数値と理由で答えられることが目的です。

📝 問題

環境は rustc 1.96.0、edition 2021。rustc を直接、または cargo test を使います。

Part 1: 仕分けを予想する(実行する前に答える)

次の6つのうち、マクロが必要なものに ○、関数・型で足りるものに × を付け、上の4つの理由のどれかを書いてください。

#候補予想(○/×と理由番号)
K1hashmap!{"a" => 1, "b" => 2}(Day 057 のもの)
K2square!(x) = $x * $x
K3ensure!(x > 0, Err(..))(条件が偽なら呼び出し元から return Err)
K4log!("{}", expensive())(ログが無効のとき expensive() を呼ばない)
K5newtype!(UserId, u64)(struct と From を作る)
K6check_eq!(x + 1, 5)(失敗時に x + 1 != 5 というソース文字列を出す)

Part 2: 実測する(予想と比べる)

次の5つを、cargo test で通るテストとして書いてください(模範解答は 6 本)。

  1. square! の $x * $x は、引数に副作用があると 2 回評価される。square(i64) 関数は 1 回。テストで示す(i を 1 ずつ増やすブロックを渡す)
  2. hashmap! の結果が HashMap::from([..]) と等しい(K1 は関数で足りることの確認)
  3. ensure を 関数+? に置き換えて、check(5)・check(-1)・check(500) が通る
  4. ログが無効のとき、expensive() が何回呼ばれるかを3方式で数える: ①普通の関数に &expensive() を渡す/②クロージャを渡す関数/③マクロ。予想は「1・0・0」
  5. newtype! で作った UserId と OrderId は、中身が同じ u64 でも比べられない(コメントアウトしたコンパイルエラーの行を残す)

Part 3: コストを測って、マクロの追加基準を決める

square! と square() を 20,000 か所呼ぶだけのファイルを作り、rustc のコンパイル時間を各 5 回測ってください(-O なし)。「マクロの方が速い(展開が軽い)」「関数の方が速い」のどちらも先入観で書かず、数値で書くこと。あわせて square!("a") と square("a") で出るエラーメッセージを見比べてください。

最後に、「マクロを追加してよい基準」の判断記録(ADR)を 15 行以内で書いてください。必ず含めること。

  • (a) 採る基準と、採らなかった基準(例:「コードが短くなるから」「速いから」)の却下理由。却下理由には今日の実測値を使う
  • (b) 前提が変わったら判断がどう変わるか(2つ以上)
  • (c) 今日の実測で測れなかったこと(1つ以上)

💡 ヒント

ヒント1(方向性)

「関数に渡すと、いつ・何回評価されるか」を考えます。関数の引数は、関数が呼ばれる前に1回だけ評価されます。マクロはトークンをそのまま埋め込むので、$x を2か所に書けば2回評価されます。

ヒント2(アプローチ)

K3 の ensure! は「関数の return は呼び出し元を戻せない」ので一見マクロが必要ですが、Result を返す関数+? 演算子で、呼び出し元の制御フローを変えられます。K4 は「遅延」が必要ですが、クロージャを渡す形でも遅らせられます。マクロが本当に唯一の手段かどうかを、1つずつ確認してください。

ヒント3(コード骨格)
use std::cell::Cell;
thread_local! { static EVALS: Cell<u32> = const { Cell::new(0) }; }
fn expensive() -> String { EVALS.with(|e| e.set(e.get() + 1)); "x".repeat(10) }

fn log_eager(msg: &str) { /* 有効なら println */ }
fn log_lazy<F: FnOnce() -> String>(f: F) { /* 有効なら f() を呼んで println */ }
macro_rules! log_m { ($($arg:tt)*) => { if ENABLED.with(|e| e.get()) { println!($($arg)*); } }; }

20,000 か所のファイルは、手で書かずに Python などで生成します(s += sq!((0i64)); を 20,000 行)。

✅ 模範解答

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

cargo new decide --lib の src/lib.rs にそのまま貼れば検証できます(依存クレートなし)。cargo test は警告(未使用)を数件出しますが、テストは通ります。

use std::cell::Cell;
use std::collections::HashMap;

thread_local! {
    static EVALS: Cell<u32> = const { Cell::new(0) };
    static ENABLED: Cell<bool> = const { Cell::new(false) };
}
fn evals() -> u32 { EVALS.with(|e| e.get()) }
fn reset() { EVALS.with(|e| e.set(0)); }
fn expensive() -> String { EVALS.with(|e| e.set(e.get() + 1)); "x".repeat(10) }

// ---- 関数で足りるもの ----
pub fn square(x: i64) -> i64 { x * x }

#[derive(Debug, PartialEq)]
pub struct Err1(pub &'static str);
pub fn ensure(cond: bool, err: Err1) -> Result<(), Err1> { if cond { Ok(()) } else { Err(err) } }
pub fn check(x: i32) -> Result<i32, Err1> {
    ensure(x > 0, Err1("neg"))?;
    ensure(x < 100, Err1("big"))?;
    Ok(x)
}

// ---- 遅延評価: 関数(クロージャ)で足りる版とマクロ版 ----
fn log_eager(msg: &str) -> bool { if ENABLED.with(|e| e.get()) { println!("{msg}"); true } else { false } }
fn log_lazy<F: FnOnce() -> String>(f: F) -> bool { if ENABLED.with(|e| e.get()) { println!("{}", f()); true } else { false } }
macro_rules! log_m {
    ($($arg:tt)*) => { if ENABLED.with(|e| e.get()) { println!($($arg)*); true } else { false } };
}

// ---- マクロが要るもの ----
// (1) item を定義する
macro_rules! newtype {
    ($name:ident, $inner:ty) => {
        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
        pub struct $name($inner);
        impl From<$inner> for $name { fn from(v: $inner) -> Self { $name(v) } }
    };
}
newtype!(UserId, u64);
newtype!(OrderId, u64);

// (2) ソース文字列が要る
macro_rules! check_eq {
    ($l:expr, $r:expr) => {
        if $l != $r { Err(format!("{} != {} (line {})", stringify!($l), stringify!($r), line!())) } else { Ok(()) }
    };
}

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

    #[test]
    fn square_macro_double_evaluates_but_fn_does_not() {
        macro_rules! square_m { ($x:expr) => { $x * $x }; }
        let mut i = 2i64;
        let a = square_m!({ i += 1; i });
        assert_eq!((i, a), (4, 12)); // 副作用が 2 回走る(3 * 4)
        let mut j = 2i64;
        let b = square(if true { j += 1; j } else { 0 });
        assert_eq!((j, b), (3, 9));
    }

    #[test]
    fn hashmap_macro_is_unnecessary() {
        macro_rules! hashmap { ($($k:expr => $v:expr),* $(,)?) => { HashMap::from([$(($k, $v)),*]) }; }
        assert_eq!(hashmap! { "a" => 1, "b" => 2 }, HashMap::from([("a", 1), ("b", 2)]));
    }

    #[test]
    fn ensure_function_with_question_mark() {
        assert_eq!(check(5), Ok(5));
        assert_eq!(check(-1), Err(Err1("neg")));
        assert_eq!(check(500), Err(Err1("big")));
    }

    #[test]
    fn eager_vs_lazy_logging() {
        ENABLED.with(|e| e.set(false));
        reset(); log_eager(&expensive()); assert_eq!(evals(), 1);
        reset(); log_lazy(expensive);     assert_eq!(evals(), 0);
        reset(); log_m!("{}", expensive()); assert_eq!(evals(), 0);
        ENABLED.with(|e| e.set(true));
        reset(); assert!(log_m!("{}", expensive())); assert_eq!(evals(), 1);
    }

    #[test]
    fn newtypes_are_distinct_types() {
        let u: UserId = 7u64.into();
        let o: OrderId = 7u64.into();
        assert_eq!(u, UserId(7));
        assert_eq!(o, OrderId(7));
        // assert_eq!(u, o); // ← コンパイルエラー: 型が違う(E0308)
    }

    #[test]
    fn check_eq_reports_source_text() {
        let x = 3;
        let msg = check_eq!(x + 1, 5).unwrap_err();
        assert!(msg.starts_with("x + 1 != 5 (line "), "{msg}");
    }
}

Part 1 の答え

#結果理由出所
K1 hashmap!×(関数で足りる)HashMap::from([(k, v), ..]) が標準にある。ただし => の見た目が欲しいだけなら嗜好の問題実測(テスト 2 が通る)
K2 square!×関数で書ける。マクロにすると引数が 2 回評価される(i=4, 結果 12。関数は j=3, 結果 9)実測
K3 ensure!△(原則 ×)関数+? で同じ制御フローを作れる(テスト 3)。ただし関数版はエラー値を先に作る(遅延しない)。エラーの構築が重い場合だけ差が出る(未測定)テスト 3 は実測、遅延の差は導出(未測定)
K4 log!○(ただし代替あり)理由 2。普通の関数は 1 回評価、クロージャ関数とマクロは 0 回。つまりクロージャを受ける関数でも足りる。マクロが勝つのは format! 風の可変長引数の書き味実測(1・0・0)
K5 newtype!○理由 4(struct と impl を作る)。関数・ジェネリクスでは作れない実測(テスト 5)
K6 check_eq!○理由 3(stringify!・line!)。関数は値しか受け取れない実測(テスト 6)

6 つのうち、マクロが唯一の手段なのは K5・K6 の 2 つだけです。K4 は「書き味」、K3 は「エラー構築の遅延」でマクロが残る余地があり、K1・K2 は不要でした。

Part 3 の答え(今回の実測)

20,000 か所の呼び出しを rustc --edition 2021 -o(-O なし)でコンパイルした時間(5 回、単位は秒、開発機は他のプロセスと同居):

方式5 回の測定値最小
sq!((N)) マクロ3.10 / 2.53 / 2.91 / 2.72 / 2.472.47
sqf((N)) 関数3.27 / 2.84 / 2.60 / 4.01 / 4.012.60

差はぶれの範囲内で、「マクロの方が速い」とも「遅い」とも言えません(最小値の差 0.13 秒、関数側の最大 4.01 秒は同居プロセスの影響と推定。要因は未調査)。したがって「コンパイルが速くなるからマクロにする」は基準にできません。

エラーメッセージの比較(実出力の要点、square!("a") と square("a")):

error[E0369]: cannot multiply `&str` by `&str` --> e1.rs:1:37 1 | macro_rules! sq { ($x:expr) => { $x * $x }; } | ^ ← マクロの定義側を指す 3 | fn main() { let a = sq!("a"); ... | -------- in this macro invocation = note: this error originates in the macro `sq` (in Nightly builds, run with -Z macro-backtrace for more info) error[E0308]: mismatched types --> e1.rs:3:43 3 | ... let b = sqf("a"); | --- ^^^ expected `i64`, found `&str` ← 呼び出し側の引数を指し「i64 が期待される」と型を言う

マクロ版は「&str を掛けられない」と展開後の $x * $x を指し、利用者は sq! のシグネチャ(何型を受けるか)を知る手段がありません。関数版は「期待は i64」と型で教えます。

ADR-063 マクロを追加してよい基準
決定: 次の「関数・型では書けない」場合だけ新しいマクロを追加する。
  ①呼び出し元の制御フローを変え、かつ `?` で書けない  ②引数の評価を遅らせ、かつクロージャ引数で書けない
  ③stringify!/line!/file! などソース情報が要る  ④struct・impl・enum などの item を生成する
採らなかった基準:
  「コードが短くなる」: hashmap!/square! は短いが、square! は引数を 2 回評価し(i=4, 結果 12 対 9)、
    hashmap! は HashMap::from で足りる(実測)。短さは副作用の見えにくさと引き換え。
  「コンパイルが速い」: 20,000 か所で最小 2.47 秒対 2.60 秒、5 回のぶれは 2.47〜4.01 秒(実測)。差が測れない。
  「マクロは上級者の書き方」: 理由にならない。エラーも型を言わないぶん利用者に不利(E0369 対 E0308、実測)。
前提が変わったら: ①`macro_metavar_expr` 等が stable になり関数で書ける範囲が増えるなら基準①〜④を見直す
  ②入力が 1 万か所を超え、-O ありでマクロ展開が支配的になる計測が出たなら速度を基準に加える
  ③チーム全員が proc-macro を読み書きできるなら、④は derive へ寄せる。
測れていないこと: release ビルド(-O)の時間、実行時の速度差(インライン化後は同一と推定のみ)、
  IDE の補完・ジャンプの実害、`ensure` 関数版で重いエラー値を先に作るコスト。

👣 Step-by-Step 解説

Step 1: 引数を 2 回評価する(K2)

macro_rules! square_m { ($x:expr) => { $x * $x }; } に { i += 1; i } を渡すと、i は 2 回増えて (i=4, 結果=3*4=12) になります。マクロは値を受け取るのではなく、式のトークンをそのまま埋め込むからです。関数は呼ぶ前に引数を 1 回だけ評価するので (j=3, 結果=9) です。$x を 2 か所に書くマクロでは、副作用のある式を渡す人に必ず罠になります。直すには let x = $x; x * x と一度束縛しますが、それは関数で足りるという合図です。

Step 2: 標準ライブラリが関数に寄ってきた(K1)

hashmap! を書いた当時(Day 057)は価値がありました。今の標準では HashMap::from([("a", 1), ("b", 2)]) で足ります。マクロが埋めていた隙間は、言語・標準が追いつくと消えるので、マクロは「負債」になりえます。残す理由は書き味だけです。

Step 3: ? で呼び出し元を戻す(K3)

return は関数の中でしか効きませんが、? 演算子は「Err なら呼び出し元に戻る」ので、ensure(cond, err)?; で同じ制御フローになります。制御フローの変更=マクロ必須、ではない点が盲点です。残る差は、Err1("neg") を成否にかかわらず先に作ってしまうことだけです(文字列リテラルなら無料、format! を含むなら有料。今日は未測定)。

Step 4: 評価の遅延は、クロージャでも書ける(K4)

3 方式の評価回数は log_eager=1、log_lazy=0、log_m!=0(実測)。マクロが唯一ではないことが数字で出ます。マクロが残る理由は、log_m!("{} {}", a, b) と format! と同じ書き味にできる点です(クロージャ版は log_lazy(|| format!(...)) と一段長い)。この差を許容できるかが判断です。

Step 5: item の生成は、マクロでしか書けない(K5)

newtype!(UserId, u64) は struct と impl From を作ります。関数もジェネリクスも item を作れないので、マクロの本来の領分です。UserId と OrderId を == で比べるとコンパイルエラー(E0308)になり、取り違えを型が止めます。

Step 6: ソース文字列は、マクロでしか取れない(K6)

stringify!($l) が x + 1 という書いたとおりの文字列を返し、line!() が行番号を返します。関数は値(4)しか見えないので、「4 != 5」までしか言えません。assert_eq! が標準でマクロなのはこの理由です。

Step 7: 速さではなく「何を知らせられるか」で決める

20,000 か所でも差は測れませんでした。差が出たのはエラーの出方です。関数は「i64 が期待される」と型を言い、マクロは展開後の定義を指します(実測)。マクロにすると、型という API の説明がコンパイラから消える。これがコストです。

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

Rust の型システムは、引数の型・評価の回数・所有権の移動を関数のシグネチャに書かせることで、コンパイラが利用者を守る設計です。マクロはその外側(トークンの世界)に出る手段で、守りを捨てる代わりに表現力を得る取引です。だから「型が書けるなら型で、値で足りるなら関数で、それでも書けないときだけマクロ」という順序になります。

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

案採らなかった理由
「短くなるならマクロ」square!/hashmap! が反例(二重評価、標準で代替)。短さは副作用の見えにくさと交換になる
「速くなるならマクロ」20,000 か所で 2.47 秒対 2.60 秒、5 回のぶれが 2.47〜4.01 秒で差が測れない。-O での差は未測定
「マクロを全面禁止」newtype!/check_eq!(item 生成・ソース文字列)は関数で書けない。禁止は過剰
proc-macro / derive に寄せるitem 生成(K5)は derive の方が型付きで強力だが、別クレートとビルド時間が要る。測っていない(マクロ② で扱う)

前提が変わったら: 標準が ${count} のような機能を stable にすれば、マクロで書いていた隙間はさらに減ります。逆に、チーム内で DSL を多用する前提なら、基準④の範囲を広げる判断もありえます。

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

今日はデバッグ問題ではありませんが、Part 3 の比較に使う実出力(rustc 1.96.0)を残します。上の ADR の節にある E0369(マクロ版)と E0308(関数版)が該当します。読み方の要点は次のとおりです。

  • マクロ由来のエラーは「this error originates in the macro」の注記が付き、位置がマクロ定義側を指す。呼び出し側を見るには -Z macro-backtrace(nightly)が要る
  • 関数由来のエラーは、呼び出し側の引数に「期待される型」が付く。直し方も読み取りやすい
  • UserId と OrderId を == で比較すると E0308(mismatched types)。型が別なので止まる(テストではコメントアウトした行)

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

観点Rust macro_rules!C のプリプロセッサGoTypeScript
引数の評価式をそのまま埋め込む($x を 2 回書けば 2 回評価)同じ(SQUARE(i++) は典型的な罠)関数のみ。マクロ無し関数のみ。マクロ無し
型の守り展開後に型検査。シグネチャで説明できない型検査より前に文字列置換関数の型で守る関数の型で守る
遅延評価マクロ、またはクロージャマクロクロージャクロージャ(アロー関数)
item 生成macro_rules!・proc-macro#define で定義を生成go generate(コード生成を別工程で)型・デコレータ・コード生成
ソース文字列stringify!・line!#x・__LINE__runtime.Caller(実行時)実行時のみ

Rust のマクロは衛生的(変数名の衝突を避ける)ぶん、C のマクロより安全ですが、「引数を 2 回評価する」罠は同じです。

🏭 実務での使いどころ

  • コードレビューで「このマクロは関数・型でだめか」を聞く基準として、上の ADR(4 つの理由)をそのまま使えます。
  • 標準ライブラリで vec!・format!・assert_eq!・matches! がマクロである理由は、上の 4 つの理由で説明できます(各マクロの実装は今日は取得し直して照合していません。一次資料は末尾)。
  • 古いコードにある自作の hashmap!・max! は、現在の標準で置き換えられるかを cargo clippy や手動で見直す価値があります(置き換え作業は今日は未実施)。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「マクロの方がコンパイルが速い(関数呼び出しが無いから)」C のインライン展開の感覚20,000 か所で最小 2.47 秒対 2.60 秒、差は測れない(実測)。rustc では関数も同じ段階で最適化される
「$x を 2 回書いても結果は同じ」式を値だと思っている副作用があれば 2 回走る(i=4、結果 12。実測)
「制御フローを変えるからマクロが必須」return は関数内でしか効かないと思う? で呼び出し元に戻せる(テスト 3)
「遅延評価はマクロだけ」&expensive() を渡すと先に呼ばれるクロージャを渡せば 0 回(実測)。マクロは書き味の差
「マクロなら型を気にしなくてよい」引数に型を書かない型エラーが展開後の定義を指し、利用者に型が伝わらない(E0369 対 E0308)

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

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

扱わなかったこと理由・次の予定
release ビルド(-O)の時間・実行速度今日はフロントエンド(展開)の時間だけ。インライン化後に同一と推定したが未測定
ensure 関数版でエラー値を先に作るコストformat! を含むエラーでの差は導出のみ。次に測る候補
IDE(rust-analyzer)の補完・ジャンプの差手元で計測していない。体感でなく数値で比べる方法が未定
proc-macro(derive)との比較別クレートとビルド時間が要る。マクロ② で扱う
実在クレート(bitflags・thiserror 等)が基準 ①〜④ のどれに当たるかの調査一次資料を取得し直していないため断言しない
ガード付き遷移($from:pat if $g:expr)前回からの持ち越し。今日の主題から外れる
既存の hashmap!・fsm! を関数・型に置き換える作業今日は仕分けと基準づくりまで。置き換えは未実施

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

  1. 同じ指示を AI に出せば数時間で出るか — 「マクロが必要な理由 4 つ」は出る。ただし「二重評価で i=4・結果 12」「評価回数 1・0・0」「20,000 か所で 2.47 秒対 2.60 秒(差が測れない)」「E0369 と E0308 の指す位置の違い」は、実際に動かさないと出ない。
  2. AI に指示するだけでは出ない部分 — 「速さを基準にできない」という否定的な実測、ADR の「採らなかった基準」と「測れていないこと」。
  3. 世界の実物と比べて何割か — 標準ライブラリのマクロ全体(vec!・format!・matches!・thread_local! など 10 個以上)のうち、今日は仕分け対象の 6 個を扱った。実在クレートの照合は未実施(上の表に明記)。

➡️ 次のステップ

  • 発展: 自分の過去のマクロ(hashmap!・fsm!・count!)に ADR の 4 基準を当て、残すものと関数・型に直すものを仕分ける。直した後に cargo test の結果が変わらないか確認する
  • 次回予告: マクロ① の最終日に向けて、ガード付き遷移($from:pat if $g:expr)、または実在クレートのマクロの読解(一次資料を取得して照合する)

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

自分の回答

気づき・メモ