Day 065 — 2026-10-05 | マクロ① macro_rules! ⑩ fsm! を「使う側」から壊す:ガード付き遷移表のコードレビュー(5つの不具合・コンパイラが3段階で止める・| でガードが2回走る)

2026-10-05 🟣 上級者 / Phase 3 デバッグ・コードレビュー マクロ①(宣言的マクロ macro_rules!)
フェーズ: 上級者 (Phase 3) | 問題タイプ: デバッグ・コードレビュー | テーマ: マクロ①(宣言的マクロ macro_rules!)

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

Day 064 で、fsm! にガード付きの行((状態, 事象) if 条件 => 行き先;)を書けるようにしました。マクロを作る側の問題はそこで片付きました。今日は使う側です。

マクロは「書き方」を決めますが、その書き方で書かれた中身が正しいかは守りません。ガード付きの遷移表には、マクロでもコンパイラでも止まらない誤りが入り込みます。今日のレビューで見る観点は3つです。

観点中身止まるか
1. 行の順番同じ (状態, 事象) の行が複数あると、上の行が勝つ(Day 064 T3 で実測)止まらない(ガード付きの行は到達可能性の検査に数えられない)
2. ガードの副作用ガードは「パターンが一致した行の数だけ」評価される。副作用があると回数がずれる止まらない
3. ガードの中の借用生成される on() は文脈を &Ctx(共有参照)で受ける。ガードから値をムーブできない止まる(借用チェッカー)

観点2 には、The Rust Reference に明記された落とし穴がもう1つあります(参照日 2026-10-05、https://doc.rust-lang.org/reference/expressions/match-expr.html 、今回取得して照合)。

"Multiple matches using the | operator can cause the pattern guard and the side effects it has to execute multiple times."

| でつないだ選択肢のうち複数が同じ値に一致すると、ガードはその回数だけ走ります。

観点3 についても、同じページにこうあります。

"Only when the guard evaluates successfully is the value moved, or copied, from the scrutinee into the variable."

つまり match 自身は、ガードの間は共有参照しか使わないように設計されています。ガードの中で自分からムーブを書けば、それは借用チェッカーに止められます。

📝 問題

環境は rustc 1.96.0、cargo new --lib reviewfsm(edition 2024)。依存クレートはありません。fsm! マクロ本体と Rejected は Day 064 の模範解答をそのまま使います(マクロ側は今日は変更しない)。

前提: 仕様(S1〜S7)

扉の暗証番号ロックです。チームで合意済みの仕様は次の7つです。

#仕様
S1Locked で Unlock、番号が正しければ Closed
S2番号が未入力(None)なら「誤り」として扱い、Locked に留まる。panic しない
S3誤り1回につき、失敗回数はちょうど +1
S4誤りの3回目で Alarm(1・2回目は Locked)
S5失敗回数が既に3以上なら、正しい番号でも Alarm(総当たり対策)
S6Locked での Unlock は拒否(Err)されない。必ず Ok を返す
S7Alarm は管理者だけが Reset で Locked に戻せる

レビュー対象の PR

同僚が次のコードを出してきました。このコードには不具合が5つあります。

use std::cell::Cell;

pub struct Ctx {
    pub entered: Option<String>,   // キーパッドで入力された番号(未入力なら None)
    pub expected: String,
    pub failures: Cell<u32>,       // 失敗回数(ガードから数えるので Cell)
    pub is_admin: bool,
}
impl Ctx {
    pub fn new(entered: Option<&str>, failures: u32, is_admin: bool) -> Self {
        Ctx { entered: entered.map(String::from), expected: "1234".into(),
              failures: Cell::new(failures), is_admin }
    }
    /// 失敗を1回記録し、記録後の失敗回数を返す
    pub fn record_failure(&self) -> u32 {
        self.failures.set(self.failures.get() + 1);
        self.failures.get()
    }
}

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

    (Locked, Unlock) if c.entered.unwrap() == c.expected => Closed;
    (Locked, Unlock) if c.record_failure() >= 3          => Alarm;
    (Locked, Unlock) if c.record_failure() < 3           => Locked;
    (Closed, Lock)                                       => Locked;
    (Closed, Push)                                       => Open;
    (Open, Pull)                                         => Closed;
    (Alarm, Reset) if c.is_admin = true                  => Locked;
}

