Day 059 — マクロ① macro_rules! ④ tt マンチャと内部規則で、状態遷移表の DSL を読むマクロを書く

2026-10-02 🟣 上級者 / Phase 3 実装 マクロ①(宣言的マクロ macro_rules!)

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

Day 056 で展開結果を予想し、Day 057 で hashmap! などを書き、Day 058 で他人のマクロをレビューしました。今日は macro_rules! の最後の大物、tt マンチャ(tt muncher)と内部規則を使って、小さな DSL(専用の書式)を解析するマクロを書きます。

題材は状態遷移表です。次のように「どの状態から、どの事象で、どの状態へ」を1行ずつ書くと、列挙型と遷移関数が生成されます。

fsm! {
    state JobState { Idle, Running, Done, Failed }
    event JobEvent { Start, Finish, Crash, Reset }

    Idle => Running on Start;
    Running => Done on Finish;
    Idle | Running => Failed on Crash;
    Done | Failed => Idle on Reset "reset";   // 末尾の文字列はメモ(省略できる)
    Running => Failed after 30;              // 30 ティック経ったら自動で遷移
}

ここで新しく出てくる道具は3つです。

道具何をするか今日どこで使うか
内部規則(@munch のように @ で始めた規則名)公開しない補助の規則。同じマクロの中で自分自身を呼び、状態を引き渡す解析の本体を @munch に置き、入口の規則は宣言だけ受け取る
tt マンチャ入力を「先頭の1行だけ食べて、残りを再帰で渡す」形で処理する遷移表は行ごとに書式が違う(on 行・after 行・メモ付き)ため、1つの繰り返しパターンでは書けない
アキュムレータ(溜め込み用の [...])再帰のたびに、生成途中のコードの断片を括弧で包んで持ち回るmatch の腕・タイムアウトの腕・表の行の3つを別々に溜める

そして、今日使う2つのフラグメント指定子があります。

指定子何に一致するか今日の用途・落とし穴
patパターン(edition 2021 以降は A | B のような最上位の | も含む)遷移元を Idle | Running と複数書けるようにする。pat_param だと | で止まる
literalリテラル("reset"、30 など。-1 も1つのリテラル)メモと、タイムアウトのティック数

今日いちばん大事な落とし穴は pat です。Idle と書いたパターンは、その名前の列挙子が見えていれば列挙子、見えていなければ「何にでも一致する変数の束縛」になります。遷移元に Runing(typo)と書いても、デフォルトのままでは警告が出るだけでコンパイルは通り、挙動が静かに変わります(実測は後述)。マクロの利用者が打ち間違えたとき、エラーにできるのかどうかが、今日の実装の勘所です。

📝 問題

cargo new macro_day059(edition 2024、外部クレートなし)の src/main.rs に、fsm! マクロを実装してください。上の呼び出し例が通り、次の7つの要件を満たすこと。

// 先に置いておく(ライブラリ側の部品。マクロの外)
use std::fmt;

#[derive(Debug, PartialEq, Eq)]
pub struct Rejected<S, E> {
    pub state: S,
    pub event: E,
}
// impl fmt::Display for Rejected<S, E> も付ける:
//   "event Finish is not allowed in state Idle"

要件

#要件
R1state と event の宣言から、#[derive(Debug, Clone, Copy, PartialEq, Eq)] 付きの pub enum を2つ生成する。列挙型の名前は呼び出し側が渡す(paste クレートは使わない)
R2JobState::on(self, ev: JobEvent) -> Result<(JobState, Option<&'static str>), Rejected<JobState, JobEvent>> を生成する。表に無い組み合わせは Err(Rejected { .. })
R3遷移元は **pat** で受け、Idle | Running => Failed on Crash; のように複数の状態を1行にまとめられる
R4on 行の末尾に **literal** のメモを付けられる(on Reset "reset";)。付けた場合だけ on の戻り値の Option が Some(メモ) になる
R5Running => Failed after 30; の行から JobState::timeout(self) -> Option<(JobState, u32)> を生成する。after が無い状態は None
R6JobState::TRANSITIONS: &'static [(&'static str, &'static str, &'static str)] を生成する。1行につき (遷移元の文字列, 遷移先の文字列, 事象名) の1要素。after 行の3つ目は "after 30"。Idle | Running はそのままの文字列で入る
R7利用者の書き間違いをコンパイルエラーにする: (a) 遷移元の状態名の typo、(b) 同じ(状態, 事象)への2重定義。事象名・遷移先の typo は、列挙型に無い名前なのでもともとエラーになる

