Day 061 — マクロ① macro_rules! ⑥ テストが通る str_enum! を「使う側」から壊す:末尾カンマ・doc コメント・重複文字列・定数パス

2026-09-30(バックフィル) 🟣 上級者 / Phase 3 デバッグ・コードレビュー マクロ①(宣言的マクロ macro_rules!)
※ 9/30 のバックフィル分です。番号は出題済みの Day 060 の続きで 061 を使っています(日付と番号の順序が逆になるのは仕様)。Day 058(共通マクロ PR のレビュー)・Day 059(fsm!)とは独立した1本として読めます。state の「次回予告」(設計問題)はこの日の続きではなく、そのまま残しています。

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

Day 058 は「マクロの中身の欠陥」をレビューで探しました。今日は視点を変えて、マクロを使う側が書くコードから壊します。macro_rules! の入力は、関数の引数と違って型ではなく字句の並びで受け付けられるので、作者のテストが通っていても、利用者が普通に書く次の入力でコンパイルが通らないことがあります。

  • rustfmt が付ける末尾のカンマ
  • 型や variant に付ける /// doc コメント(#[doc = "..."] に展開されるので、パターンに書いていなければ弾かれる)
  • 同じ値を2か所に書いてしまう入力ミス(型でもパターンでも弾けず、警告だけで通る)

もう一つ、Rust 固有の線引きがあります。$s:expr で受けた式は、"debug" のような文字列リテラルならパターン位置に置けるのに、定数のパス A だと置けません。マクロの入力は「式」なのか「パターンに使えるリテラル」なのか。この線引きが API の約束になります。

扱う題材は、文字列と enum を対応づける定番のマクロです。世界では strum の EnumString・AsRefStr という derive(手続き型マクロ)がこの役を担っています(GitHub Peternator7/strum、2455 star、MIT、最終 push 2026-03-07。GitHub API で 2026-10-03 に取得。crates.io の累計ダウンロード数は 662,264,239)。それを macro_rules! で自作するとき、何が足りなくなるのかを測るのが今日のねらいです。

実務では、設定ファイルのキー、ログレベル、HTTP メソッド、DB のステータス列など、「文字列で保存し enum で扱う」型が1プロジェクトに何十個も出ます。毎回 match を手で書くと、variant を足したのに FromStr の腕を足し忘れる事故が起きるので、マクロか derive にします。

📝 問題

Part 1: コードレビュー(実行する前に答える)

次の PR を受け取りました。テストは3つとも通っています(cargo test で確認済み。edition 2024、rustc 1.96.0)。

macro_rules! str_enum {
    ($vis:vis enum $name:ident { $($var:ident => $s:expr),* }) => {
        #[derive(Debug, Clone, Copy, PartialEq)]
        $vis enum $name { $($var),* }
        impl $name {
            pub const ALL: &'static [$name] = &[$($name::$var),*];
            pub fn as_str(&self) -> &'static str {
                match self { $($name::$var => $s),* }
            }
        }
        impl std::str::FromStr for $name {
            type Err = String;
            fn from_str(x: &str) -> Result<Self, String> {
                match x { $($s => Ok($name::$var),)* _ => Err(format!("unknown: {x}")) }
            }
        }
    };
}

str_enum! { pub enum Level { Debug => "debug", Info => "info", Warn => "warn" } }

fn main() {
    println!("{:?}", "warn".parse::<Level>());
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn roundtrip() {
        for &v in Level::ALL {
            assert_eq!(v.as_str().parse::<Level>(), Ok(v));
        }
    }
    #[test]
    fn count() { assert_eq!(Level::ALL.len(), 3); }
    #[test]
    fn unknown() { assert!("trace".parse::<Level>().is_err()); }
}

利用者が次の8通りの呼び出しを書きます。実行する前に、それぞれ (a) コンパイルが通るか、(b) 通るなら警告の有無と挙動、(c) 通らないなら何のエラーかを答えてください。

// U1: 末尾カンマ(rustfmt が付ける形)
str_enum! { pub enum L { Debug => "debug", Info => "info", } }

// U2: enum に doc コメント
str_enum! { /// log level
    pub enum L { Debug => "debug", Info => "info" } }

// U3: variant に doc コメント
str_enum! { pub enum L { /// verbose
    Debug => "debug", Info => "info" } }

// U4: 文字列を重複させてしまった
str_enum! { pub enum L { Debug => "x", Info => "x" } }