Part 1: コンパイルを通す(予想してから実行)

  1. cargo build を実行する前に、何個のエラーが出るか、どのエラー番号(E0xxx)が出るかを予想して書いてください
  2. 実行して、予想との差を記録してください
  3. コンパイラの提案(help:)をそのまま適用する方針で、通るまで直してください。何回ビルドしたか、各回どのエラーが出たかを記録してください

Part 2: 仕様テストで、コンパイラが止めない不具合を見つける

S1〜S7 をそれぞれ1本の #[test] にして、Part 1 の「コンパイラの提案どおりに直した版」に対して実行してください。実行前に、どのテストが落ちるかと、S4 の3回の結果の列を予想してください。

Part 3: 5つの不具合を表にまとめ、正しく直す

#どこ不具合の中身コンパイラが止めたかどのテストが落としたか

を埋め、S1〜S7 がすべて通る版を書いてください。行の追加・並べ替え・Ctx へのメソッド追加は自由。マクロ本体は変更しない。

Part 4: | パターンとガード(確かめるだけ)

次の2つの行で、audit()(呼ばれた回数を数え、false を返す)が何回呼ばれるかを予想し、テストで確かめてください。あわせて cargo clippy が警告を出すかどうかも記録してください。

(A | B, X | Y) if c.audit() => C;   // 入力 (A, X)
(A | _, X)     if c.audit() => C;   // 入力 (A, X) と (B, X)

💡 ヒント

ヒント1(方向性)
  • コンパイラのエラーは段階的に出ます。型の検査で止まると、借用の検査まで進みません。「エラーが1つ」は「不具合が1つ」を意味しません
  • コンパイラの help: は「コンパイルが通る直し方」であって、「仕様どおりの直し方」ではありません。S2 と照らし合わせてください
  • 失敗回数が「1回の誤りで何回増えるか」を、行を上から1つずつ追って数えてください
ヒント2(アプローチ)
  • c.is_admin = true は Rust では代入式で、型は () です。ガードには bool が要ります
  • Option<String>::unwrap() は self を取ります(ムーブ)。&Ctx 越しにはムーブできません。as_ref() でもムーブは避けられますが、None で何が起きるかは別問題です。借用だけで比べるなら as_deref() で Option<&str> にして、Some(c.expected.as_str()) と比べます
  • record_failure() が2つの行のガードにあると、誤り1回で何回呼ばれますか。3行目のガードが偽になったとき、その下にガード無しの受け皿はありますか
  • S5 は「締め出し判定を、番号の判定より上に置く」問題です
ヒント3(コード骨格)
impl Ctx {
    pub fn code_ok(&self) -> bool {
        self.entered.as_deref() == Some(self.expected.as_str())
    }
}

fsm! {
    // ...
    (Locked, Unlock) if /* 締め出し済みか(副作用なし) */ => Alarm;
    (Locked, Unlock) if c.code_ok()                       => Closed;
    (Locked, Unlock) if /* 失敗を記録して3回目か */       => Alarm;
    (Locked, Unlock)                                      => Locked; // 受け皿
    // ...
    (Alarm, Reset) if c.is_admin => Locked;
}

✅ 模範解答

Part 1 の答え: ビルドは3回、エラーは毎回1個ずつ(実測、rustc 1.96.0)

予想で多いのは「E0308 と E0507 の2個が同時に出る」ですが、実際は1個ずつしか出ません。

回出たエラー指した場所適用した help:
1回目E0308 mismatched types — expected bool, found ()呼び出し側の表の c.is_admin = true(src/lib.rs:66:23)you might have meant to compare for equality → == true(模範解答では c.is_admin だけにする)
2回目E0507 cannot move out of c.entered which is behind a shared reference呼び出し側の c.entered(src/lib.rs:60:25)4つの提案のうち先頭の consider calling .as_ref() を採用
3回目E0277 can't compare &String with String呼び出し側の ==(src/lib.rs:60:53)consider dereferencing here → *c.entered.as_ref().unwrap()
4回目(成功)——