テストも書きます。表とは別に人が手で書いた正解表(オラクル)を用意し、4状態×4事象の 16組すべてで on の結果と照合してください。

設計の設問(コードの後に答える)

  • D1. 遷移表の読み方は3案あります。(A) tt マンチャ、(B) 1つの繰り返し $( ... )* で読む「平らな」版、(C) 表を transitions { ... } と timeouts { ... } の2区画に分けて、それぞれ繰り返しで読む。今回 (A) を使う理由を、(B) で何ができて何ができないかを実際に試して答えてください。また (A) の弱点(再帰の上限)が何行目で出るかを測ってください
  • D2. R7 の検査をマクロの内側(生成コードに #[deny(...)] を付ける)に入れる案と、利用者の #![deny(...)] に任せる案があります。どちらを採り、何が代償ですか
  • D3. 列挙型の名前を呼び出し側から受け取る代わりに、paste クレートで Job から JobState を組み立てる案があります。採りますか

💡 ヒント

ヒント1(方向性)
  • 入口の規則は「state ... event ... の宣言」と「残りの遷移表($($table:tt)*)」だけ受け取り、中身を読むのは別の規則にする。規則名の頭に @ を付けると、外から呼ばれない内部規則であることが一目で分かる
  • 再帰のたびに「ここまでに作った match の腕」を持ち回る。マクロの出力は途中で組み替えられないので、腕は [ ... ] で包んだ生のトークンとして運ぶ
  • 規則の並べる順序に意味がある。どれにも一致しない入力は no rules expected ... で落ちるが、フラグメントの途中で失敗すると次の規則に進めず即エラーになる
ヒント2(アプローチ)
  • 終端の規則(残りが空)、after 行、メモ付き on 行、メモ無し on 行の **4本の @munch 規則**を作る。終端は入力が空のときだけ一致するので、どの位置に置いても pat の途中失敗を起こさない。on 行は、長い形(メモ付き)を先、短い形を後ろに置く癖を付ける
  • pat を溜めた腕に埋め込むときは ($from, $Event::$ev) => ... のようにそのまま置く。$State::$from のように前に何かを付けることはできない(pat は不透明な塊)。代わりに生成関数の中で use {$State::*, $Event::*}; して、裸の列挙子名が見えるようにする
  • R7 の (a) は「typo した名前が変数の束縛になる」ので、未使用変数の lintが検出できる。(b) は到達不能パターンの lintが検出できる。どちらも既定では警告
  • R6 の文字列は stringify!($from)。Idle | Running がそのまま文字列になる
ヒント3(コード骨格)
macro_rules! fsm {
    // 入口: 宣言を受け取り、遷移表は tt の塊のまま内部規則へ渡す
    (state $State:ident { $($S:ident),+ $(,)? }
     event $Event:ident { $($E:ident),+ $(,)? }
     $($table:tt)*) => {
        fsm!(@munch $State $Event [] [] [] $($table)*);   // 3つのアキュムレータ: on の腕 / after の腕 / 表の行
        // enum を2つ出す
    };

    // 終端: 残りが空 → 溜めた腕で impl を出す
    (@munch $State:ident $Event:ident [$($on:tt)*] [$($after:tt)*] [$($row:tt)*]) => { /* impl */ };

    // 1行食べる: `$from:pat => $to:ident after $n:literal ;`
    (@munch $State:ident $Event:ident [$($on:tt)*] [$($after:tt)*] [$($row:tt)*]
     $from:pat => $to:ident after $n:literal ; $($rest:tt)*) => {
        fsm!(@munch $State $Event [$($on)*] [$($after)* /* 腕を足す */] [$($row)* /* 行を足す */] $($rest)*);
    };

    // on 行は、メモ付き・メモ無しの2本
}

✅ 模範解答

use std::fmt;

/// 遷移できなかったときの値。マクロの外(ライブラリ側)に置く
#[derive(Debug, PartialEq, Eq)]
pub struct Rejected<S, E> {
    pub state: S,
    pub event: E,
}

impl<S: fmt::Debug, E: fmt::Debug> fmt::Display for Rejected<S, E> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "event {:?} is not allowed in state {:?}", self.event, self.state)
    }
}

