Day 064 — 2026-10-04 | マクロ① macro_rules! ⑨ ガード付き遷移 ($s:pat, $e:pat) if $g:expr => $to:fsm! に条件付きの行を足し、if let ガードを通す4案を実測で比べる

2026-10-04 🟣 上級者 / Phase 3 実装 マクロ①(宣言的マクロ macro_rules!)
フェーズ: 上級者 (Phase 3) | 問題タイプ: 実装 | テーマ: マクロ①(宣言的マクロ macro_rules!)

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

Day 058〜059 で作った fsm! は「状態 × 事象 → 次の状態」の表を書くだけで enum と on() を生成しました。ただし実務の状態機械には、同じ状態・同じ事象でも、条件によって行き先が変わる行がほぼ必ずあります。

  • 扉: 施錠中に「解錠」→ 暗証番号が正しければ「閉」、3回失敗していれば「警報」、どちらでもなければ「施錠のまま」
  • 注文: 「支払い完了」→ 在庫があれば「出荷待ち」、無ければ「取り寄せ」

match には、これを書く仕組みが最初からあります。マッチガード(パターン if 条件 =>)です。The Rust Reference は動作をこう書いています(参照日 2026-10-04、https://doc.rust-lang.org/reference/expressions/match-expr.html )。

"When the pattern matches successfully, the pattern guard is executed."

つまり、パターンが一致した行だけガードが評価され、偽なら次の行へ進みます。今日の実装は、この match の仕組みをマクロの表の書き方に持ち込むことです。

ここで、マクロ特有の壁が3つ出てきます。今日はこの3つに実際にぶつかります。

壁中身一次資料
1. 衛生性(hygiene)マクロの中で作ったローカル変数(例: ctx)は、呼び出し側が書いたガード式からは見えないReference: "local variables are looked up at the macro definition site"
2. 後続トークンの制限(follow-set)$g:expr の後ろに置けるのは => , ; だけ。$s:pat の後ろには if が置けるReference: "expr and stmt may only be followed by one of: =>, ,, or ;."
3. 再帰の上限トークンを1つずつ食べる書き方(tt muncher)は、再帰が深くなると止まるReference: "The default in rustc is 128."

(3つとも参照日 2026-10-04 に取得して照合。URL は末尾の「一次資料」)

今日の位置づけ: Day 063 の判断記録(ADR-063)では、マクロを追加してよい理由を4つに絞りました。fsm! は **④ item(enum・impl)を生成する** に当たるので、マクロのまま育ててよい部品です。

📝 問題

環境は rustc 1.96.0、cargo new --lib guardfsm(Cargo.toml の edition は cargo new の既定で 2024。2021 に変えても同じ結果になることを確認済み)。依存クレートはありません。

Part 1: ガード付きの fsm! を実装する(本題)

次の呼び出しが通る fsm! を src/lib.rs に書いてください。

fsm! {
    context c: Ctx;                       // ガードから読む文脈。名前 c は呼び出し側が決める
    state Door { Locked, Closed, Open, Alarm }
    event Ev { Unlock, Lock, Push, Pull, Reset }

    (Locked, Unlock) if c.tick() && c.attempts >= 3 => Alarm;   // 先に「試行回数超過」を見る
    (Locked, Unlock) if c.tick() && c.code_ok      => Closed;
    (Locked, Unlock)                                => Locked;  // ガード無しの受け皿
    (Closed, Lock)                                  => Locked;
    (Closed, Push)                                  => Open;
    (Open, Pull)                                    => Closed;
    (Alarm, Reset) if c.is_admin                    => Locked;
}

要件:

  1. Door::on(self, ev: Ev, c: &Ctx) -> Result<Door, Rejected<Door, Ev>> を生成する。どの行にも当たらなければ Err(Rejected { state, event })
  2. 行は上から順に照合し、最初に「パターン一致 かつ ガード真」の行が勝つ
  3. ガードは任意(if ... が無い行も書ける)
  4. 状態名の打ち間違い(Opn)や、絶対に届かない行はコンパイルエラーにする(Day 059 と同じ #[deny(unused_variables, unreachable_patterns)] の工夫)

Ctx は次のものを使ってください(tick() はガードが評価された回数を数えるためのもの)。

use std::cell::Cell;
pub struct Ctx { pub code_ok: bool, pub attempts: u32, pub is_admin: bool, pub guard_evals: Cell<u32> }
impl Ctx {
    pub fn new(code_ok: bool, attempts: u32, is_admin: bool) -> Self {
        Ctx { code_ok, attempts, is_admin, guard_evals: Cell::new(0) }
    }
    pub fn tick(&self) -> bool { self.guard_evals.set(self.guard_evals.get() + 1); true }
}

Part 2: テストを書いて、予想と比べる

次の5本を cargo test で通してください。書く前に、括弧の中の値を予想して紙に書くこと。

#入力確かめること(予想を書く)
T1Locked + Unlock、暗証番号 正、試行 0行き先は?
T2Locked + Unlock、暗証番号 誤、試行 0行き先は?(拒否か、留まるか)
T3Locked + Unlock、暗証番号 正、試行 3行き先は?(2つのガードが両方真)
T4Alarm + Reset、管理者でない/管理者Err になるか/行き先は?
T5ガードの評価回数: ①Closed+Push ②Locked+Unlock(暗証番号 誤・試行 0)③同(試行 5)①②③ それぞれ何回?

Part 3: 壊して、エラーの出方を記録する

次の3つを実際に試し、エラーの種類と指している場所(呼び出し側か、マクロ定義側か)を記録してください。

  • (a) マクロの中で引数名を ctx に固定し(context c: を受け取らない)、呼び出し側のガードに ctx.ok と書く
  • (b) Part 1 の表で、ガード無しの (Locked, Unlock) => Locked; を一番上に移す
  • (c) (Open, Pull) を (Opn, Pull) と打ち間違える

Part 4: if let ガードを表に書けるようにする(4案を測る)

rustc 1.96.0 では、match のガードに if let が書けます(Reference の文法 MatchGuardCondition → ... | let Pattern = MatchGuardScrutinee。手元でも edition 2021・2024 の両方でコンパイルと実行を確認)。次の行を表に書きたいとします。

(Locked, Unlock) if let Some(p) = c.entered && p == c.expected => Closed;

ところが Part 1 の $(if $g:expr)? では通りません。次の4案を実際に書いて、通るか/何行まで書けるかを表にしてください。

案マッチャー(行の受け方)
A$(if $g:expr)?(Part 1 のまま)
A'$(if $g:expr)? $(if let $gp:pat = $ge:expr)?(2つの任意グループを並べる)
B$(if $($g:tt)+)?(=> までのトークンを丸ごと受ける)
C$(if { $($g:tt)+ })?(呼び出し側にガードを { } で囲ませる)
Dtt muncher(ガードを1トークンずつ食べ、=> が来たら行を確定する)

最後に、どの案を採るかの判断記録を 10 行以内で書いてください。迷った他案と採らなかった理由、前提が変わったら判断がどう変わるか、を含めること。

💡 ヒント

ヒント1(方向性)

生成する match の腕は、($s, $e) if $g => Ok($to), の形です。ガードが無い行は if $g の部分が消えるだけ。つまり「ガードを任意にする」は、**マクロの任意グループ $( ... )? をそのまま展開側にも書く**だけで済みます。

ガードから c が見えるかどうかは、c という名前を誰が書いたかで決まります。

ヒント2(アプローチ)
  • パターンは $s:pat、ガードは $g:expr、行き先は $to:ident。$s:pat の後ろに , や if を置くのは許されますが、+ などは許されません
  • 引数名は context $c:ident : $Ctx:ty ; で呼び出し側から受け取り、fn on(self, ev: $Event, $c: &$Ctx) に使います。こうすると c は呼び出し側の名前になり、衛生性の壁を越えられます
  • #[deny(unreachable_patterns)] は、ガード無しの行の後ろにある同じパターンの行を止めます(ガード付きの行の後ろは止めません。ガードが偽なら次に進むからです)
  • Part 4 の D: => は1つのトークンです。「先頭が => なら確定、それ以外なら1つ食べて溜める」の2本の規則を、この順に書きます
ヒント3(コード骨格)
#[macro_export]
macro_rules! fsm {
    (
        context $c:ident : $Ctx:ty ;
        state $State:ident { $($S:ident),+ $(,)? }
        event $Event:ident { $($E:ident),+ $(,)? }
        $( ($s:pat, $e:pat) $(if $g:expr)? => $to:ident ; )*
    ) => {
        /* enum を2つ */
        impl $State {
            #[deny(unused_variables, unreachable_patterns)]
            pub fn on(self, ev: $Event, $c: &$Ctx) -> Result<Self, $crate::Rejected<Self, $Event>> {
                #[allow(unused_imports)]
                use {$State::*, $Event::*};
                match (self, ev) {
                    $( ($s, $e) $(if $g)? => Ok($to), )*
                    (state, event) => Err($crate::Rejected { state, event }),
                }
            }
        }
    };
}

✅ 模範解答

Part 1・2 の答え(cargo test で 5 本通過を実測)

cargo new --lib guardfsm の src/lib.rs にそのまま貼れば検証できます。

use std::cell::Cell;

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

#[macro_export]
macro_rules! fsm {
    (
        context $c:ident : $Ctx:ty ;
        state $State:ident { $($S:ident),+ $(,)? }
        event $Event:ident { $($E:ident),+ $(,)? }
        $( ($s:pat, $e:pat) $(if $g:expr)? => $to:ident ; )*
    ) => {
        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
        pub enum $State { $($S),+ }
        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
        pub enum $Event { $($E),+ }

        impl $State {
            /// 遷移表を上から順に照合する。最初に「パターン一致 かつ ガード真」の行が勝つ
            #[deny(unused_variables, unreachable_patterns)]
            pub fn on(self, ev: $Event, $c: &$Ctx)
                -> ::core::result::Result<Self, $crate::Rejected<Self, $Event>>
            {
                #[allow(unused_imports)]
                use {$State::*, $Event::*};
                match (self, ev) {
                    $( ($s, $e) $(if $g)? => ::core::result::Result::Ok($to), )*
                    (state, event) => ::core::result::Result::Err($crate::Rejected { state, event }),
                }
            }
        }
    };
}

/// ガードの中で読む文脈。ガードが何回評価されたかを数える
pub struct Ctx {
    pub code_ok: bool,
    pub attempts: u32,
    pub is_admin: bool,
    pub guard_evals: Cell<u32>,
}
impl Ctx {
    pub fn new(code_ok: bool, attempts: u32, is_admin: bool) -> Self {
        Ctx { code_ok, attempts, is_admin, guard_evals: Cell::new(0) }
    }
    /// ガードから呼ぶと評価回数が 1 増える
    pub fn tick(&self) -> bool { self.guard_evals.set(self.guard_evals.get() + 1); true }
}

fsm! {
    context c: Ctx;
    state Door { Locked, Closed, Open, Alarm }
    event Ev { Unlock, Lock, Push, Pull, Reset }

    (Locked, Unlock) if c.tick() && c.attempts >= 3 => Alarm;   // 先に「試行回数超過」を見る
    (Locked, Unlock) if c.tick() && c.code_ok      => Closed;
    (Locked, Unlock)                                => Locked;  // ガード無しの受け皿(拒否ではなく留まる)
    (Closed, Lock)                                  => Locked;
    (Closed, Push)                                  => Open;
    (Open, Pull)                                    => Closed;
    (Alarm, Reset) if c.is_admin                    => Locked;
}

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

    #[test]
    fn guard_true_moves() {
        let c = Ctx::new(true, 0, false);
        assert_eq!(Door::Locked.on(Ev::Unlock, &c), Ok(Door::Closed));
    }

    #[test]
    fn guard_false_falls_through_to_unguarded_row() {
        let c = Ctx::new(false, 0, false);
        assert_eq!(Door::Locked.on(Ev::Unlock, &c), Ok(Door::Locked));
    }

    #[test]
    fn first_matching_row_wins() {
        // 暗証番号が正しくても、試行回数超過の行が先に書いてあるので Alarm
        let c = Ctx::new(true, 3, false);
        assert_eq!(Door::Locked.on(Ev::Unlock, &c), Ok(Door::Alarm));
    }

    #[test]
    fn guard_false_without_fallback_is_rejected() {
        let c = Ctx::new(false, 0, false);
        assert_eq!(
            Door::Alarm.on(Ev::Reset, &c),
            Err(Rejected { state: Door::Alarm, event: Ev::Reset })
        );
        let admin = Ctx::new(false, 0, true);
        assert_eq!(Door::Alarm.on(Ev::Reset, &admin), Ok(Door::Locked));
    }

    #[test]
    fn guards_are_evaluated_only_when_pattern_matches() {
        let c = Ctx::new(true, 0, false);
        assert_eq!(Door::Closed.on(Ev::Push, &c), Ok(Door::Open));
        assert_eq!(c.guard_evals.get(), 0); // Locked 行のガードは呼ばれない

        let c = Ctx::new(false, 0, false);
        Door::Locked.on(Ev::Unlock, &c).unwrap();
        assert_eq!(c.guard_evals.get(), 2); // 1行目・2行目のガードが順に評価され、3行目へ

        let c = Ctx::new(false, 5, false);
        Door::Locked.on(Ev::Unlock, &c).unwrap();
        assert_eq!(c.guard_evals.get(), 1); // 1行目で決まり、2行目のガードは評価されない
    }
}

実行結果(実測、rustc 1.96.0、edition 2024。edition 2021 に変えても 5 本通過):

running 5 tests
.....
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Part 2 の答え(すべて実測):

#結果読み取ること
T1Ok(Closed)2行目のガードが真
T2Ok(Locked)ガード2本が偽 → 3行目(ガード無し)が受け皿になる。**受け皿が無ければ Err**
T3Ok(Alarm)両方真でも上の行が勝つ。表の行の順番は仕様の一部
T4管理者でない → Err(Rejected{Alarm, Reset})、管理者 → Ok(Locked)ガード付きの行しかない組み合わせは、偽のとき拒否になる
T5① 0 回 ② 2 回 ③ 1 回パターンが一致しない行のガードは呼ばれない(①)。上から順に評価し、真で止まる(③)

Part 3 の答え(実出力の要点、rustc 1.96.0)

(a) 引数名をマクロ内で ctx に固定した場合:

error[E0425]: cannot find value `ctx` in this scope --> a.rs:9:22 9 | fsm_bad! { (0, 1) if ctx.ok => 1; } | ^^^ not found in this scope help: an identifier with the same name is defined here, but is not accessible due to macro hygiene --> a.rs:4:35 4 | pub fn on(st: u8, ev: u8, ctx: &Ctx) -> u8 {

指す場所: 呼び出し側(そしてマクロ定義側の ctx を「衛生性のせいで見えない」と名指しする)。rustc 1.96.0 はこの help: を出してくれるので、原因はすぐ分かります。直し方は、名前を呼び出し側から $c:ident で受け取ること(模範解答の context c: Ctx;)。

(b) ガード無しの行を一番上に移した場合:

error: unreachable pattern --> src/lib.rs:32:24 32 | $( ($s, $e) $(if $g)? => ::core::result::Result::Ok($to), )* | ^^^^^^^^ | no value can reach this | matches all the relevant values ... 55 | / fsm! { | |_- in this macro invocation

指す場所: マクロ定義側。止めること自体はできますが、表の何行目が悪いかを言いません(fsm! 全体を指すだけ)。7行の表なら目で探せますが、50行の表では探すのに時間がかかります。これが Day 063 で記録した「マクロのエラーは定義側を指す」コストの、今日の実例です。

(c) Opn の打ち間違い:

error: unused variable: `Opn` --> src/lib.rs:65:6 65 | (Opn, Pull) => Closed; | ^^^ help: you might have meant to pattern match on the similarly named variant `Open`

指す場所: 呼び出し側の該当行、しかも Open の候補まで出ます。$s:pat に渡したトークンは呼び出し側の位置情報(span)を持ったままなので、行が特定できます。(b) との違いは、(b) のエラーが「生成した match の腕」というマクロが作った構造に対して出ている点です。

Part 4 の答え(4案の実測)

案結果(実測)if let ガード書ける行数(3トークンのガード c.is_admin の行を並べた場合)
A $(if $g:expr)?呼び出し時に error: no rules expected keyword let(while trying to match meta-variable $g:expr)×1,000 行で成功(再帰しない)
A' 任意グループ2つマクロ定義の時点でエラー: $g:expr may be followed by if, which is not allowed for expr fragments `×―(定義できない)
B $(if $($g:tt)+)?呼び出し時に local ambiguity when calling macro: multiple parsing options(=> が「ガードの続き」とも「区切り」とも読める)×―
C $(if { $($g:tt)+ })?成功。Some("7")→7、Some("x")→-1、None→0○(if { let Ok(n) = .. } と書かせる)再帰しない(行数の上限は今日は未測定。A と同じく再帰が無いので上限が出ないと推定)
D tt muncher成功(if let Some(p) = c.entered && p == c.expected が通り、テスト 1 本通過)○(普通の書き方のまま)**25 行まで成功、26 行で recursion limit reached**。#![recursion_limit = "256"] で 50 行成功・51 行失敗

D の上限は計算と合います(導出): 1行あたり「行の開始 1 + ガードのトークン 3 + 行の確定 1 = 5 回」再帰するので、128 ÷ 5 ≒ 25 行、256 ÷ 5 ≒ 51 行(実測は 50 行。入口の 1 回ぶんずれる)。ガードが長いほど書ける行数が減るので、c.tick() && c.attempts >= 3(10 トークン)のような行ばかりなら、既定の上限で 10 行ほどしか書けない計算です(この組み合わせは未実測)。

D の版(src/lib.rs の差し替え。cargo test 1 本通過を実測):

#[derive(Debug, PartialEq, Eq)]
pub struct Rejected<S, E> { pub state: S, pub event: E }

/// 版D: ガードを tt で1つずつ食べる(tt muncher)。`if let` ガードも通る
#[macro_export]
macro_rules! fsm {
    (
        context $c:ident : $Ctx:ty ;
        state $State:ident { $($S:ident),+ $(,)? }
        event $Event:ident { $($E:ident),+ $(,)? }
        $($table:tt)*
    ) => {
        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
        pub enum $State { $($S),+ }
        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
        pub enum $Event { $($E),+ }
        $crate::fsm!(@row [$c $Ctx ; $State $Event] [] $($table)*);
    };

    // 終端: 溜めた腕で impl を出す
    (@row [$c:ident $Ctx:ty ; $State:ident $Event:ident] [$($arms:tt)*]) => {
        impl $State {
            #[deny(unused_variables, unreachable_patterns)]
            pub fn on(self, ev: $Event, $c: &$Ctx)
                -> ::core::result::Result<Self, $crate::Rejected<Self, $Event>>
            {
                #[allow(unused_imports)]
                use {$State::*, $Event::*};
                match (self, ev) {
                    $($arms)*
                    (state, event) => ::core::result::Result::Err($crate::Rejected { state, event }),
                }
            }
        }
    };

    // ガード無しの行
    (@row $hdr:tt [$($arms:tt)*] ($s:pat, $e:pat) => $to:ident ; $($rest:tt)*) => {
        $crate::fsm!(@row $hdr [$($arms)* ($s, $e) => ::core::result::Result::Ok($to),] $($rest)*);
    };
    // ガード付きの行: `if` の後ろを `=>` まで1トークンずつ集める
    (@row $hdr:tt [$($arms:tt)*] ($s:pat, $e:pat) if $($rest:tt)*) => {
        $crate::fsm!(@guard $hdr [$($arms)*] [($s, $e)] [] $($rest)*);
    };
    // 先頭が `=>` なら行を確定(この規則を先に書くのが要点)
    (@guard $hdr:tt [$($arms:tt)*] [$($p:tt)*] [$($g:tt)*] => $to:ident ; $($rest:tt)*) => {
        $crate::fsm!(@row $hdr [$($arms)* $($p)* if $($g)* => ::core::result::Result::Ok($to),] $($rest)*);
    };
    // それ以外は1トークン食べてガードに溜める
    (@guard $hdr:tt [$($arms:tt)*] [$($p:tt)*] [$($g:tt)*] $t:tt $($rest:tt)*) => {
        $crate::fsm!(@guard $hdr [$($arms)*] [$($p)*] [$($g)* $t] $($rest)*);
    };
}

pub struct Ctx { pub entered: Option<u32>, pub expected: u32, pub is_admin: bool }

fsm! {
    context c: Ctx;
    state Door { Locked, Closed, Open, Alarm }
    event Ev { Unlock, Lock, Push, Pull, Reset }

    (Locked, Unlock) if let Some(p) = c.entered && p == c.expected => Closed;
    (Closed, Lock) => Locked;
    (Closed, Push) => Open;
    (Open, Pull) => Closed;
    (Alarm, Reset) if c.is_admin => Locked;
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn if_let_guard_passes_through() {
        let c = Ctx { entered: Some(1234), expected: 1234, is_admin: false };
        assert_eq!(Door::Locked.on(Ev::Unlock, &c), Ok(Door::Closed));
        let c = Ctx { entered: Some(1111), expected: 1234, is_admin: false };
        assert!(Door::Locked.on(Ev::Unlock, &c).is_err());
        let c = Ctx { entered: None, expected: 1234, is_admin: false };
        assert!(Door::Locked.on(Ev::Unlock, &c).is_err());
    }
}

途中で踏んだ罠(実測): 最初は内部規則の見出しを [$c:ident $Ctx:ty $State:ident $Event:ident] と書き、定義の時点で $Ctx:ty is followed by $State:ident, which is not allowed for ty fragments ` で止まりました(ty の後ろに置けるのは { [ => , > = : ; | as where)。$Ctx:ty ; と ; を挟んで直しています。

判断記録(10 行以内):

ADR-064 fsm! のガードの受け方
決定: 既定は案A `$(if $g:expr)?`。`if let` ガードが必要な表だけ案D(tt muncher)を別マクロで用意する。
理由: A は再帰しないので 1,000 行でも通り(実測)、エラーも `$g:expr` の位置を指す。
採らなかった案: A'(定義時にエラー、expr の後に if を置けない)/B(`=>` で曖昧エラー)/
  C(通るが `if { let .. }` という Rust に無い書き方を覚えさせる。読む人が驚く)/
  D を既定にする(3トークンのガードで既定 25 行、256 に上げても 50 行が上限。ガードが長いと更に減る)。
前提が変わったら: ①表の大半が `if let` を使うなら D を既定にし recursion_limit を README に明記する
  ②`if let` を使う行が数行なら、ガード側を `matches!(c.entered, Some(p) if p == c.expected)` の式に書き直して A で済ませる
  ③表が 100 行を超えるなら、マクロをやめて build.rs や proc-macro で生成する案を測る。
測れていないこと: C の行数上限、D の 10 トークンガードでの行数、両案のコンパイル時間。

👣 Step-by-Step 解説

Step 1: 任意グループは、展開側にもそのまま書く

$(if $g:expr)? で受けたら、展開側も $(if $g)? と書きます。外側の $( ... )*(行の繰り返し)の中に、内側の $( ... )?(ガードの有無)が入れ子になっているだけで、tt muncher(再帰)は要りません。$g が何回繰り返されたかを rustc が覚えているので、**ガードの無い行では if ごと消えます**。

なぜ $s:pat の後ろに if が置けるのか: match のガードがまさに「パターンの後ろの if」なので、Rust は pat の後ろに if を許しています(Reference の follow-set: =>, ,, =, if, in。rustc 1.96.0 のエラーメッセージは、さらに if let も挙げます)。

Step 2: ガードの中の名前は、呼び出し側に決めさせる

Part 3 (a) の E0425 が示すとおり、マクロの中で ctx という引数を作っても、呼び出し側が書いた ctx.ok の ctx とは別物として扱われます。これが衛生性(Reference の言葉では mixed-site hygiene: ローカル変数はマクロ定義側で解決される)です。C のマクロのように「たまたま同じ名前だったので繋がった」が起きないための安全装置です。

繋げたいときは、名前そのものを呼び出し側から渡す(context $c:ident)。こうすると引数の $c もガードの c も、同じ「呼び出し側が書いた c」になります。これは |c| ... とクロージャの引数名を呼び出し側が決めるのと同じ考え方です。

Step 3: 行の順番は仕様である

T3 で、暗証番号が正しくても Alarm になりました。match は上から最初に当たった腕を採るので、表の行の順番がそのまま優先順位です。状態遷移表を Excel などの表で管理している現場では、行の並べ替えが仕様変更になる点に注意が要ります(ソートしてはいけない表)。

Step 4: ガードの評価回数は、パターンで絞られる

T5 の ①0回・②2回・③1回は、Reference の「パターンが一致したらガードを実行する」「&& と同じく左から評価し、偽で止まる」の2つの記述どおりでした。ガードに重い処理(DB 照会など)を書くと、同じ (状態, 事象) の行の数だけ呼ばれうる、ということです。

補足(実測): 同じ腕の中で | を使ったパターンは、ガードが複数回走ることがあります。match (1, 1) { (1, 1) | (1, _) if g() => .., _ => .. } では g() が 2 回、(1, 1 | _) と入れ子にしても 2 回でした(g() が偽を返す場合)。Reference にも "Multiple matches using the | operator can cause the pattern guard ... to execute multiple times." とあります。今日の表は Locked | Closed のように重ならない選択肢しか使わないので 1 回で済みますが、ガードに副作用を書くなら気にすべき点です。

Step 5: 「止められる」と「どこが悪いか言える」は別

Part 3 の (b) と (c) は、どちらも deny で止まります。違いは位置情報です。

誤り止まるか指す場所理由
(c) Opn止まる呼び出し側の該当行+候補 Open$s のトークンが呼び出し側の span を持つ
(b) 行の順番止まるマクロ定義の $( ($s, $e) ... )*「到達不能」は生成した腕どうしの関係で、どの行かを言わない

(b) を行単位で指させたいなら、proc-macro で自前の検査を書き、compile_error! を該当行の span で出す必要があります(マクロ② の範囲)。

Step 6: if let ガードを通すには、expr から降りる

let Some(p) = c.entered は式ではない(if や match のガードの中でだけ書ける構文)ので、$g:expr は受け取れません(案A のエラー)。ではトークンのまま受けようとすると:

  • 案B: $($g:tt)+ は => もトークンとして食べられるので、「まだガードの続き」か「ここで終わり」かを決められず、曖昧エラー
  • 案A': $g:expr の直後に別の if が来うる形は、マクロの定義そのものが拒否される(expr の後ろには => , ; しか置けない)

逃げ道は2つです。区切りを括弧で作る(案C: { } の中は1つのトークン木なので曖昧にならない)か、1トークンずつ食べて「=> が来たら止まる」を規則の順番で表す(案D)。D は自然な書き方のまま通りますが、1トークンごとに1段再帰するので、既定の上限 128 にすぐ当たります(3トークンのガードで 25 行)。

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

match のガードは、「パターン(形)で絞り込み、ガード(値の条件)で最終判断する」という2段構えです。形の検査はコンパイラが網羅性・到達可能性を調べられますが、ガードの中身はコンパイラには分かりません(だから ガード付きの腕は網羅性の計算に数えられません。T4 で Alarm+Reset が拒否になりえたのはこのため)。今日の fsm! は、この区別を表の書き方にそのまま残しています。

マクロ側の工夫は、Rust の構文をそのまま通すことに尽きます。$s:pat と $g:expr で受けて、match にそのまま置く。独自の DSL(例: when code_ok)を作らないので、ガードの意味は Rust の match と同じで、利用者が新しく覚えることがありません。案C を既定にしなかったのも同じ理由です(if { let .. } は Rust に無い書き方)。

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

案採らなかった理由
ガードをクロージャで渡す(if |c| c.code_ok)呼び出し側で名前を決められるので衛生性は解決するが、match のガードに置けないので (|c| ..)(ctx) と呼ぶ形になり、if let も使えない。context c: で名前を1回渡す方が短い
Ctx を self に持たせる(struct Machine { state, ctx })状態が Copy な enum で済まなくなり、Day 059 までの on(self, ev) の形が変わる。文脈を変更したい(試行回数を増やす)なら次の候補だが、今日は読み取りだけに絞った
ガードをマクロの外の関数にして、表には関数名だけ書く(if code_ok)表の見通しは良くなるが、関数が増えて行と条件が離れる。行が 20 を超えるなら検討する
案D(muncher)だけにするif let も通るが、行数の上限が既定で 25 行(3トークンのガード)。表が大きい用途で突然止まる

前提が変わったら: 文脈を書き換える必要(失敗回数を数える等)が出たら $c: &$Ctx を &mut に変えるか、遷移の結果と一緒に「やること(アクション)」を返す設計に変えます(Day 059 のメモ付き遷移の発展)。その場合、ガードの中で &mut を使えるかは別途測る必要があります(未実施)。

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

今日は実装問題ですが、Part 3・4 で実際に出たエラーを、読み方つきでまとめます(いずれも rustc 1.96.0 の実出力)。

エラーいつ出るか読み方・直し方
E0425 cannot find value ctx + not accessible due to macro hygieneマクロ内で作った名前を、呼び出し側のガードから使った名前を $c:ident で呼び出し側から受け取る
unreachable pattern + in this macro invocationガード無しの行の後ろに同じパターンの行がある表の順番を見直す。エラーは行を名指ししないので、ガード無しの行を探す
unused variable: Opn + similarly named variant Open状態名の打ち間違い(use で解決できない名前は「変数への束縛」になる)候補の名前に直す。deny(unused_variables) が無いと全部に当たる腕になり黙って誤動作する
no rules expected keyword let$g:expr に if let を渡した案C か案D に替える
$g:expr may be followed by if `マクロの定義(呼ぶ前)の検査expr の後ろには => , ; しか置けない
local ambiguity when calling macro$($g:tt)+ => で => の扱いが2通りに読めた区切りを括弧で作るか、muncher にする
$Ctx:ty is followed by $State:ident `ty の直後に別の断片を並べた間に ; などの許された区切りを挟む
recursion limit reached while expandingmuncher の行・ガードが多すぎる#![recursion_limit = "256"](上限が倍になるだけ)か、再帰しない案に替える

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

観点Rust(今日の fsm!)TypeScript(XState)GoJava
ガードの書き方match のガードをそのまま使う(if 条件、if let も可)設定オブジェクトに guard: ({ context }) => ... を書くswitch の中で if を書くswitch のパターン+when 条件(Java 21 のパターンマッチ)
状態名の打ち間違いコンパイルエラー(deny(unused_variables))文字列なら実行時まで気付かない(型付けすればコンパイル時)定数なら未定義エラーenum ならコンパイルエラー
届かない行コンパイルエラー(deny(unreachable_patterns))。ただし位置はマクロ定義側検出しない検出しない支配されたラベルはコンパイルエラー
評価の順番上から、パターン一致の行だけガードを評価遷移配列の上から、最初に真のガード書いた順上から

XState の guard の書き方は、今日は一次資料を取得して照合していません(記憶に基づく要旨)。

🏭 実務での使いどころ

  • 決済・注文・ワークフローの状態遷移: 「支払い完了 → 在庫ありなら出荷待ち/無しなら取り寄せ」のように、同じ事象で行き先が分かれる行は必ず出ます。行き先の分岐を on() の外の if に散らすより、表の1行に置いた方が「何が起こりうるか」を表だけで読めます。
  • 受け皿の行(T2)を明示する設計判断: ガードが全部偽のとき「拒否(Err)」か「留まる(Ok(同じ状態))」かは、業務上の意味が違います(拒否はログ・警告の対象、留まるは正常)。表にガード無しの受け皿を書くかどうかで、この判断をコードに残せます。
  • ガードに副作用を書かない: T5 のとおり、ガードは行の数だけ呼ばれえます。今日の tick() は数えるための例外で、実務のガードは読み取りだけにします。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「マクロの中の ctx は呼び出し側からも見える」C のマクロ(ただの文字列置換)の感覚衛生性で別物になる。E0425 と not accessible due to macro hygiene(実測)
「ガードは全部の行で評価される」if の連鎖と同じだと思うパターンが一致した行だけ(T5: 0・2・1 回、実測)
「ガードが両方真なら、どちらでもよい」表を集合だと思う上の行が勝つ(T3: Alarm、実測)。表の順番は仕様
「if let も $g:expr で受けられる」if let を式だと思うlet .. は式ではない。no rules expected keyword let(実測)
「$($g:tt)+ なら何でも受けられる」tt は万能だと思う区切りの => も tt なので曖昧エラー(実測)
「recursion_limit を上げれば muncher は解決」上限の問題だと思う上げても倍になるだけ(25 → 50 行、実測)。ガードの長さに比例して減る
「unreachable_patterns を deny すれば、悪い行を教えてくれる」止まることと、位置を言うことを混同マクロ定義側を指し、表の何行目かは言わない(実測)

📖 一次資料(参照日: 2026-10-04。今回取得して、引用部分を照合済み)

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

扱わなかったこと理由・次の予定
案C の行数上限、案D の 10 トークンガードでの行数今日は 3 トークンのガードだけを測った。D の式(上限 ÷ (トークン数 + 2))は導出のみ
4案のコンパイル時間今日は「通るか・何行まで」だけ。Day 062 の方法(5 回測って最小)で測る候補
ガードで文脈を書き換える(&mut Ctx)・遷移時のアクション設計が変わる(上の「前提が変わったら」)。別の日に
表の何行目が届かないかを行単位で報告する検査macro_rules! では難しい。proc-macro(マクロ②)で扱う
実在クレート(rust-fsm・statig・sm など)のガードの書き方との照合一次資料を今日は取得していない。断言しない
XState のガードとの比較の照合記憶に基づく要旨のみ(上の比較表に明記)
edition 2021 と 2024 での if-let ガード・let チェーンの扱いの差手元の rustc 1.96.0 では両方で通ったことだけ確認。安定化された版は未確認

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

  1. 同じ指示を AI に出せば数時間で出るか — ガード付きの fsm!(案A)のコード自体は出る。ただし「案A' は定義時に落ちる」「案B は曖昧エラー」「案D は既定で 25 行・256 で 50 行」「unreachable のエラーは表の行を名指ししない」「ty の後ろに ident を置けず定義で落ちた」は、実際に動かして初めて出た結果。
  2. AI に指示するだけでは出ない部分 — 4案を同じ表で並べた実測と、そこから出した「既定は A、if let が要る表だけ D」の判断記録(採らなかった案・前提が変わった場合・測れていないこと)。
  3. 世界の実物と比べて何割か — match のガードの仕様(Reference の Match expressions)のうち、扱ったのは「パターン一致時のみ評価」「左から評価し偽で止まる」「| で複数回評価」「if let・let チェーン」の4点で、属性付きのガード条件(OuterAttribute* let)や網羅性との関係の詳細は扱っていない。実在の状態機械クレートとの照合は 0 件(上の表に明記)。

➡️ 次のステップ

  • 発展: 案D の muncher を、ガードのトークン数に依存しない形にできるか考える(例: ガードを [ ] で囲む案Cの変形と比べて、書き味と上限を測る)。または、遷移と一緒に「アクション」(例: 試行回数を増やす)を返す fsm! に拡張する
  • 次回予告: マクロ① の残りの日程で、実在クレートのマクロ(一次資料を取得して照合)を読み、Day 063 の ADR の4基準のどれに当たるかを確かめる

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

自分の回答

気づき・メモ