1回目のエラー出力(抜粋):

error[E0308]: mismatched types --> src/lib.rs:66:23 | 66 | (Alarm, Reset) if c.is_admin = true => Locked; | ^^^^^^^^^^^^^^^^^ expected `bool`, found `()` help: you might have meant to compare for equality | 66 | (Alarm, Reset) if c.is_admin == true => Locked;

2回目(抜粋):

error[E0507]: cannot move out of `c.entered` which is behind a shared reference --> src/lib.rs:60:25 | 60 | (Locked, Unlock) if c.entered.unwrap() == c.expected => Closed; | ^^^^^^^^^ -------- `c.entered` moved due to this method call | | | move occurs because `c.entered` has type `Option<String>`, which does not implement the `Copy` trait note: `Option::<T>::unwrap` takes ownership of the receiver `self`, which moves `c.entered` help: consider calling `.as_ref()` to borrow the value's contents help: consider calling `.as_mut()` to mutably borrow the value's contents help: you can `clone` the value and consume it, but this might not be your desired behavior help: consider cloning the value if the performance cost is acceptable

読み取ること:

  • E0507 は1回目に出なかった。 rustc は型検査(E0308)で失敗すると、借用検査(E0507)まで進みません。「エラーが1つ=直す所が1つ」ではない
  • 3つとも、マクロの中ではなく呼び出し側の行を指した。 $g:expr で受けた式は、呼び出し側のスパン(位置情報)を持ったまま match に置かれるため。Day 064 の unreachable pattern(マクロ定義側を指した)とは対照的
  • 2回目の help: は4つ出たが、どれも「None のとき panic する」ことには触れていない。as_mut() の提案は &Ctx 越しでは通らないはず(未実測)

Part 2 の答え: 7本中5本が落ちる(実測)

「コンパイラの提案どおりに直した版」に S1〜S7 を流した結果:

test tests::s1_correct_code_opens ... ok
test tests::s2_no_input_stays_locked ... FAILED
test tests::s3_one_failure_counts_once ... FAILED
test tests::s4_third_failure_alarms ... FAILED
test tests::s5_lockout_beats_correct_code ... FAILED
test tests::s6_unlock_never_rejected ... FAILED
test tests::s7_only_admin_resets ... ok
test result: FAILED. 2 passed; 5 failed
テスト実際の結果期待
S2panic: `called Option::unwrap() on a None value(src/lib.rs:60:45`)Ok(Locked)
S3失敗回数 21
S4[Ok(Locked), Ok(Alarm), Ok(Alarm)][Ok(Locked), Ok(Locked), Ok(Alarm)]
S5Ok(Closed)Ok(Alarm)
S6失敗回数 1 から始めると Err(Rejected)(failures=1 で落ちた)常に Ok

S4・S6 を行ごとに追うと、原因は1つ(record_failure() が2行にある)から2つの症状が出ています。

開始時の失敗回数2行目のガード3行目のガード結果
0呼んで 1 → 1 >= 3 偽呼んで 2 → 2 < 3 真Ok(Locked)、回数は 2
1呼んで 2 → 偽呼んで 3 → 3 < 3 偽受け皿が無い → Err
2呼んで 3 → 真評価されないOk(Alarm)

つまり「3回で警報」のつもりが、2回目の誤りで警報になり、条件によっては Unlock が拒否されます。どちらもコンパイルは通り、警告も出ません。

Part 3 の答え: 5つの不具合と修正版(7本すべて通過を実測)

#どこ不具合の中身コンパイラが止めたか落としたテスト
B11行目と2・3行目の順番締め出し判定が番号判定より下にある。失敗3回以上でも正しい番号なら開く止めないS5
B22行目と3行目の record_failure()副作用のあるガードが2行にあり、誤り1回で2回数える止めないS3・S4
B31行目の c.entered.unwrap()&Ctx 越しのムーブ(E0507)。提案どおり as_ref().unwrap() にすると今度は None で panic半分だけ止めた(ムーブは止めたが、panic は止めない)S2
B4最後の行の c.is_admin = true== の打ち間違いで代入式になった止めた(E0308)—(コンパイル時)
B5(Locked, Unlock) に受け皿が無いガードが全部偽だと Err(Rejected) になる止めないS6