macro_rules! fsm {
    // ---- 入口: 状態と事象の宣言を受け取り、残りの「遷移表」を内部規則へ渡す ----
    (
        state $State:ident { $($S:ident),+ $(,)? }
        event $Event:ident { $($E:ident),+ $(,)? }
        $($table:tt)*
    ) => {
        fsm!(@munch $State $Event [] [] [] $($table)*);
        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
        pub enum $State { $($S),+ }
        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
        pub enum $Event { $($E),+ }
    };

    // ---- 終端: 遷移表を読み切ったので、溜めた腕から impl を出す ----
    (@munch $State:ident $Event:ident
        [$($on:tt)*] [$($after:tt)*] [$($row:tt)*]
    ) => {
        impl $State {
            #[deny(unused_variables, unreachable_patterns)]
            pub fn on(self, ev: $Event) -> Result<(Self, Option<&'static str>), Rejected<Self, $Event>> {
                #[allow(unused_imports)]
                use {$State::*, $Event::*};
                match (self, ev) {
                    $($on)*
                    (state, event) => Err(Rejected { state, event }),
                }
            }
            #[deny(unused_variables, unreachable_patterns)]
            pub fn timeout(self) -> Option<(Self, u32)> {
                #[allow(unused_imports)]
                use $State::*;
                match self {
                    $($after)*
                    _ => None,
                }
            }
            pub const TRANSITIONS: &'static [(&'static str, &'static str, &'static str)] = &[$($row)*];
        }
    };

    // ---- 1行読む: `A | B => C after N;`(タイムアウト遷移) ----
    (@munch $State:ident $Event:ident
        [$($on:tt)*] [$($after:tt)*] [$($row:tt)*]
        $from:pat => $to:ident after $n:literal ; $($rest:tt)*
    ) => {
        fsm!(@munch $State $Event
            [$($on)*]
            [$($after)* $from => Some(($State::$to, $n)),]
            [$($row)* (stringify!($from), stringify!($to), concat!("after ", stringify!($n))),]
            $($rest)*);
    };

    // ---- 1行読む: `A => B on E "メモ";`(メモ付き。メモなしより先に置く) ----
    (@munch $State:ident $Event:ident
        [$($on:tt)*] [$($after:tt)*] [$($row:tt)*]
        $from:pat => $to:ident on $ev:ident $note:literal ; $($rest:tt)*
    ) => {
        fsm!(@munch $State $Event
            [$($on)* ($from, $Event::$ev) => Ok(($State::$to, Some($note))),]
            [$($after)*]
            [$($row)* (stringify!($from), stringify!($to), stringify!($ev)),]
            $($rest)*);
    };

    // ---- 1行読む: `A => B on E;` ----
    (@munch $State:ident $Event:ident
        [$($on:tt)*] [$($after:tt)*] [$($row:tt)*]
        $from:pat => $to:ident on $ev:ident ; $($rest:tt)*
    ) => {
        fsm!(@munch $State $Event
            [$($on)* ($from, $Event::$ev) => Ok(($State::$to, None)),]
            [$($after)*]
            [$($row)* (stringify!($from), stringify!($to), stringify!($ev)),]
            $($rest)*);
    };
}

fsm! {
    state JobState { Idle, Running, Done, Failed }
    event JobEvent { Start, Finish, Crash, Reset }

    Idle => Running on Start;
    Running => Done on Finish;
    Idle | Running => Failed on Crash;
    Done | Failed => Idle on Reset "reset";
    Running => Failed after 30;
}

