Day 066 — 2026-10-06 | マクロ① macro_rules! ⑪ fsm! に「アクションを返す遷移」を実装する:ガードから Cell を外し、判断と実行を分ける(S1〜S10 通過・Vec 版の約10倍速い・E0594 の help: は穴を開け直す提案)

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

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

Day 065 のコードレビューで見つかった不具合 B2(失敗回数を2回数える)の根は、**ガード(判断)の中で Cell を使って文脈を書き換えた(変更した) ことでした。模範解答では「副作用のあるガードは最後のガード行に1つだけ」という約束**で直しましたが、約束はコメントにしか残らず、次に行を足す人が破れます。

今日は Day 065 で採らなかった案 (c) を実装します。遷移関数は判断だけをして、やるべきこと(アクション)を値として返す。実行は呼び出し側が1か所で行います。

役割誰がやるか文脈の受け方
判断(どこへ行くか・何をすべきか)on()(マクロが生成)&Ctx(読むだけ)
実行(失敗回数を増やす・ブザーを鳴らす)fire()(呼び出し側が書く)&mut Ctx

これは状態機械の世界では定番の形です。XState v5 のドキュメントには、純粋な遷移関数についてこうあります(参照日 2026-10-06、https://stately.ai/docs/pure-transitions 、今回取得して照合)。

"Pure transition functions allow you to compute the next state and actions of a state machine without creating a live actor or executing any side effects."

transition() は [nextState, actions] を返し、アクションは実行しません。今日の on() はその Rust 版です。

Rust ならではの論点はアクションの列をどこに置くかです。Vec<Act> で返すと呼ぶたびにヒープ確保が起きます。今日は &'static [Act] で返します。&[Act::RecordFailure, Act::SoundAlarm] のような定数だけでできた式への参照は、コンパイラが静的領域に置いてくれる(定数昇格、constant promotion)からです。The Rust Reference の条件はこうです(参照日 2026-10-06、https://doc.rust-lang.org/reference/destructors.html 、今回取得して照合)。

"the promoted expression can be evaluated at compile-time and the resulting value does not contain interior mutability or destructors"

📝 問題

環境は rustc 1.96.0、cargo new --lib actfsm(edition 2024)。依存クレートはありません。Day 064 の fsm! を出発点にします。

Part 1: マクロを拡張する

fsm! を次の形の入力を受け取れるように拡張してください。

fsm! {
    context c: Ctx;
    state  Door { Locked, Closed, Open, Alarm }
    event  Ev   { Unlock, Lock, Push, Pull, Reset }
    action Act  { RecordFailure, ResetFailures, SoundAlarm }   // 新規: アクションの enum

    (Locked, Unlock) if c.failures >= 3     => Alarm  / [SoundAlarm];          // 新規: / [..] でアクション
    (Locked, Unlock) if c.code_ok()         => Closed / [ResetFailures];
    (Locked, Unlock) if c.failures + 1 >= 3 => Alarm  / [RecordFailure, SoundAlarm];
    (Locked, Unlock)                        => Locked / [RecordFailure];
    (Closed, Lock)                          => Locked;                         // / [..] は省略可
    (Closed, Push)                          => Open;
    (Open, Pull)                            => Closed;
    (Alarm, Reset) if c.is_admin            => Locked / [ResetFailures];
}

要件:

  1. on(self, ev, c: &Ctx) は Result<Step<Self, Act>, Rejected<Self, Ev>> を返す。Step は to(行き先)と actions: &'static [Act](実行順のアクション列)を持つ
  2. / [..] を省略した行は空の列 &[] を返す
  3. Ctx の failures は **Cell ではなく素の u32**。ガードからは書き換えられない
  4. 呼び出し側の fire(s, ev, &mut Ctx) が on() を呼び、返ってきたアクションを Ctx::apply で順に実行する

Part 2: 仕様テストを通す

Day 065 の S1〜S7 を fire() 経由で書き直し、さらに次の3本を足して10本すべて通してください。

#仕様
S8on() は純粋: 全組み合わせ(入力3種 × 失敗回数0〜5 × 管理者2種 × 状態4 × 事象5 = 720 通り)で、2回呼んで結果が同じ、文脈も変わらない
S93回目の誤りでは、アクション列がちょうど [RecordFailure, SoundAlarm](この順)
S10同じ行のアクション列は、呼ぶたびに同じアドレスを指す(ヒープ確保が起きていない証拠)

Part 3: 壊して確かめる(予想してから実行)

次の3つを試す前に、エラー番号と、コンパイラの help: が何を提案するかを予想してください。

  1. 行のアクション名を / [RecordFailur] と打ち間違える
  2. ガードに if { c.failures += 1; c.failures >= 3 } と書く(Day 065 の B2 を Cell 無しで再現しようとする)
  3. アクションに値を持たせて &[Act::Log(n)](n は実行時の値)を &'static [Act] として返す

Part 4: 測る

--release で、今日の &'static [Act] 版と、同じ結果を Vec<Act> に詰め替えて返す版を、1,000万ステップずつ回して比べてください。5回測って中央値を取ります。

💡 ヒント

ヒント1(方向性)
  • 新しい構文は2か所だけです: ヘッダの action $Action:ident { .. } と、各行の末尾の省略可能な / [ .. ]
  • 「省略可能な部分の中の繰り返し」は $( ... )? の中に $( ... ),* を入れる二重の繰り返しになります
  • fire() の中で on(ev, c) の戻り値を持ったまま c.apply()(&mut)を呼べるかどうかは、戻り値が c を借りているかで決まります
ヒント2(アプローチ)
  • $to:ident の後ろに / を置けるか: ident の後ろにはどのトークンを置いてもよい(follow-set の制限が無い)。$g:expr の後ろの => も許されている
  • 展開側では &[ $( $( $Action::$a ),* )? ] と書けば、省略時は &[] になります。$Action は深さ0、$a は深さ3(行・省略可能部分・並びの3段)のメタ変数ですが、浅いメタ変数を深い繰り返しの中で使うのは許されています
  • $Action::$a と完全修飾で書くのは、状態名・事象名・アクション名を同じ use で glob すると名前が衝突しうるからです
  • &[定数, 定数] は定数昇格で &'static になります。だから Step が c を借りず、fire() で c.apply() が通ります
ヒント3(コード骨格)
macro_rules! fsm {
    (
        context $c:ident : $Ctx:ty ;
        state  $State:ident  { $($S:ident),+ $(,)? }
        event  $Event:ident  { $($E:ident),+ $(,)? }
        action $Action:ident { $($A:ident),* $(,)? }
        $( ($s:pat, $e:pat) $(if $g:expr)? => $to:ident $( / [ $($a:ident),* $(,)? ] )? ; )*
    ) => {
        // ... enum 3つ ...
        // match の腕:
        // ($s, $e) $(if $g)? => Ok($crate::Step { to: $to, actions: &[ $( $( $Action::$a ),* )? ] }),
    };
}

pub fn fire(s: Door, ev: Ev, c: &mut Ctx) -> Result<Door, Rejected<Door, Ev>> {
    let step = s.on(ev, c)?;
    for &a in step.actions { c.apply(a); }
    Ok(step.to)
}

✅ 模範解答

src/lib.rs(このまま cargo test で検証できます):

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

/// 遷移1回分の結果: 行き先と、呼び出し側が実行するアクションの列
#[derive(Debug, PartialEq, Eq)]
pub struct Step<S: 'static, A: 'static> {
    pub to: S,
    pub actions: &'static [A],
}

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

        impl $State {
            /// 純粋な遷移関数: 文脈は読むだけ。副作用はアクションとして返す
            #[deny(unused_variables, unreachable_patterns)]
            pub fn on(self, ev: $Event, $c: &$Ctx)
                -> ::core::result::Result<
                       $crate::Step<Self, $Action>,
                       $crate::Rejected<Self, $Event>>
            {
                #[allow(unused_imports)]
                use {$State::*, $Event::*};
                match (self, ev) {
                    $( ($s, $e) $(if $g)? => ::core::result::Result::Ok($crate::Step {
                        to: $to,
                        actions: &[ $( $( $Action::$a ),* )? ],
                    }), )*
                    (state, event) => ::core::result::Result::Err($crate::Rejected { state, event }),
                }
            }
        }
    };
}