修正版(cargo new --lib reviewfsm の src/lib.rs に貼れば検証できます。fsm! 本体は Day 064 と同一):

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 entered: Option<String>,
    pub expected: String,
    pub failures: Cell<u32>,
    pub is_admin: bool,
}
impl Ctx {
    pub fn new(entered: Option<&str>, failures: u32, is_admin: bool) -> Self {
        Ctx { entered: entered.map(String::from), expected: "1234".into(),
              failures: Cell::new(failures), is_admin }
    }
    /// 入力が期待値と一致するか(借用だけで比べる。None なら false)
    pub fn code_ok(&self) -> bool {
        self.entered.as_deref() == Some(self.expected.as_str())
    }
    /// 失敗を1回記録し、記録後の失敗回数を返す
    pub fn record_failure(&self) -> u32 {
        self.failures.set(self.failures.get() + 1);
        self.failures.get()
    }
}

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

    (Locked, Unlock) if c.failures.get() >= 3   => Alarm;   // B1: 締め出しを最初に見る(副作用なし)
    (Locked, Unlock) if c.code_ok()             => Closed;  // B3: 借用だけで比べる(None でも panic しない)
    (Locked, Unlock) if c.record_failure() >= 3 => Alarm;   // B2: 副作用のあるガードは最後のガード行に1つだけ
    (Locked, Unlock)                            => Locked;  // B5: 受け皿(S6: Unlock は拒否しない)
    (Closed, Lock)                              => Locked;
    (Closed, Push)                              => Open;
    (Open, Pull)                                => Closed;
    (Alarm, Reset) if c.is_admin                => Locked;  // B4: 代入ではなく bool をそのまま
}

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

    #[test]
    fn s1_correct_code_opens() {
        let c = Ctx::new(Some("1234"), 0, false);
        assert_eq!(Door::Locked.on(Ev::Unlock, &c), Ok(Door::Closed));
    }
    #[test]
    fn s2_no_input_stays_locked() {
        let c = Ctx::new(None, 0, false);
        assert_eq!(Door::Locked.on(Ev::Unlock, &c), Ok(Door::Locked));
    }
    #[test]
    fn s3_one_failure_counts_once() {
        let c = Ctx::new(Some("0000"), 0, false);
        assert_eq!(Door::Locked.on(Ev::Unlock, &c), Ok(Door::Locked));
        assert_eq!(c.failures.get(), 1);
    }
    #[test]
    fn s4_third_failure_alarms() {
        let c = Ctx::new(Some("0000"), 0, false);
        let r: Vec<_> = (0..3).map(|_| Door::Locked.on(Ev::Unlock, &c)).collect();
        assert_eq!(r, vec![Ok(Door::Locked), Ok(Door::Locked), Ok(Door::Alarm)]);
    }
    #[test]
    fn s5_lockout_beats_correct_code() {
        let c = Ctx::new(Some("1234"), 3, false);
        assert_eq!(Door::Locked.on(Ev::Unlock, &c), Ok(Door::Alarm));
    }
    #[test]
    fn s6_unlock_never_rejected() {
        for f in 0..6 {
            let c = Ctx::new(Some("0000"), f, false);
            assert!(Door::Locked.on(Ev::Unlock, &c).is_ok(), "failures={f}");
        }
    }
    #[test]
    fn s7_only_admin_resets() {
        let c = Ctx::new(None, 3, false);
        assert!(Door::Alarm.on(Ev::Reset, &c).is_err());
        let a = Ctx::new(None, 3, true);
        assert_eq!(Door::Alarm.on(Ev::Reset, &a), Ok(Door::Locked));
    }
}

実行結果(実測、rustc 1.96.0、edition 2024):