fn main() {
    let s = JobState::Idle;
    let (s, _) = s.on(JobEvent::Start).unwrap();
    println!("after Start        : {s:?}");
    println!("timeout in Running : {:?}", s.timeout());
    println!("Done + Reset       : {:?}", JobState::Done.on(JobEvent::Reset));
    println!("Idle + Finish      : {}", JobState::Idle.on(JobEvent::Finish).unwrap_err());
    for r in JobState::TRANSITIONS {
        println!("{r:?}");
    }
}

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

    const ALL_STATES: [JobState; 4] = [Idle, Running, Done, Failed];
    const ALL_EVENTS: [JobEvent; 4] = [Start, Finish, Crash, Reset];

    /// 遷移表とは別に、人が手で書いた正解表(オラクル)
    fn oracle(s: JobState, e: JobEvent) -> Option<(JobState, Option<&'static str>)> {
        match (s, e) {
            (Idle, Start) => Some((Running, None)),
            (Running, Finish) => Some((Done, None)),
            (Idle | Running, Crash) => Some((Failed, None)),
            (Done | Failed, Reset) => Some((Idle, Some("reset"))),
            _ => None,
        }
    }

    #[test]
    fn all_16_pairs_match_the_oracle() {
        for s in ALL_STATES {
            for e in ALL_EVENTS {
                assert_eq!(s.on(e).ok(), oracle(s, e), "{s:?} + {e:?}");
            }
        }
    }

    #[test]
    fn rejected_carries_state_and_event() {
        let err = Done.on(Start).unwrap_err();
        assert_eq!(err, Rejected { state: Done, event: Start });
        assert_eq!(err.to_string(), "event Start is not allowed in state Done");
    }

    #[test]
    fn timeout_only_in_running() {
        assert_eq!(Running.timeout(), Some((Failed, 30)));
        for s in [Idle, Done, Failed] {
            assert_eq!(s.timeout(), None);
        }
    }

    #[test]
    fn table_has_one_row_per_source_line() {
        assert_eq!(JobState::TRANSITIONS.len(), 5);
        assert_eq!(JobState::TRANSITIONS[2], ("Idle | Running", "Failed", "Crash"));
        assert_eq!(JobState::TRANSITIONS[4], ("Running", "Failed", "after 30"));
    }
}

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

cargo run -q:

after Start        : Running
timeout in Running : Some((Failed, 30))
Done + Reset       : Ok((Idle, Some("reset")))
Idle + Finish      : event Finish is not allowed in state Idle
("Idle", "Running", "Start")
("Running", "Done", "Finish")
("Idle | Running", "Failed", "Crash")
("Done | Failed", "Idle", "Reset")
("Running", "Failed", "after 30")

cargo test -q の末尾: test result: ok. 4 passed; 0 failed。cargo clippy --all-targets は警告 0 件。ソースは 167 行(テスト込み)。

D1 の回答: tt マンチャを選ぶのは「行の書式が混ざるから」。弱点は 126 行目で出る

案読める行実測判定
(A) tt マンチャon 行・メモ付き on 行・after 行の3種を混在できる同じ遷移行を並べて測ると 125 行まで通り、126 行でコンパイル失敗(recursion limit reached while expanding)。127 行以降は fsm! の展開で失敗採用。弱点は上限
(B) 平らな繰り返し $( $from:pat => $to:ident on $ev:ident $($note:literal)? ; )*on 行とメモ付き on 行(メモは $(...)? で表せる)。**after 行は読めない**(after と on は同じ繰り返しの中で書式が違うため)メモ付き行を含む 2000 行がコンパイルでき、実行まで cargo run 全体で約 1.1 秒(ビルド込みの壁時計)on 行だけなら (A) より強い
(C) 2区画に分けるtransitions { ... } と timeouts { ... } を別の繰り返しで読む未実測(導出: (B) と同じ仕組みを2回使うので再帰は起きない)次点。表が1枚で読めなくなるのが代償
  • 採った理由: R5 の after 行を on 行と同じ表に混ぜる要件があるから。ここが (B) の限界で、tt マンチャを使う唯一の正当な理由です。「on 行だけの表」なら (B) を使うのが正解で、tt マンチャを選ぶ理由はありません
  • 却下した (B): 2000 行が通る強さは魅力ですが、after 行を混ぜられない
  • 却下した (C): 設計としては健全。ただし「1行ごとに遷移を書く表」という DSL の見た目を崩す。前提が変わったら判断が変わる: 遷移が 125 行を超える表を扱う、または #![recursion_limit] を利用者に上げさせたくない場合は (C) に切り替える
  • 再帰上限の読み方: 既定の recursion_limit は 128。今回は1行につき再帰が1段、入口と終端で数段使うので、行数 + 数段で上限に当たる(上の実測: 125 行まで可・126 行で不可)。#![recursion_limit = "256"] を crate の先頭に置けば伸ばせるが、今日は 256 での上限は測っていない

D2 の回答: 検査はマクロの内側に入れる

on と timeout に #[deny(unused_variables, unreachable_patterns)] を付けて生成します。

