📚 背景知識(読んでから問題へ)
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行(再現コード)
- 何が起きるか(コンパイルエラー/誤った値/警告のどれか。エラーならエラーの種類)
- 既存の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 を持ち込んで隠していた |
| 3 | follow-set | check_range! の $lo:tt .. $hi:tt | check_range!(-5, -1..10) / check_range!(99, 0..MAX - 1) / check_range!(5, 0..n + 1) | コンパイルエラー no rules expected '1'(- と + でも同様) | テストの境界値が 0 と 10 の1トークンだけ |
| 4 | 0要素 | 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..10 | 0..MAX - 1 | 0..=100 | 評価回数 | 判定 |
|---|---|---|---|---|---|---|
A: 元の $lo:tt .. $hi:tt | check_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) + match | check_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 の初期化に使える | 採用 |
- 採った理由: 元のマクロが生成していたのは「
static1個と、それを操作する関数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)*) |
|---|---|---|
| 126 | 126 | — |
| 127 | 127 | — |
| 128 | 128(通る) | — |
| 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)*、引数 1 | 0.22 / 0.20 / 0.20 | 10.71 / 10.79 / 10.59 |
修正後、引数を 1u32(型付きリテラル) | — | 0.53 / 0.35 / 0.31 |
比較: 文を並べる let mut s = $first; $( s += $rest; )* s | 0.75 / 0.22 / 0.20 | 4.79 / 4.67 / 4.69 |
比較: 配列で [$first $(, $rest)*].into_iter().sum() | 0.18 / 0.11 / 0.09 | 0.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! の $v | 4 | 3(分岐による) | 欠陥1 |
check_range! の $lo $hi | 各2 | 各1〜2 | 1トークンなら副作用は無いが、tt の制約が別の欠陥(欠陥3)を生む |
max_of! の $x | 1(配列の中) | 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 .. は書けなかった」は本当です(実測)。
これはマクロの定義の時点で出るエラーです。expr は「どこまでが式か」をパーサが決めるので、.. のように式の一部にもなれるトークンが後ろに来ると、将来の文法の拡張で意味が変わるおそれがあります。Rust はこれを防ぐため、expr の直後に置けるトークンを => , ; の3つに限っています(Rust Reference「Follow-set Ambiguity Restrictions」)。
作者は tt に替えて定義を通しましたが、tt はトークン木1個です。-1 は - と 1 の2トークン、MAX - 1 は3トークンなので一致しません。
| 書いた範囲 | トークン | $lo:tt .. $hi:tt に一致するか |
|---|---|---|
0..10 | 0 .. 10 | ○ |
0..MAX | 0 .. MAX | ○ |
-1..10 | - 1 .. 10 | ✗(1 で失敗) |
0..MAX - 1 | 0 .. 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つのエラーになります(実測)。
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 に従ってはいけない例
読み方: 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 — どのトークンで照合に失敗したか
読み方: note: while trying to match が規則のどこまで一致したかを示します。$lo:tt は - に一致し、次に .. を期待したところに 1 が来た、と読めます。0..MAX - 1 では note: while trying to match meta-variable $hi:tt が出て、$hi が MAX を取った後の - で失敗したと分かります(実測)。
欠陥4: 0要素(型注釈あり)
読み方: 「実行時に panic する」と言いながらコンパイルエラーです。コンパイラが定数の添字と長さ0の配列を見て、必ず panic すると判定したためです(unconditional_panic リントは既定で deny)。長さが実行時に決まる Vec なら、この検査は働かず実行時の panic になります。
欠陥5: 再帰上限 — help の数字を上げるのは最後の手段
読み方: help は利用者のクレート s_129 に属性を足せと言います。上限は展開が起きるクレートの属性で決まるので、マクロの作者が直すことはできません。Day 057 の D1 と同じく、マクロの作者は再帰しない形に書き換えるべきです。
欠陥6: E0428
読み方: redefined here と previous definition が同じ行(49行目)を指しています。同じマクロの定義行から2回生成された、というマクロ特有の見え方です。2つの呼び出しのうち、2回目の呼び出し(67行目)だけが示されます。
加点: 非 Copy 型で E0508
help は &xs[0] か xs[0].clone() を提案しますが、前者は戻り値の型が変わり、後者は不要な複製です。修正版は配列を作らず let mut m = $first; と所有権を順に移すので、String でも複製なしで動きます。
🌐 他言語との比較
| 観点 | C プリプロセッサ | C++ | Go | TypeScript | Rust 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分類をそのまま使える)
- 各メタ変数は展開結果に何回残るか(2回以上なら
matchで束縛) #[macro_export]するマクロの中のパスは::std::...・$crate::...かttで受けている箇所は、2トークン以上の入力(負の数・演算)で壊れないか- 繰り返しが0回・1回のとき、展開結果がエラー・警告を出さないか
- 自己再帰していないか。しているなら要素数の上限をドキュメントに書いたか
- マクロ内で固定の名前の項目(
fn・struct・static)を作っていないか - そもそも関数か型で書けないか
- 各メタ変数は展開結果に何回残るか(2回以上なら
- テストの置き場所:
#[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。記述は各資料の要旨で、原文の引用ではありません)
- The Rust Reference — Macros By Example(Follow-set Ambiguity Restrictions:
exprの後は=>,;のみ/Hygiene:macro_rules!は mixed-site で、ローカル変数・ラベル・$crateが定義側で解決される): https://doc.rust-lang.org/reference/macros-by-example.html - The Rust Reference — Limits:
recursion_limit(既定値 128): https://doc.rust-lang.org/reference/attributes/limits.html - std のソース
library/core/src/macros/mod.rs(assert_eq!がmatch (&$left, &$right)で1回だけ評価する実装): https://github.com/rust-lang/rust/blob/master/library/core/src/macros/mod.rs - std:
RangeBounds::contains: https://doc.rust-lang.org/std/ops/trait.RangeBounds.html - rustc のリント
unconditional_panic(既定 deny): https://doc.rust-lang.org/rustc/lints/listing/deny-by-default.html - Linux カーネル
include/linux/minmax.h(C のマクロで2回評価を避ける実装): https://github.com/torvalds/linux/blob/master/include/linux/minmax.h trybuild(コンパイル失敗を期待するテスト): https://github.com/dtolnay/trybuildpaste(識別子の連結。Q3 の却下案 B): https://crates.io/crates/paste
🚧 今日扱わなかった範囲(次回以降)
| 扱わなかったもの | 理由・予定 |
|---|---|
別クレートからの #[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フラグメントも使う