running 7 tests
test tests::s1_correct_code_opens ... ok
test tests::s3_one_failure_counts_once ... ok
test tests::s5_lockout_beats_correct_code ... ok
test tests::s2_no_input_stays_locked ... ok
test tests::s4_third_failure_alarms ... ok
test tests::s7_only_admin_resets ... ok
test tests::s6_unlock_never_rejected ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

cargo clippy --lib も警告 0 件(実測)。

Part 4 の答え: 重なる | ではガードが2回走る。clippy はマクロ越しだと黙る(実測)

tests/orpat.rs に置いた検証コード:

use reviewfsm::fsm;
use std::cell::Cell;

pub struct Log { pub n: Cell<u32> }
impl Log {
    /// 監査ログを1件書いた体で数え、false を返す(行は成立させない)
    pub fn audit(&self) -> bool { self.n.set(self.n.get() + 1); false }
}

mod disjoint {
    use super::*;
    fsm! {
        context c: Log;
        state S { A, B, C }
        event E { X, Y }
        (A | B, X | Y) if c.audit() => C;   // 選択肢が重ならない
        (_, _) => A;
    }
}
mod overlap {
    use super::*;
    fsm! {
        context c: Log;
        state S { A, B, C }
        event E { X, Y }
        (A | _, X) if c.audit() => C;       // A は `A` と `_` の両方に一致する
        (_, _) => A;
    }
}

#[test]
fn reference_example() {
    // The Rust Reference の例をそのまま
    let i: Cell<i32> = Cell::new(0);
    match 1 {
        1 | _ if { i.set(i.get() + 1); false } => {}
        _ => {}
    }
    assert_eq!(i.get(), 2);
}

#[test]
fn disjoint_or_pattern_evaluates_guard_once() {
    let c = Log { n: Cell::new(0) };
    disjoint::S::A.on(disjoint::E::X, &c).unwrap();
    assert_eq!(c.n.get(), 1);
}

#[test]
fn overlapping_or_pattern_evaluates_guard_twice() {
    let c = Log { n: Cell::new(0) };
    overlap::S::A.on(overlap::E::X, &c).unwrap();
    assert_eq!(c.n.get(), 2);
    let c = Log { n: Cell::new(0) };
    overlap::S::B.on(overlap::E::X, &c).unwrap();
    assert_eq!(c.n.get(), 1);
}
行入力audit() の回数(実測)
(A | B, X | Y)(A, X)1
(A | _, X)(A, X)2
(A | _, X)(B, X)1
Reference の例 1 | _12

cargo test --test orpat は 3 本通過、コンパイラの警告 0 件。cargo clippy --all-targets は wildcard_in_or_patterns(_ を含む | は意味が無い)を Reference の例の行(tests/orpat.rs:35)にだけ出し、fsm! の中の (A | _, X) には出しませんでした。マクロ展開で作られたコードは、clippy の多くの検査が対象外にするためと考えられます(clippy の実装は未確認)。

読み取ること: enum の状態名を | で並べるだけ(重ならない)なら1回で済みます。_ やバインディングを混ぜて選択肢が重なると、副作用のあるガードは回数がずれ、マクロ越しでは clippy も教えてくれません。 B2 と同じ「ガードに副作用を書くと回数がずれる」問題の、もう1つの入口です。

🪜 Step-by-Step 解説

Step 1: エラーは段階ごとに1つずつ出る

rustc は「構文解析 → マクロ展開 → 名前解決 → 型検査 → 借用検査」の順に進み、ある段階で失敗すると多くの場合そこで止まります。今日の PR は E0308(型検査)で止まり、E0507(借用検査)は直してから初めて見えました。レビューでは「コンパイラが通らない」の1個目のエラーを直して安心しない。直したら必ずもう一度ビルドします。

Step 2: help: は「通す」ための提案で、「仕様を満たす」ための提案ではない

E0507 の4つの提案は、すべて「ムーブをやめる」方向でした。as_ref().unwrap() を採ると E0277 → * の追加で通りますが、None で panic します。コンパイラは「entered が None のとき何をすべきか」(S2)を知りません。仕様を知っているのはレビューする人だけです。