案内容判定
A: 内側に #[deny(...)]生成した関数にだけ lint を強制する採用。利用者の設定に依らず R7 が守られる
B: 利用者の #![deny(warnings)] に任せる利用者側の設定却下。typo が警告で済むのが危険の本体で、守るかどうかが利用者次第になる
C: マクロの中で全状態名を照合する$S と $from を比べる却下。pat は不透明な塊で、Idle | Running や Idle if .. のような形を macro_rules! では分解できない
  • 代償: lint を deny にすると、利用者は意図して未使用の束縛を書けなくなる(例: _ や Failed if cond のような拡張)。今回の DSL ではそのような書き方を許していないので問題にならない
  • 測った効果: Runing => Done on Finish; と書いたとき、deny を付けない版は警告だけでコンパイルが通る。deny を付けた版は error: unused variable: Runing` で落ちる(どちらも実測)。2重定義 Running => Failed on Finish; の追加は error: unreachable pattern`(実測)

D3 の回答: paste は使わない

  • 採らなかった理由: paste は手続き的マクロの依存で、外部クレートなしの要件に反する。名前を呼び出し側が書く手間(state JobState)は1か所だけ
  • 前提が変わったら: 状態機械を20個以上定義するなら、名前の組み立てを任せる価値が出る。その場合も、まず次月の「マクロ②(手続き的マクロ)」で、paste を使わず自作する選択肢と比べる

🪜 Step-by-Step 解説

Step 1: 入口の規則は宣言だけを受け取る

(state $State:ident { $($S:ident),+ $(,)? }
 event $Event:ident { $($E:ident),+ $(,)? }
 $($table:tt)*) => { ... };

$($table:tt)* は「残りのトークンを全部」受け取ります。tt(token tree)は1つのトークン、または対になった括弧の中身全部で、どんな入力でも必ず一致するので、解析を後回しにするのに使えます。入口では遷移表を読まず、そのまま @munch へ渡します。

Step 2: @munch — 1行食べて、残りを渡す

(@munch $State:ident $Event:ident [$($on:tt)*] [$($after:tt)*] [$($row:tt)*]
 $from:pat => $to:ident on $ev:ident ; $($rest:tt)*) => {
    fsm!(@munch $State $Event
        [$($on)* ($from, $Event::$ev) => Ok(($State::$to, None)),]
        [$($after)*]
        [$($row)* (stringify!($from), stringify!($to), stringify!($ev)),]
        $($rest)*);
};
  • 先頭の @munch は規則を選ぶための目印で、入口の規則(state ... で始まる)と衝突しません
  • [$($on:tt)*] がアキュムレータ。呼び出しのたびに $($on)* に新しい腕を足して、次の呼び出しへ渡します。1行ごとに配列の前後へ足すだけで、途中の腕を書き換えることはしません
  • 食べた行は $($rest)* から消えるので、入力は1行ずつ短くなり、必ず終端の規則に着く

Step 3: 終端でまとめて impl を出す

(@munch $State:ident $Event:ident [$($on:tt)*] [$($after:tt)*] [$($row:tt)*]) => {
    impl $State {
        pub fn on(self, ev: $Event) -> Result<...> {
            use {$State::*, $Event::*};
            match (self, ev) {
                $($on)*
                (state, event) => Err(Rejected { state, event }),
            }
        }
        ...
    }
};

溜めた腕を match の中に展開するのは、終端の規則だけです。途中の規則は match の枠を知らないまま腕だけを運びます。これがアキュムレータの基本形です。

Step 4: pat を match の腕にそのまま置く

($from, $Event::$ev) => ... で $from は Idle | Running のような**最上位の | を含むパターン**を1つの塊として運びます。edition 2021 以降の pat はこの形に一致します。edition 2018 の挙動に戻す pat_param を使うと Idle | Running が通らない(実測は後述)。

Step 5: 裸の名前が列挙子に見える仕組みと、その裏返しの危険

use {$State::*, $Event::*}; を関数の中に書くと、Idle は JobState::Idle に解決されます。**この use が無いと**、Idle は新しい変数名として扱われ、どの状態にも一致する腕になります。逆に、use があっても列挙子にない名前(typo)は変数の束縛になる。ここが R7(a) の原因で、#[deny(unused_variables)] が止める仕組みです。

Step 6: 表の行を stringify! で作る

stringify!($from) は pat の塊をソースの見た目どおりの文字列にします(Idle | Running)。after 行は concat!("after ", stringify!($n)) で "after 30" にします。TRANSITIONS は定数なので、実行時の確保はありません。