pub mod door {
    #[derive(Debug, Clone, PartialEq, Eq)]
    pub struct Ctx {
        pub entered: Option<String>,
        pub expected: String,
        pub failures: u32, // Cell ではない: on() からは書き換えられない
        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, is_admin }
        }
        pub fn code_ok(&self) -> bool {
            self.entered.as_deref() == Some(self.expected.as_str())
        }
        /// アクションを1つ実行する(副作用はここだけ)
        pub fn apply(&mut self, a: Act) {
            match a {
                Act::RecordFailure => self.failures += 1,
                Act::ResetFailures => self.failures = 0,
                Act::SoundAlarm => {} // 実機ならブザー。今日は記録しない
            }
        }
    }

    crate::fsm! {
        context c: Ctx;
        state  Door { Locked, Closed, Open, Alarm }
        event  Ev   { Unlock, Lock, Push, Pull, Reset }
        action Act  { RecordFailure, ResetFailures, SoundAlarm }

        (Locked, Unlock) if c.failures >= 3     => Alarm  / [SoundAlarm];
        (Locked, Unlock) if c.code_ok()         => Closed / [ResetFailures];
        (Locked, Unlock) if c.failures + 1 >= 3 => Alarm  / [RecordFailure, SoundAlarm];
        (Locked, Unlock)                        => Locked / [RecordFailure];
        (Closed, Lock)                          => Locked;
        (Closed, Push)                          => Open;
        (Open, Pull)                            => Closed;
        (Alarm, Reset) if c.is_admin            => Locked / [ResetFailures];
    }

    /// 呼び出し側の1ステップ: 判断(on)と実行(apply)をここで分ける
    pub fn fire(s: Door, ev: Ev, c: &mut Ctx)
        -> Result<Door, crate::Rejected<Door, Ev>>
    {
        let step = s.on(ev, c)?;          // ここでは &Ctx として借りるだけ
        for &a in step.actions {          // actions は &'static なので c の借用は残らない
            c.apply(a);
        }
        Ok(step.to)
    }
}

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

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

    // ---- 今日の追加: S8〜S10 ----
    const STATES: [Door; 4] = [Door::Locked, Door::Closed, Door::Open, Door::Alarm];
    const EVENTS: [Ev; 5] = [Ev::Unlock, Ev::Lock, Ev::Push, Ev::Pull, Ev::Reset];

    fn contexts() -> Vec<Ctx> {
        let mut v = Vec::new();
        for e in [None, Some("1234"), Some("0000")] {
            for f in 0..6 {
                for adm in [false, true] {
                    v.push(Ctx::new(e, f, adm));
                }
            }
        }
        v
    }

    /// S8: on() は純粋(同じ入力で同じ結果、文脈を変えない)を全組み合わせで
    #[test]
    fn s8_on_is_pure() {
        let mut n = 0;
        for c in contexts() {
            for s in STATES {
                for e in EVENTS {
                    let before = c.clone();
                    let r1 = s.on(e, &c);
                    let r2 = s.on(e, &c);
                    assert_eq!(r1, r2);
                    assert_eq!(c, before);
                    n += 1;
                }
            }
        }
        assert_eq!(n, 36 * 4 * 5);
    }

    /// S9: アクションの列が仕様どおり(警報の行は記録→警報の順)
    #[test]
    fn s9_actions_in_order() {
        let c = Ctx::new(Some("0000"), 2, false);
        let st = Door::Locked.on(Ev::Unlock, &c).unwrap();
        assert_eq!(st.to, Door::Alarm);
        assert_eq!(st.actions, &[Act::RecordFailure, Act::SoundAlarm]);
        let c = Ctx::new(Some("0000"), 0, false);
        assert_eq!(Door::Closed.on(Ev::Push, &c).unwrap().actions, &[] as &[Act]);
    }

    /// S10: アクション列は静的領域に置かれ、呼ぶたびに同じアドレス(確保が起きていない)
    #[test]
    fn s10_actions_are_static() {
        let c = Ctx::new(Some("0000"), 0, false);
        let a = Door::Locked.on(Ev::Unlock, &c).unwrap().actions;
        let b = Door::Locked.on(Ev::Unlock, &c).unwrap().actions;
        assert!(std::ptr::eq(a, b));
        println!("size_of Step = {}", std::mem::size_of::<crate::Step<Door, Act>>());
        println!("size_of Result = {}", std::mem::size_of::<Result<crate::Step<Door, Act>, crate::Rejected<Door, Ev>>>());
    }
}