// U5: 文字列を定数で渡す
const A: &str = "a";
str_enum! { pub enum L { X => A, Y => "b" } }

// U6: 文字列を .trim_end() つきの式で渡す
str_enum! { pub enum L { X => "a".trim_end(), Y => "b" } }

// U7: variant が 0 個
str_enum! { pub enum L { } }

// U8: 別のマクロが $s:expr で受けて転送する
macro_rules! levels {
    ($($v:ident => $s:expr),*) => { str_enum! { pub enum L { $($v => $s),* } } };
}
levels! { Debug => "debug" }

先輩は「$s:expr はパターンに置けないので、元の PR は定数でなくても落ちるし、U8 の転送も壊れるはず」と言っています。 本当にそうか、なぜかを答えてください(レビューでは、自分や他人の思い込みを実機で確かめた方が負けにくいです)。

Part 2: 直す(実装)

U1〜U5・U7 が通るようにマクロを直してください。要件は次のとおりです。

  • 末尾カンマを許す。enum と variant に付けた属性(doc コメント・#[derive(...)])を受け取って、出力する enum にそのまま付ける
  • 文字列が重複したらコンパイルエラーにする(警告では足りない。const _: () = ...; と const fn を使う)
  • as_str は const fn にする(const NAME: &str = Level::Info.as_str(); が書けること)
  • 0 variant の enum でもコンパイルが通る
  • 追加したテスト(下の模範解答にある 5 つ)が cargo test で通ること。重複の拒否は手で確認でよい

Part 3: 設計の設問

  • D1: 入力を $s:expr ではなく $s:literal にして、定数パス A を受け付けないことにした。何を得て何を失ったか。expr のまま定数も受け付けたければ、出力のどこをどう変える必要があるか。
  • D2: 出力する enum に #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] を固定で付けた。利用者が #[derive(Debug)] を自分でも書いたらどうなるか。固定をやめて利用者に全部書かせる案と比べ、採るならどちらか。
  • D3: 自作の str_enum! と strum の derive(手続き型マクロ)で、どちらを採るか。判断を変える条件を2つ挙げよ。

💡 ヒント

ヒント1(方向性)
  • U1〜U3 は「パターンに書いていないものは弾かれる」。エラーメッセージの while trying to match の行が、どの部品で止まったかを教えてくれる
  • U5・U6・U8 は、先輩の予想を鵜呑みにせず、$s が式の位置とパターンの位置のどちらに置かれているかを分けて考える
ヒント2(アプローチ)
  • 属性は $(#[$m:meta])*、末尾カンマは $(,)?
  • 重複検出は、実行時ではなく const _: () = f(&[...]); の中で panic! すればコンパイルエラーになる。&str の == は const fn では使えないので、as_bytes() を while で比べる
  • 0 variant は match self {} が &L では通らない。参照を外した match *self {} を使う(Copy が前提)
ヒント3(コード骨格)
macro_rules! str_enum {
    ( $(#[$m:meta])* $vis:vis enum $name:ident {
        $( $(#[$vm:meta])* $var:ident => $s:literal ),* $(,)?
    } ) => {
        /* $(#[$m])* #[derive(...)] $vis enum ... */
        /* impl: ALL, const fn as_str (match *self) */
        /* impl FromStr: 絶対パス ::std::... で書く */
        /* const _: () = assert_distinct(&[ $( $s ),* ]); */
    };
}

✅ 模範解答

macro_rules! str_enum {
    (
        $(#[$m:meta])*
        $vis:vis enum $name:ident {
            $( $(#[$vm:meta])* $var:ident => $s:literal ),* $(,)?
        }
    ) => {
        $(#[$m])*
        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
        $vis enum $name { $( $(#[$vm])* $var ),* }

        impl $name {
            pub const ALL: &'static [$name] = &[ $( $name::$var ),* ];
            pub const fn as_str(&self) -> &'static str {
                match *self { $( $name::$var => $s ),* }
            }
        }
        impl ::std::str::FromStr for $name {
            type Err = String;
            fn from_str(x: &str) -> ::std::result::Result<Self, String> {
                match x {
                    $( $s => ::std::result::Result::Ok($name::$var), )*
                    _ => ::std::result::Result::Err(format!("unknown {}: {x}", stringify!($name))),
                }
            }
        }
        const _: () = assert_distinct(&[ $( $s ),* ]);
    };
}

const fn str_eq(a: &str, b: &str) -> bool {
    let (a, b) = (a.as_bytes(), b.as_bytes());
    if a.len() != b.len() { return false; }
    let mut i = 0;
    while i < a.len() { if a[i] != b[i] { return false; } i += 1; }
    true
}
const fn assert_distinct(xs: &[&str]) {
    let mut i = 0;
    while i < xs.len() {
        let mut j = i + 1;
        while j < xs.len() {
            if str_eq(xs[i], xs[j]) { panic!("str_enum!: duplicate string"); }
            j += 1;
        }
        i += 1;
    }
}

str_enum! {
    /// log level
    #[derive(PartialOrd, Ord)]
    pub enum Level {
        /// verbose
        Debug => "debug",
        Info => "info",
    }
}
str_enum! { enum Empty {} }

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashSet;
    #[test] fn roundtrip() {
        for &v in Level::ALL { assert_eq!(v.as_str().parse::<Level>(), Ok(v)); }
        assert_eq!(Level::ALL.len(), 2);
        assert!(Level::Debug < Level::Info);
    }
    #[test] fn unknown() { assert_eq!("x".parse::<Level>().unwrap_err(), "unknown Level: x"); }
    #[test] fn hashable() { let s: HashSet<Level> = Level::ALL.iter().copied().collect(); assert_eq!(s.len(), 2); }
    #[test] fn empty() { assert!(Empty::ALL.is_empty()); }
    const NAME: &str = Level::Info.as_str();   // const fn
    #[test] fn constfn() { assert_eq!(NAME, "info"); }
}
fn main() { println!("{:?}", "info".parse::<Level>()); }

実行結果(出題者の環境: rustc 1.96.0 / cargo 1.96.0、edition 2024):

running 5 tests
test tests::empty ... ok
test tests::constfn ... ok
test tests::roundtrip ... ok
test tests::hashable ... ok
test tests::unknown ... ok
test result: ok. 5 passed; 0 failed

Part 1 の答え(元の PR に対して、実際にコンパイルして確認した結果)

入力結果何が起きたか
U1 末尾カンマエラー no rules expected '}'$(...),* は区切りの後に必ず次の要素を要求する。$(,)? が無い
U2 enum の docエラー no rules expected '#'(「outer doc comments expand to #[doc = "..."]」と注記が出る)$vis:vis の前に属性を受ける部品が無い
U3 variant の docエラー no rules expected '#'variant の前に属性を受ける部品が無い
U4 文字列の重複通る。警告 unreachable pattern だけFromStr の2つ目の腕に到達できず、"x".parse() は常に先の variant を返す。Info.as_str().parse() が Debug になり往復が壊れる。元のテストは重複した入力を使っていないので気づけない
U5 定数パスエラー arbitrary expressions aren't allowed in patterns(the expr fragment specifier forces the metavariable's content to be an expression と注記)as_str の腕(式の位置)は通るが、FromStr の腕(パターンの位置)で落ちる
U6 .trim_end() つき式エラー(U5 と同じ)パターンに置けない式は全部同じ理由で落ちる
U7 0 variantエラー E0004: non-exhaustive patterns: type '&L' is non-emptymatch self {} は参照型 &L を空の match で受けられない
U8 $s:expr 転送通る下の「先輩の予想が外れた箇所」を参照

先輩の予想が外れた箇所(出題者も予想を外した)

出題者も当初「$s:expr で受けた式はパターンに置けないので、元の PR は定数パスでなくても落ちる」と考えていました。実測は逆でした。

  • 元の PR の $s:expr は、"debug" のような文字列リテラルなら、パターンの位置に置いてもコンパイルが通る(Part 1 の PR がそのまま通っている)。落ちるのは、定数のパス(U5)やメソッド呼び出し(U6)のとき
  • U8 の levels! は、$s:expr で受けた "debug" を str_enum! に転送しても通る。修正後の $s:literal 版でも通る(実測)。「expr で受けたら literal に渡せない」は、リテラルに限っては成り立たなかった
  • つまり、元の PR の欠陥は「リテラルしか通らないのに、型は expr と名乗っている」という約束と実装のずれ。リテラルだけを試したテストでは検出できない

修正後に定数パス A を渡すと error: no rules expected 'A' になり、エラーの位置が「パターンの中」から「マクロの入口」に移ります(実測)。-1 を渡したときは E0308 mismatched types(文字列ではないので当然)で、literal でも expr でも防げません。型の整合は as_str の戻り値の型が見張っています。

🔍 Step-by-Step 解説

Step 1: テストが通ることと、入力を受け付けることは別

元のテスト3つは「variant 3 個・末尾カンマなし・属性なし・重複なし・リテラル」という1通りの入力しか使っていません。マクロのテストは、入力の形のバリエーション(U1〜U8 のような)を書かないと、利用者が最初に踏む穴を見逃します。

Step 2: 属性は $(#[$m:meta])* で通す

/// は #[doc = "..."] になるので、meta で受ければ doc コメントも #[derive(...)] も同じ部品で通ります。出力では $(#[$m])* と書けば元の位置にそのまま戻ります。

Step 3: 重複は const の panic でコンパイルエラーにする

unreachable_patterns は警告で、CI で -D warnings にしていなければ通ってしまいます。const _: () = assert_distinct(&[...]); にすれば、重複の入力は E0080 evaluation panicked: str_enum!: duplicate string になり、コンパイルが止まります(実測)。&str の == は const fn で使えない(理解している範囲。今回は as_bytes() で比べる方法だけを実行して確認した)ので、バイト列を while で比べています。

Step 4: 0 variant と const fn

match *self {} にすると、0 variant でも通ります(Copy が前提)。as_str を const fn にしたので、const NAME: &str = Level::Info.as_str(); が書けます(テスト constfn)。

Step 5: 絶対パスで書く

FromStr・Result・Ok・Err を ::std::... で書いています。利用者が自分のクレートで Result を別の型にしていても壊れません(Day 058 の「絶対パス」の欠陥と同じ種類。今回は別名 Result での実測はしていない)。

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

macro_rules! の本質は字句の並びのパターンマッチで、型検査は展開した後に起きます。だから、利用者に向けた約束(どんな入力を受け付けるか)は、パターンの書き方そのものです。$s:expr と書けば「式なら何でも」と約束したことになり、$s:literal と書けば「リテラルだけ」と約束します。実装がパターン位置に置く以上、本当の約束は後者なので、約束と実装を一致させるのが正しい直し方です。

Rust は「ゼロコスト」と「コンパイル時に潰す」を好むので、重複の検出も実行時のテストではなく const の評価で行います。ここで unreachable_patterns の警告に頼らず const _ で止めているのは、警告は無視できるが、エラーは無視できないからです。

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

案採らなかった理由
重複を実行時テスト(ALL の往復)だけで守る利用者がテストを書かなければ守れない。マクロ側で止める方が約束が強い
FromStr を if x == $s の連鎖にして expr のまま定数も受ける定数パスを受けられるが、unreachable pattern の警告も出なくなり、重複検出を const に頼ることになる。今回は入力を絞る案を採った。前提が変わる条件: 利用者が文字列を定数で共有したいと要望したとき
derive を固定にせず、利用者に全部書かせる重複の事故(D2)は消えるが、毎回 5 個の derive を書かせることになる。内部用の型なので固定を採った。公開 API なら逆の判断もありうる

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

エラー1: U1 の末尾カンマ

error: no rules expected `}` --> src/main.rs:19:60 | 1 | macro_rules! str_enum { | --------------------- when calling this macro ... note: while trying to match meta-variable `$var:ident`

読み方: note: while trying to match が、パターンのどの部品で止まったかを指している。ここでは「, の後なのでもう1つ $var:ident が来るはず」。直し方は $(,)? を足す。

エラー2: U2 / U3 の doc コメント

error: no rules expected `#` | outer doc comments expand to `#[doc = "..."]`, which is what this macro attempted to match

読み方: コンパイラが /// を #[doc] に展開したことまで教えてくれる。$(#[$m:meta])* を足す。

エラー3: U5 の定数パス

error: arbitrary expressions aren't allowed in patterns = note: the `expr` fragment specifier forces the metavariable's content to be an expression

読み方: エラーの位置は呼び出し側の A を指している。マクロの中身ではなく、展開されたパターンが問題。literal に変えるか、FromStr を if x == $s の連鎖に変える。

エラー4: U7 の 0 variant

error[E0004]: non-exhaustive patterns: type `&L` is non-empty

読み方: 空の enum でも、&L(参照)は「非空の型」とみなされる。match *self {} にする。

エラー5: 重複を const で止めた場合

error[E0080]: evaluation panicked: str_enum!: duplicate string

エラー6: 利用者が #[derive(Debug)] を書き足した場合(D2)

error[E0119]: conflicting implementations of trait `Debug` for type `Level`

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

観点Rust macro_rules!Rust strum(derive)GoTypeScript
文字列 ⇔ 値の対応マクロで生成#[derive(EnumString)]stringer(String() のみ。逆変換は手書き)文字列リテラル型のユニオンで足りる(実行時には消える)
属性・doc の通し方$(#[$m:meta])* を自分で書くderive が自動で透過該当なし該当なし
重複の検出自分で const を書く未確認map リテラルの重複キーはコンパイルエラー(理解。未実行)ユニオンは重複を許す
入力の約束パターンの書き方属性の文法(派生側が検証)なし型

🏭 実務での使いどころ

  • 設定のキー、ログレベル、HTTP メソッド、状態列("pending" / "done")のように、閉じた集合を文字列で保存する型。variant を足すとき、FromStr の腕を足し忘れる事故を型で防ぐ
  • 外部クレートを増やしたくない小さなツール・組み込みの現場では、50 行のマクロで足りることがある。一方、公開 API や serde と組むなら strum・serde の derive の方が約束が明確
  • マクロを公開するときは、末尾カンマ・属性・0 件・重複の4つを最初のテストに入れる

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
テストが通っていれば入力の形は網羅されているテストを書いた人の頭にある入力は1通りだからマクロのテストは入力の形を変えて書く(末尾カンマ・属性・0 件・転送)
警告 unreachable pattern は無害通るので放置する重複文字列で往復が壊れている。入力の不整合は const で止める
$s:expr ならパターンに使えないはずexpr と pat は別物と習ったからリテラルは通る(実測)。定数パスと式は通らない。約束と実装がずれている
$s:literal にすれば何でも安全受け付ける範囲が狭まるから-1 は通って型エラーで落ちる。型の整合は別の仕組みの仕事
FromStr 内の Result はそのままで書いてよい自分のクレートでは問題が出ないから利用者のスコープで解決される。絶対パス ::std:: で書く

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

  • The Rust Reference「Macros By Example」— Metavariables と Fragment Specifiers(expr・literal・meta)、follow-set — doc.rust-lang.org/reference/macros-by-example.html
  • The Rust Reference「Patterns」— パターンに置けるもの(リテラル・パス・範囲)— doc.rust-lang.org/reference/patterns.html
  • strum(Peternator7/strum)— EnumString・AsRefStr の仕様 — github.com/Peternator7/strum(star 数・ライセンス・push 日は GitHub API の実測。derive の細かな挙動は未確認)

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

扱わなかったこと理由・次の予定
strum を実際に依存に入れて、同じ入力(U1〜U8)を通す比較今日は自作マクロの実測だけ。D3 の答えは推論で、strum 側の挙動は未確認。次に使うとき実測する
大文字小文字を無視する FromStr・別名(alias)・Display の生成要件が増えるので除外。strum の serialize_all 等が担う領域
$s:expr で定数パスを受け付ける実装(if x == $s の連鎖)D1 の設問で言葉にしただけで、コードは書いていない
コンパイル時間の比較(macro_rules! と proc-macro 依存)計測していない。D3 の判断材料として残る宿題
trybuild による「コンパイルが失敗すること」のテスト未導入。重複拒否は手で確認しただけ
edition 2021 以前での挙動edition 2024 のみ実行

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

  1. 同じ指示を AI に出せば数時間で出るか — str_enum! 自体は出る。ただし「$s:expr のリテラルはパターンに通る」は出題者が予想を外して実機で初めて分かった(記録として本文に残した)。
  2. AI に指示するだけでは出ない部分 — U1〜U8 の実測表(エラー文・警告の有無)、予想が外れた記録、strum の star 数などの出所つきの数値、採らなかった案の表。
  3. 世界の実物と比べた扱った割合 — strum は derive が複数ある(EnumString・Display・EnumIter 等。正確な数は数えていない)うち、文字列との往復1点だけ。1 割未満と見積もる(導出、数えていない)。

➡️ 次のステップ

  • 発展: strum を入れて U1〜U8 を同じ入力で通し、挙動の違いを表にする(今日は未実施)
  • 次回予告: (state の次回予告のまま)マクロ① の5問目(設計・アーキテクチャ)— 「マクロにするか、関数・型にするか」を既出のマクロで判断する

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

自分の回答

気づき・メモ