Step 7: オラクルと照合する

all_16_pairs_match_the_oracle は、oracle 関数(人が手で書いた match)と、マクロが生成した on を 16 組すべてで突き合わせます。マクロの出力をマクロ自身の出力で検証しないのが要点で、TRANSITIONS(マクロが作る表)と on(マクロが作る関数)を比べるテストでは、両方が同じ誤りを持っていても通ってしまいます。

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

マクロは「書式の読み取り」だけを担当し、意味は型に任せる

今回、マクロがやっているのは「行をばらして match の腕に並べ直す」ことだけです。状態遷移の意味(その事象で遷移してよいか)は、生成された match と Result が持っています。遷移表の誤りの検出は unreachable_patterns と unused_variables、型の検査は列挙型に任せ、マクロに独自の検査を書かない。Day 058 で見た「マクロをやめる」の延長で、マクロの責務を最小にすると壊れ方が減ります。

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

候補内容採否
採用状態遷移表 DSL(pat・literal・tt マンチャ・内部規則を自然に全部使える)要件に after 行の混在があるので、(B) の平らな版では書けない
却下逆ポーランド記法の電卓マクロtt マンチャの定番例だが、pat・literal を使う場面が無い
却下簡易 HTML ビルダー(html! { div { p { "x" } } })再帰が入れ子で、行ごとの tt マンチャとは別の技法になる。次回以降に回す

実装の途中で、最初は要件に after が無く、(B) の平らな版で全部書けることに気づきました。それでは tt マンチャを使う理由が無く、問題として成立しないので、混在行を要件に足しています。

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

どれも出題者の環境(rustc 1.96.0)で実際に出たものを短縮しています。

警告1: 遷移元の typo は、deny が無いと警告だけで通る(R7(a))

Runing => Done on Finish; と書くと、deny なしの版では次の警告が出て、コンパイルは通ります。

warning: unused variable: `Runing` help: you might have meant to pattern match on the similarly named variant `Running` warning: variable `Runing` should have a snake case name

読み方: 「Runing という新しい変数を作ったが使っていない」=列挙子ではなく変数の束縛として読まれた。この腕は (何でも, Finish) に一致するので、Done への遷移があらゆる状態で起きるようになります。deny 付きの版では同じ内容が error: unused variable: Runing`` になります。

エラー2: 2重定義(R7(b))

Running => Done on Finish; の後ろに Running => Failed on Finish; を足すと、deny 付きの版で error: unreachable pattern。後ろの行が前の行に隠れて決して使われないことを示します。どちらを残すかはマクロでは決められません。

エラー3: 事象名の typo は、元から E0599

on Finsh; → error[E0599]: no variant, associated function, or constant named Finsh found for enum JobEvent`。コンパイラが Finish を候補に挙げます。遷移先の typo(Dne`)も同じ E0599 です。

エラー4: セミコロンを忘れた

Running => Done on Finish(; なし)→ 次の行の Idle が来た時点で error: no rules expected Idle`、補足に while trying to match meta-variable $note:literal。**どの規則のどの位置で止まったか**が補足に出るので、エラー位置ではなく**この補足を読む**のが tt マンチャのデバッグの基本です。メモ付きの規則($note:literal` が期待される規則)で失敗が報告されるのは、最後に試した規則の位置が出るため。

エラー5: 遷移が 126 行を超えた

error: recursion limit reached while expanding fsm!`(126 行では stringify! の展開で、127 行以降は fsm! の展開で出た。どちらも実測)。対処は #![recursion_limit = "256"]` か、D1 の (C) への切り替え。

エラー6: pat_param に変えた

$from:pat_param にすると Idle | Running => ... が error: no rules expected |`(補足: while trying to match =>)になります。**pat は edition 2021 以降で最上位の | を含み、pat_param` は含まない**ためです。

🌐 他言語との比較

観点Rust macro_rules!GoTypeScriptC プリプロセッサ
DSL を言語内に埋め込む可能。入力はトークンで、**構文の型(pat・literal)で受けられる**go generate で外部ツールを呼ぶ(言語内のマクロは無い)型レベルのテンプレートリテラル型で一部可能。実行時は文字列を解析可能だが入力は生のテキストで、構文の型が無い
書き間違いの検出生成された match に対する lint と型検査で捕まる生成物のコンパイルエラーとして出る型エラー展開後のエラーとして出て、元の行に戻りにくい
再帰の上限既定 128(今日の実測: 約125行の遷移表)—型の再帰深さに上限がある再帰的な展開を原則許さない