正しい直し方は、「借用したまま、Option のまま比べる」ことです。

self.entered.as_deref() == Some(self.expected.as_str())   // Option<&str> 同士の比較

as_deref() は Option<String> を Option<&str> に変えます(ムーブもコピーも起きない)。None は自然に「一致しない」になります。

Step 3: 副作用のあるガードは「行を通過した回数」だけ走る

Day 064 の T5 で、ガードはパターンが一致した行の数だけ評価されると実測しました。今日の B2 はその裏返しで、同じ (Locked, Unlock) の3行に record_failure() が2つあると、1回の誤りで2回数えます。

直し方は2通りあります。

  • 今日の模範解答(最小の修正): 副作用のあるガードを、同じパターンの最後のガード行に1つだけ置く。上の行は副作用なし(failures.get() を読むだけ)
  • より堅い設計(採らなかった案。下の「迷った他案」を参照): ガードは読むだけにして、失敗回数の更新は on() の結果を見て呼び出し側で行う

Step 4: 行の順番が仕様を表す

S5(締め出し済みなら正しい番号でも警報)は「締め出し判定を番号判定より上に置く」ことでしか表せません。Day 064 T3 の「上の行が勝つ」がここで仕様になります。ガード付きの行は到達可能性の検査に数えられないので、順番の誤りはコンパイラにもマクロにも止められません。テスト(S5)だけが止めます。

Step 5: 受け皿の有無は「拒否するか、留まるか」の判断

ガード付きの行だけで終わると、全部偽のときは最後の (state, event) => Err(...) に落ちます。S6 は「Unlock は拒否しない」なので、ガード無しの (Locked, Unlock) => Locked; が必要です。B5 は B2 と組み合わさって初めて表に出た(失敗回数 1 から始めたときだけ Err)ので、for f in 0..6 で開始状態を振るテストでないと見つかりません。

Step 6: どの誤りを、誰が止めるか

止める仕組み今日止めたもの
コンパイラ(型検査・借用検査)B4(代入)、B3 の前半(ムーブ)
fsm! の #[deny(unused_variables, unreachable_patterns)]今日は該当なし(Day 064 の Opn・ガード無し行の後ろの行)
仕様テストB1・B2・B3 の後半(panic)・B5
clippy今日の行は何も(マクロ越しの A | _ も黙った)

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

Rust の match は、ガードの間は共有参照しか使わず、ガードが真になってから初めて値をムーブします(Reference の引用、背景知識)。これは「ガードは判断であって変更ではない」という設計です。fsm! が文脈を &Ctx で渡しているのも同じ考え方で、ガードから文脈を書き換えられないようにしています。

今日の PR が B2 を作れたのは、Cell で & 越しの書き換え(内部可変性)を持ち込んだからです。Cell 自体は正しい道具ですが、ガードに持ち込むと「判断の回数=変更の回数」になり、行の並べ方で数がずれます。型システムが止めてくれる範囲(& なので書き換え不可)を、自分で外したことになります。

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

案中身採らなかった理由
(a) 今日の模範解答副作用ガードを最後のガード行に1つだけ置く採用。 PR の形を保ったまま最小の差分で S1〜S7 を満たす。ただし「最後に1つだけ」という約束は表のコメントにしか残らず、次に行を足す人が破れる
(b) ガードを純粋にし、回数の更新は呼び出し側on() は failures.get() + 1 >= 3 を読むだけ、呼び出し側が結果を見て failures += 1副作用がガードから消え、Cell も要らなくなる(&mut Ctx を呼び出し側で持つ)。ただし「失敗とは何か」の判定が on() の外にも必要になり、表と呼び出し側の2か所に分かれる。未実装・未測定
(c) 遷移と一緒に「アクション」を返すon() が (次の状態, Vec<Action>) を返し、Action::RecordFailure を呼び出し側が実行状態機械の定番の形(XState の actions、UML の遷移アクション)だが、fsm! の生成するシグネチャが変わる。マクロを変更しないという今日の条件から外れる
(d) マクロで `` を禁止する$s:pat を $s:ident に変える重なる ` は防げるが、(Locked | Closed, Lock)` のような安全な書き方も書けなくなる。Day 058 の表の短さを失う