Part 2 の答え: 10本すべて通過(実測、rustc 1.96.0、edition 2024、Apple M1)

running 10 tests
size_of Step = 24
size_of Result = 24
..........
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

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

  • S8 は 720 通りを回して、on() が文脈を一度も変えないことを確かめています。Day 065 の Cell 版では、この性質は型では保証されず、テストで確かめるしかありませんでした。今日の版では Ctx に Cell が無いので、このテストが落ちるコードはそもそもコンパイルできません(Part 3-2 で実測)。S8 は「型が保証していることを念のため確かめる」テストに格下げされました
  • size_of::<Step<Door, Act>>() は 24 バイト(&[Act] が 16 バイト=ポインタ+長さ、Door が 1 バイト、残りは整列の詰め物)。Result<Step, Rejected> も 24 バイトで、Result の判別用の場所が詰め物の中に収まっています(実測。どの位置に置かれたかは未確認)

Part 3 の答え: 3つとも止まる。ただし 2 の help: は採ってはいけない(実測)

#壊し方エラー指した場所help: の提案採ってよいか
1/ [RecordFailur]E0599 no variant ... named RecordFailur found for enum Act呼び出し側の表の行(src/lib.rs:86:62)と、マクロ定義の pub enum $Action の行(28行)の両方there is a variant with a similar name → RecordFailure採ってよい
2ガードで c.failures += 1E0594 cannot assign to c.failures, which is behind a & reference呼び出し側の表の行(src/lib.rs:85:31)**マクロ定義の on() を $c: &mut $Ctx に変えよ**(33行目)採ってはいけない
3&[Act::Log(n)] を &'static で返すE0515 cannot return reference to temporary value配列式全体(提案なし。temporary value created here とだけ)—