Go は状態機械を switch と定数で書くのが普通で、表から生成するなら外部の生成ツールになります。Rust は同じことを言語内で、利用者のコードと同じ型検査にかけられるのが違いです。ただし今日は他言語での実測はしていません(表は言語の仕様に関する一般的な整理です)。

🛠 実務での使いどころ

  • プロトコルの状態機械(接続・認証・再送)を表の形で宣言し、match の書き漏れをコンパイラに見つけさせる。既存のクレートでは rust-fsm や statig などがあるが、今日は比較していないので、実務で使う前に両者の機能(階層状態・非同期対応など)と自作を比べること
  • **「宣言の表」+「生成された TRANSITIONS」**は、遷移図(Graphviz など)の自動出力や、ドキュメントとコードのずれの検出に使える
  • 実務では「on 行だけの表」なら平らな繰り返し (B) が堅い。tt マンチャは、行の書式が混ざる DSL や、前の行を見て次を決める DSL のためにとっておく

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「pat に typo を書いたらコンパイルエラーになる」列挙子のつもりで書いた名前が、変数の束縛として有効だから既定では警告のみ。#[deny(unused_variables)] を生成コードに付けて初めてエラーになる
「tt マンチャは複雑な DSL に必ず必要」定番の技法として紹介されるから行の書式が揃っていれば $( ... )* で読める。2000 行が通った(再帰を使う版は 125 行で止まる)
「規則の順序は関係ない」関数の引数のオーバーロードのように見えるから上から順に試され、フラグメントの途中で失敗すると次の規則に進めないことがある
「$State::$from と書けば列挙子に補完できる」ident なら書けるからpat は不透明な塊。前に何も付けられない。use {$State::*} で名前を見えるようにする
「マクロが作った表と関数を比べればテストになる」一致しているから安心に見える両方が同じ誤りを持てば一致する。マクロの外で手書きしたオラクルと比べる

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

注意: 今日は上記ページを取得し直して照合していません。記述は出題者の既存知識と、実際にコンパイルして得た挙動(上の実測)に基づいています。参照した資料の記述と食い違う場合は、実測を優先して資料側を確認してください。

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

扱わなかったこと理由・次の予定
rust-fsm・statig など実在クレートとの機能比較取得して読んでいない。比較は次月のテーマ「マクロ②(手続き的マクロ・derive)」で行う
#![recursion_limit = "256"] での上限今日は 128 の挙動だけ測った
trybuild によるコンパイル失敗テスト外部クレートなしの方針で未導入。エラー1〜6は手で再現しただけで、自動テストには入れていない(宿題として残す)
(C) 2区画案の実装と測定導出のみ。次回、HTML ビルダー(再帰が入れ子になる別の技法)と合わせて測る
遷移の数が多いときのコンパイル時間の伸び方再帰版の時間を測っていない((B) 2000 行の壁時計 1 回のみ)
非同期遷移・階層状態・ガード条件(if)付きの遷移今日の DSL に入れていない
別クレートからの $crate/#[macro_export] の実測Day 057 から持ち越しのまま

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

  • 同じ指示を AI に出せば数時間で出るか: コード自体は出る。出ないのは、「平らな版で全部書けてしまう」ことに気づいて要件を足した判断と、typo が警告で通るという実測、2000 行と 126 行の境目の数字
  • AI の指示だけでは出ない部分: D1 の測定(125/126 行・2000 行)、D2 の「deny あり/なし」の比較、判断の記録
  • 世界の実物に対する割合: 状態機械クレートの機能(階層・ガード・非同期・図の出力など)のうち、今日は遷移・タイムアウト・表の出力の3つだけ。実在クレートは未確認なので割合は数えていない

➡️ 次のステップ

  • 発展: Idle => Running on Start if ready; のようなガード付き遷移を足す。if は pat の follow-set に含まれるので、$from:pat if $g:expr と書ける。ただし pat の中にガードが吸い込まれないかを確かめる
  • 次回予告: マクロ① の5問目(設計・アーキテクチャ)— 「マクロにするか、関数・型にするか」の判断を、今週書いた4つのマクロ(hashmap!・count!・fsm! など)で通して行う

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

自分の回答

気づき・メモ