前提が変わったら: 失敗回数のような「数える副作用」が2つ以上の状態にまたがる(例: Closed での誤操作も数える)なら、(a) の「最後の行に1つ」は守れなくなるので (c) に移ります。逆に、文脈が読むだけで済む表(今日の is_admin のような条件だけ)なら、Cell を Ctx から外して、ガードの副作用そのものを型で禁じるのが一番安全です。

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

今日実際に出たエラー(すべて rustc 1.96.0 の実出力)の読み方です。

エラー出た原因読み方正しい直し方
E0308 mismatched types: expected bool, found ()ガードに x = true(代入)Rust の代入式は () を返す。ガードには bool が要るif c.is_admin(== true も通るが冗長)
E0507 cannot move out of c.entered which is behind a shared reference&Ctx 越しに Option<String>::unwrap()(self を取る)note: の takes ownership of the receiver self が原因の行。behind a shared reference は「& しか持っていない」の意味as_deref() で Option<&str> にして比べる
E0277 can't compare &String with Stringas_ref().unwrap() で &String になり、右辺は String のまま左右の型を help: が示す。* で揃えると通るそもそも unwrap をやめる(上の行)
実行時 panic: `called Option::unwrap() on a None value`E0507 の提案どおりに直した結果位置(src/lib.rs:60:45)は呼び出し側の表の行同上

エラーが呼び出し側を指す理由: $g:expr で受けた式は、呼び出し側で書かれたトークン(とその位置情報)のまま match のガードに置かれます。だから、ガードの中の型・借用のエラーは表の行を正確に指します。これは Day 064 で見た unreachable pattern(マクロ定義側を指し、表の何行目かを言わない)と対照的で、マクロに渡した部品の中の誤りは位置が分かり、マクロが組み立てた構造の誤りは位置が分かりにくい、という違いです。

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