2 の出力(抜粋):

error[E0594]: cannot assign to `c.failures`, which is behind a `&` reference --> src/lib.rs:85:31 | 85 | (Locked, Unlock) if { c.failures += 1; c.failures >= 3 } => Alarm / [SoundAlarm]; | ^^^^^^^^^^^^^^^ `c` is a `&` reference, so it cannot be written to | help: consider changing this to be a mutable reference | 33 | pub fn on(self, ev: $Event, $c: &mut $Ctx) | +++

読み取ること:

  • 1 と 2 を同時に入れると、E0599(名前解決)だけが先に出て、E0594(借用検査)は直した後に初めて出ました(実測)。Day 065 の「エラーは段階ごとに1つずつ」と同じです
  • 2 の help: は、今日わざわざ閉じた穴(ガードから文脈を書き換えられる)を、マクロ定義ごと開け直す提案です。コンパイラは「この & は設計上の意図だ」とは知りません。Day 065 の「help: は通すための提案で、仕様を満たすための提案ではない」の、マクロ版です
  • 3 は、なぜ今日のアクションを値を持たない unit variant に限ったかの答えです。Act::Log(3) のような定数なら昇格して通りますが、Act::Log(n) は実行時の値なので静的領域に置けません(同じファイルの konst() は通り、runtime(n) だけが E0515、実測)

3 の検証に使ったファイル(rustc --edition 2024 a.rs):

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Act { RecordFailure, Log(u32) }
pub fn konst() -> &'static [Act] { &[Act::RecordFailure, Act::Log(3)] }
pub fn runtime(n: u32) -> &'static [Act] { &[Act::RecordFailure, Act::Log(n)] }
fn main() { println!("{:?} {:?}", konst(), runtime(1)); }

Part 4 の答え: 静的スライス版が約10倍速い(実測、ただしばらつき大)

examples/bench.rs(cargo run --release --example bench):

use actfsm::door::*;
use std::hint::black_box;
use std::time::Instant;

const N: u32 = 10_000_000;

// 案(c)-静的: on() が &'static [Act] を返す(今日の模範解答)
fn run_static(c: &mut Ctx) -> u64 {
    let mut s = Door::Locked;
    let mut acc = 0u64;
    for i in 0..N {
        let ev = if s == Door::Alarm { Ev::Reset } else { Ev::Unlock };
        c.is_admin = i % 2 == 0;
        let st = black_box(s).on(black_box(ev), c).unwrap_or(actfsm::Step { to: s, actions: &[] });
        for &a in st.actions { c.apply(a); acc += 1; }
        s = st.to;
    }
    acc
}

// 案(c)-Vec: 同じ結果を Vec<Act> に詰め替えて返す(毎回ヒープ確保が起きる形)
fn on_vec(s: Door, ev: Ev, c: &Ctx) -> (Door, Vec<Act>) {
    match s.on(ev, c) { Ok(st) => (st.to, st.actions.to_vec()), Err(_) => (s, Vec::new()) }
}
fn run_vec(c: &mut Ctx) -> u64 {
    let mut s = Door::Locked;
    let mut acc = 0u64;
    for i in 0..N {
        let ev = if s == Door::Alarm { Ev::Reset } else { Ev::Unlock };
        c.is_admin = i % 2 == 0;
        let (to, acts) = on_vec(black_box(s), black_box(ev), c);
        for a in acts { c.apply(a); acc += 1; }
        s = to;
    }
    acc
}

fn main() {
    for name in ["static", "vec"] {
        let mut ts = Vec::new();
        let mut acc = 0;
        for _ in 0..5 {
            let mut c = Ctx::new(Some("0000"), 0, false);
            let t = Instant::now();
            acc = if name == "static" { run_static(&mut c) } else { run_vec(&mut c) };
            ts.push(t.elapsed().as_secs_f64() * 1e3);
        }
        ts.sort_by(|a, b| a.partial_cmp(b).unwrap());
        println!("{name:6} median {:.1} ms (min {:.1} / max {:.1}) actions={acc}", ts[2], ts[0], ts[4]);
    }
}

結果(実測、Apple M1・8GB、他の作業と並行。2回実行):

static median 222.0 ms (min 209.9 / max 348.1) actions=12499999
vec    median 2278.7 ms (min 1638.5 / max 2591.3) actions=12499999
static median 190.6 ms (min 157.4 / max 461.9) actions=12499999
vec    median 3627.5 ms (min 1714.5 / max 4358.0) actions=12499999
版1,000万ステップの中央値1ステップあたり(導出)
&'static [Act]191〜222 ms約 19〜22 ns
Vec<Act>2,279〜3,628 ms約 230〜360 ns
  • 両方とも実行されたアクションは 12,499,999 個で一致(同じ仕事をしている確認)
  • 差は約 10〜16 倍。ただし最大値と最小値の開きが大きく(static で 157〜462 ms)、測定機が静かでないことが分かります。「10倍前後」より細かい数字は主張しません
  • Vec 版の遅さの中身(確保・解放のどちらが重いか、空の Vec::new() は確保しないので Err の回は安い)は分解していません

🪜 Step-by-Step 解説

Step 1: 二重の繰り返しで「省略可能なアクション列」を受ける

$( ($s:pat, $e:pat) $(if $g:expr)? => $to:ident $( / [ $($a:ident),* $(,)? ] )? ; )*
  • 外側 $( ... )* が行の繰り返し(1段目)、$( / [ ... ] )? が省略可能部分(2段目、? も1段に数える)、その中の $($a:ident),* がアクション名の並び(3段目)。展開側でも $a を同じ3段で包む
  • 展開側の &[ $( $( $Action::$a ),* )? ] は、入力と同じ入れ子で $a を包みます。$Action は外側(深さ0)のメタ変数なので、どの深さの中でも使えます
  • $to:ident の直後の / は、ident フラグメントに follow-set の制限が無いので許されます。もし $to:expr にしていたら / は「割り算の続き」と区別できず、マクロ定義の時点で弾かれます(実測: error: `$to:expr` may be followed by `/`, which is not allowed for `expr` fragments、note: allowed there are: =>, , or ;)。**行き先を ident にしたことが、この構文を可能にしている**