観点Rust(match ガード)TypeScriptGoJava 21(switch パターン+when)
ガードで == を = と打ち間違える() と bool の型エラーで止まる(実測)if (x = true) は通る(strict でも止まらない。ESLint の no-cond-assign で検出)if x = true { は構文エラーwhen x = true は boolean 型の代入式なので通る(未実測)
ガードの中で値をムーブ/破棄共有参照越しのムーブは E0507該当概念なし(参照のみ)該当概念なし該当概念なし
None/null の扱いunwrap() は実行時 panic、as_deref() で型のまま比べられるentered! は通って実行時 undefined 比較(panic しない)*entered は nil で panicentered.equals(...) は NullPointerException
重なる選択肢でガードが複数回走る(Reference に明記、実測 2 回)switch に該当機能なし該当機能なし未確認

TypeScript・Go・Java の列は、今日は実行して確かめていません(言語仕様の記憶に基づく要旨)。

🏭 実務での使いどころ

  • ガード付き遷移表のレビュー項目として、今日の5つはそのまま使えます: ①同じ (状態, 事象) の行の順番 ②ガードの副作用 ③& 越しのムーブ ④代入の打ち間違い ⑤受け皿の有無
  • 仕様テストは開始状態を振る: B5 は失敗回数 0 から始めるテストでは見つからず、1 から始めたときだけ出ました。状態機械のテストでは、for で文脈(回数・フラグ)を振るのが安く効きます(S6)
  • コンパイラの提案を機械的に当てない: 特に unwrap を含む提案は、None/Err の場合の仕様と照らし合わせます。AI agent に「コンパイルを通して」と頼んだときに一番入りやすい不具合でもあります(今日の Part 1 はその再現)
  • 認証・課金まわりの回数制限(ログイン試行・API レート・OTP 再送)は、今日の B2 と同じ「判断の中で数える」形になりがちです。数える場所を1か所にする(案 (b)(c))のが定石です

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「エラーが1つなら、直す所は1つ」全部の検査が同時に走ると思う型検査で止まると借用検査まで進まない。今日は 1 個ずつ 3 回出た(実測)
「help: の提案どおり直せば正しい」コンパイラが仕様を知っていると思う提案は「通す」ため。as_ref().unwrap() は None で panic(実測)
「ガードは1行に1回だけ評価される」1行=1判定だと思うパターン一致した行の数だけ。さらに重なる | では1行で2回(実測)
「&Ctx で渡しているから、ガードは文脈を変えられない」& は不変だと思うCell/RefCell で内部可変性を持ち込むと変えられる。B2 はこれ
「clippy が A | _ を教えてくれる」普通の match では教えてくれるマクロの中の行には出なかった(実測)
「受け皿が無くても、ガードのどれかは当たる」条件を網羅したつもりになるガード付きの腕は網羅性に数えられない。副作用で条件がずれると全部偽になる(S6、実測)

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

  • The Rust Reference, Match expressions: https://doc.rust-lang.org/reference/expressions/match-expr.html
    • "Multiple matches using the | operator can cause the pattern guard and the side effects it has to execute multiple times."
    • "Only when the guard evaluates successfully is the value moved, or copied, from the scrutinee into the variable."
    • Part 4 の reference_example は、このページの例(1 | _ if { i.set(i.get() + 1); false } → i == 2)を手元で実行して一致を確認したもの

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

扱わなかったこと理由・次の予定
案 (b)(純粋なガード+呼び出し側で更新)・案 (c)(アクションを返す)の実装と比較今日は「マクロを変えない」レビューに絞った。(c) は fsm! の拡張なので、マクロ① の残り期間で実装候補
E0507 の as_mut() 提案が &Ctx で実際に通らないこと推定のみ。未実測
clippy がマクロ展開のコードを黙る理由(in_external_macro 等の判定)clippy のソースを今日は読んでいない。「黙った」という観測だけ
TypeScript・Go・Java の比較列実行していない(比較表に明記)
Day 064 からの持ち越し(案C・D の上限、4案のコンパイル時間、実在の状態機械クレートとの照合 0 件)今日は使う側のレビューに集中。実在クレート(rust-fsm・statig)の一次資料の取得は次の概念理解の日に
失敗回数の永続化・並行アクセス(2台のキーパッドから同時に Unlock)Cell は Sync でないので複数スレッドからは使えない。並行の扱いは Phase 3 前半(Arc/Mutex/atomic)の復習として別の日に

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

  1. 同じ指示を AI に出せば数時間で出るか — 「不具合入りのコードと直し方」自体は出る。ただし「エラーが1個ずつ3回出る」「E0507 の提案どおりに直すと E0277 を経て None で panic する」「S4 が [Locked, Alarm, Alarm]」「失敗回数1から始めたときだけ Err」「clippy はマクロ内の A | _ を黙る」は、実際にビルドとテストを回して得た結果。
  2. AI に指示するだけでは出ない部分 — コンパイラの help: を機械的に当てた場合の経路(3回のビルド)を実出力で残したことと、5つの不具合を「誰が止めるか」(コンパイラ/マクロの deny/仕様テスト/clippy)で仕分けた表、副作用の置き場所の4案の判断記録。
  3. 世界の実物と比べて何割か — Reference の Match expressions のうち、今日扱ったのは「| での複数回評価」「ガード中は共有参照でムーブはガード成功後」の2点(Day 064 の「パターン一致時のみ評価」「if let ガード」と合わせて4点)。状態機械の実物(XState の actions、UML の遷移アクション)とは、案 (c) として名前を挙げただけで、一次資料の照合は 0 件。

➡️ 次のステップ

  • 発展: 案 (c) を実装する — fsm! の行に => Locked / RecordFailure; のようにアクションを書けるようにし、on() が (次の状態, アクション) を返す形にする。ガードから Cell を取り除けるかを S1〜S7 で確かめる
  • 次回予告: マクロ① の残り期間で、実在クレート(rust-fsm・statig・bitflags)のマクロの一次資料を取得して読み、Day 063 の ADR の4基準のどれに当たるかを照合する(概念理解)

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

Part 1 のエラー数の予想と実際

Part 2 で落ちると予想したテスト / 自分の回答

気づき・メモ