Step 2: アクション列を &'static に置く

&[Act::RecordFailure, Act::SoundAlarm] は、すべて定数・Drop 無し・内部可変性無しなので定数昇格し、&'static [Act] になります。だから:

  • on() の戻り値は**引数 c を借りていない**(ライフタイムが 'static)
  • fire() で let step = s.on(ev, c)?; の後、step を持ったまま c.apply(a)(&mut)を呼べる
  • 確保が起きない(S10 で同じアドレスを実測、Part 4 で Vec 版より約10倍速い)

Step 3: Ctx から Cell を外す

Day 065 では、ガードの中で数えるために failures: Cell<u32> が必要でした。今日は数える仕事を RecordFailure アクションに移したので、failures: u32 で足ります。on() は &Ctx しか持たないので、**ガードの中で c.failures += 1 と書けば E0594 で止まる**(Part 3-2)。Day 065 で「最後のガード行に1つだけ」とコメントで頼んでいた約束が、型の制約になりました。

Step 4: 「次に失敗したら何回目か」をガードで読む

3回目の誤りを判定するガードは c.failures + 1 >= 3 です。まだ数えていない(数えるのは後で実行されるアクション)ので、「この誤りを数えたら何回目か」を + 1 で先読みします。これは今日の設計の代償です。判断の時点では、まだ実行されていない副作用の結果を自分で計算する必要があります(下の「迷った他案」の (b) と同じ悩み)。

Step 5: 判断と実行をつなぐのは呼び出し側の数行

let step = s.on(ev, c)?;
for &a in step.actions { c.apply(a); }
Ok(step.to)

ここが副作用の唯一の入口です。ログを取りたい・テストでアクションを記録したい・本番ではブザーを鳴らしたい、といった差し替えはこの数行(と apply)だけを変えれば済み、遷移表には触りません。

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

Rust の & と &mut の区別は、「読むだけの関数」と「書き換える関数」を型で分けるためにあります。Day 065 の PR は Cell で & 越しの書き換えを持ち込み、その区別を自分で外していました。今日の実装は区別を戻し、**判断は &、実行は &mut** という形にそのまま写しています。

さらに、アクションを &'static [Act] で返すことで、「判断の結果が文脈を借りない」こともコンパイラに見える形になりました。これはゼロコスト抽象化の典型例です。判断と実行を分けるという設計上の抽象化を入れても、実行時のコスト(確保)は増えていません(S10・Part 4)。

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

案中身採らなかった理由
(c-1) 今日の模範解答&'static [Act]、アクションは unit variant のみ採用。 確保ゼロ、on() が文脈を借りない、ガードの副作用は型で禁止。代償は「値を持つアクションを書けない」(E0515、Part 3-3)と「ガードで + 1 の先読みが要る」(Step 4)
(c-2) Vec<Act> で返すactions: Vec<Act>、Act::Log(c.failures) のような値も持てる毎回確保が起きる(約10倍遅い、実測)。組み込み・no_std(Phase 4)では alloc が要る。値を持つアクションが必要になったら移る
(c-3) 固定長の配列 [Option<Act>; N] や arrayvec確保なしで値も持てるN(1行あたりの最大アクション数)をマクロが数える必要があり、Day 059 の数え上げの複雑さが戻る。外部クレート依存も増える。未実装
(c-4) コールバック on(ev, &Ctx, &mut impl FnMut(Act))返さずに、その場で呼び出し側の関数に渡す確保なしで値も持てるが、on() の中から外の可変状態に触れる道が復活し、S8(2回呼んで同じ)の意味が薄れる。テストでアクション列を assert_eq! で比べにくい
(b) アクション無し、呼び出し側が結果の状態を見て数えるon() は状態だけ返し、fire() が「Locked→Locked なら失敗」と推測する「失敗とは何か」が表と fire() の2か所に分かれる。Locked→Alarm が「3回目の誤り」なのか「締め出し済み」なのか(記録すべきか否か)を状態だけでは区別できない。今日の S5 と S9 の違いがまさにそれで、(b) では書けない

前提が変わったら: 監査ログのように実行時の値を持つアクション(誰が・何番を入力したか)が必要になったら (c-2) に移ります。1行のアクション数が小さく固定で、確保を避けたい組み込み向けなら (c-3)。逆に、アクションが不要な表(今日の Closed・Open の行のように行き先だけ)しか無いなら、Day 064 の形のままで十分です。

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

エラー出た原因読み方正しい直し方
E0599 no variant ... named RecordFailur found for enum Act行のアクション名の打ち間違い2か所を指す: 表の行(打ち間違えた場所)とマクロ定義の pub enum $Action(enum が作られた場所)。直すのは表の行help: どおり RecordFailure
E0594 cannot assign to c.failures, which is behind a & referenceガードで文脈を書き換えようとした「& しか持っていないので書けない」。今日の設計どおりに止まっている書き換えをアクションに移す(/ [RecordFailure])。**help: の &mut 化は採らない**
E0515 cannot return reference to temporary value実行時の値を含む配列を &'static で返したtemporary value created here が配列式全体を指す。定数昇格の条件を満たしていないアクションを unit variant に限るか、(c-2) の Vec に移る
`$to:expr` may be followed by `/`, which is not allowed for `expr` fragmentsマクロ定義で行き先を $to:expr にした呼び出す前、定義の時点で出る。note: が後ろに置けるトークン(=> , ;)を列挙する行き先は $to:ident
no rules expected `/` / note: while trying to match `=>`表の行で (Closed, Push) / [SoundAlarm] => Open; と / を前に置いたnote: がマクロ定義側の「ここでは => を待っていた」位置を指す/ [..] は行き先の後ろ、; の直前

最後の2行も今日実行して出力を確かめました(rustc 1.96.0)。

🔀 他言語との比較

観点Rust(今日の fsm!)TypeScript(XState v5)GoJava
判断と実行の分離on() が Step { to, actions } を返し、fire() が実行transition() が [nextState, actions] を返し、実行しない(一次資料で確認)慣習で func (s State) On(ev, ctx) (State, []Action) と書く同様に record で返す
判断が文脈を書き換えないことの保証&Ctx で型が保証(E0594、実測)assign は action object を返す宣言的 API だが、ガード関数の中で context を書き換えるのは言語では止まらないポインタで渡せば書き換え可能。値渡しなら呼び出し側には影響しない(コピーのコスト)final はフィールドの再代入だけを止める。中身の可変性は止まらない
アクション列の確保定数昇格で 0 回(S10 実測)配列オブジェクトを毎回生成(GC)スライスリテラルはエスケープ解析次第で確保されうるList.of(...) は毎回生成(定数にすれば共有可)

TypeScript の「ガードの中の書き換えは止まらない」、Go・Java の列は今日は実行していません(言語仕様の記憶に基づく要旨)。XState の行は、上の一次資料で確認した範囲です。

🏭 実務での使いどころ

  • 決済・認証の状態機械: 「ロックアウトの判定」と「失敗回数の永続化(DB 書き込み)」を分けると、判定ロジックを DB 無しでテストできます(S8 のように全組み合わせを回せる)。今日の apply を「DB に書く」に差し替えるだけです
  • Elm アーキテクチャ/Redux の reducer と同じ形: update(msg, model) -> (model, Cmd) の Cmd が今日のアクション列です。UI 以外でも、イベントソーシングやワークフローエンジン(Temporal のワークフロー関数は決定的でなければならない、という制約)と同じ考え方です
  • リプレイとテスト: アクションが値なので、本番のイベント列をテストで再生し、「どのアクションが出たか」を assert_eq! で比べられます(S9)
  • 組み込み: &'static [Act] は確保が無いので、no_std の環境(Phase 4 予定)でもそのまま使えます

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「&[...] を返すと、ローカル変数への参照になってダングリングする」C++ の感覚で、関数内の配列は関数を抜けたら消えると思う定数だけの式なら定数昇格で 'static になる(S10 で同じアドレスを実測)。実行時の値を含むと E0515
「コンパイラの help: に従えば設計どおりになる」提案を正解だと思うE0594 の提案はマクロを &mut にせよというもので、設計の目的そのものを壊す(実測)
「アクションに分ければ、ガードは何も気にしなくてよい」副作用が消えたので安心するまだ実行されていない副作用の結果を + 1 で先読みする必要がある。先読みを間違えると S4 が落ちる
「Vec でも大差ない」確保は速いと思う今日の条件で約10倍(実測、ばらつき大)。1ステップの仕事が小さいほど確保の比率が上がる
「状態を見れば、何が起きたか分かる」(案 (b))状態だけ返せば十分だと思うLocked→Alarm には「3回目の誤り(数える)」と「締め出し済み(数えない)」の2種類がある。アクションが無いと区別できない

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

  • The Rust Reference, Destructors — Constant promotion: https://doc.rust-lang.org/reference/destructors.html
    • "the promoted expression can be evaluated at compile-time and the resulting value does not contain interior mutability or destructors"
    • Part 3-3 の konst()(通る)と runtime(n)(E0515)の対比は、この条件を手元で確かめたもの
  • XState v5, Pure transition functions: https://stately.ai/docs/pure-transitions
    • "Pure transition functions allow you to compute the next state and actions of a state machine without creating a live actor or executing any side effects."
    • transition() は [nextState, actions] を返し、アクションは実行しない(同ページ)
  • XState v5, Actions: https://stately.ai/docs/actions
    • "Actions are fire-and-forget effects."

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

扱わなかったこと理由・次の予定
値を持つアクション(Log(u32))を Vec 版・arrayvec 版で実装して比べる今日は unit variant に絞った(E0515 で理由を実測)。必要になる題材が出たら
Part 4 のばらつきの原因・Vec 版の確保と解放の内訳M1 8GB で他の作業と並行して測った。静かな機械(Mac mini)で取り直す余地あり
Result<Step, Rejected> が 24 バイトに収まる位置(判別値の置き場所)大きさだけ実測。レイアウトは未確認
エントリー/エグジットアクション(状態に入る・出るときのアクション)XState・UML にはあるが、今日は遷移アクションのみ
実在クレート(rust-fsm・statig)がアクション/出力をどう返すかの照合次の概念理解の日に一次資料を取得して読む
Day 065 からの持ち越し(as_mut() 提案、clippy がマクロ内で黙る理由、並行アクセス)今日は案 (c) の実装に集中

3 問の自己点検

  1. 同じ指示を AI に出せば数時間で出るか — 「アクションを返す状態機械マクロ」自体は出る。ただし、E0594 の help: がマクロ定義を &mut に変えよと提案すること(設計を壊す提案)、E0599 が表の行とマクロ定義の両方を指すこと、2つ同時に入れると名前解決のエラーしか出ないこと、静的スライス版が Vec 版の約10倍速いこと(ばらつき込み)は、実際にビルド・計測して得た結果
  2. AI に指示するだけでは出ない部分 — Day 065 の不具合 B2 から出発し、「約束(コメント)→ 型の制約(&Ctx+アクション)」へ移した判断の筋道と、5案(c-1〜c-4・b)の採否、特に (b) が S5 と S9 を区別できないという具体的な却下理由
  3. 世界の実物と比べて何割か — XState のアクションの概念のうち、扱ったのは「遷移アクション」と「純粋な遷移関数が actions を返す」の2点。エントリー/エグジットアクション、assign(文脈の更新をアクションとして書く)、遅延アクションなどは扱っていない。UML ステートマシンの遷移アクション・entry/exit/do のうち扱ったのは遷移アクションの1つ

➡️ 次のステップ

  • 発展: アクションに値を持たせたい場合(監査ログに入力番号を残す)を、(c-2) Vec と (c-4) コールバックの両方で実装し、S8〜S10 のどれが成り立たなくなるかを確かめる
  • 次回予告: マクロ① の残り期間で、実在クレート(rust-fsm・statig・bitflags)のマクロの一次資料を取得して読み、Day 063 の ADR の4基準と、今日の「アクションを返すか」の観点で照合する(概念理解)

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

Part 3 の予想(エラー番号と help: の中身)と実際

自分の回答

気づき・メモ