Day 068 — 2026-10-08 | マクロ① macro_rules! ⑬ 値を持つアクション(監査ログ)をどこに置くか:遅延束縛・Vec・SmallVec・コールバックの4案を S8〜S12 で比べ、ADR-068 を書く(Vec は 1,000万ステップで確保 9,999,999 回・SmallVec とコールバックは 0 回/コールバックで文脈を直接書き換えると E0500)

2026-10-08 🟣 上級者 / Phase 3 設計・アーキテクチャ マクロ①(宣言的マクロ macro_rules!)
フェーズ: 上級者 (Phase 3) | 問題タイプ: 設計・アーキテクチャ | テーマ: マクロ①(宣言的マクロ macro_rules!)

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

Day 066 の fsm! は、アクションを &'static [Act] で返しました。確保ゼロで、on() は文脈を借りません。ただし代償として、アクションは値を持てない(Act::Log(n) を返すと E0515)という制限を残しました。Day 067 の ADR-067 も「値を持つアクション(監査ログ)が要るなら、どれを選んでも Vec かコールバックが要る」と書いて止まっています。

今日はその「要る」が来た日です。扉の管理部門から次の要件が届きました。

監査要件 A-1: 解錠の試行ごとに、Audit(n, ok) を1件残すこと。n は「この試行を数えた後の連続失敗回数」(成功なら 0)、ok は成功か否か。値は判断した時点のものでなければならない(後から別のアクションが文脈を変えても、記録された値は変わらない)。

これは扉の業界では珍しい要件ではありません。入退室管理(PACS)では「誰が・いつ・どの扉で・成功/失敗」を残すのが基本機能で、失敗の連続回数はロックアウトの根拠として後から説明を求められます(ここは業界の一般的な慣行の要旨で、特定の規格の条文は今日は引いていません)。

設計の論点は1つです。**「値」を持つアクションを、どの入れ物で判断(on)から実行(apply)へ渡すか。** 入れ物によって、Day 066 で手に入れた3つの性質のどれが残るかが変わります。

性質Day 066 の仕様テスト中身
純粋さS8判断は 720 通りで「2回呼んで同じ結果」「文脈を変えない」
順序の検査S93回目の誤りでアクション列が仕様どおりの順
確保ゼロS10呼ぶたびにヒープ確保が起きない(Day 066 では「同じアドレス」で確かめた)

もう1つ、扉の制御はマイコン(組み込み)で動くことが多い、という前提も効きます。ヒープを持たない no_std 環境では Vec はそもそも使えません(alloc クレートが要る)。

📝 問題

環境は rustc 1.96.0、cargo new --lib valact(edition 2024)。Day 066 の fsm! を出発点にします。比べる案は4つです。

案中身
(c-1') 遅延束縛アクションは値を持たない unit variant のまま(AuditFailures)。apply が実行する時点の文脈から値を読む
(c-2) Vecon() が Vec<Act> を返す。Act::Audit(u32, bool) を持てる
(c-3) SmallVecsmallvec::SmallVec<[Act; 3]> で返す。3個までは構造体の中に置き、超えたらヒープへ
(c-4) コールバックon_emit(self, ev, &Ctx, emit: &mut impl FnMut(Act))。返さずに、その場で emit に渡す

Part 1: 予想する(表を埋める)

実装する前に、各案について次を ○/×/条件付き で予想し、理由を1行で書いてください。

案S8 純粋さS9 順序の検査(assert_eq! で列を比べる)S10 確保ゼロ要件 A-1「判断した時点の値」
(c-1') 遅延束縛
(c-2) Vec
(c-3) SmallVec
(c-4) コールバック

Part 2: マクロを1つにまとめる

4案のためにマクロを4つ書くのは避けたい。どれか1つの形をマクロが生成し、残りはその上に数行で作るとします。

  1. どの形を「土台」にすべきか。土台から他の案を作れるか、逆は作れるか、を理由に答えてください
  2. 土台の形で、表の行に Audit(c.failures + 1, false) のような引数つきのアクションを書けるように、fsm! の入力規則を書いてください。$Action::$a と完全修飾で書く Day 066 の方針は保ちます

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

次の2つを試す前に、エラー番号と、エラーがどこ(表の行か、マクロ定義か)を指すかを予想してください。

  1. (c-4) で、emit の中で RecordFailure を**同じ c にすぐ適用**する: s.on_emit(ev, c, &mut |a| c.apply(a, log))
  2. 表の行で引数の数を間違える: Audit(c.failures + 1)(bool を忘れた)

Part 4: 測る

--release で、(c-2)・(c-3)・(c-4) を 1,000万ステップずつ回し、時間(5回の中央値)と確保回数(#[global_allocator] で数える)を比べてください。

Part 5: 判断を記録する

ADR-068「値を持つアクションの入れ物」を 10 行以内で書いてください。採らなかった案の却下理由と、前提が変わったら判断がどう変わるかを必ず含めること。

💡 ヒント

ヒント1(方向性)
  • 「土台」は、他の形を全部その上に作れる、最も情報を落とさない形です。Vec から「その場で流す」は作れますが、確保はもう起きた後です
  • 遅延束縛は、apply が文脈を読むのでアクションの実行順に依存します。表の行で [RecordFailure, AuditFailures] と [AuditFailures, RecordFailure] を並べたら何が残るか考えてください
  • コールバックの中で c を書き換えるには &mut c が要ります。そのとき on_emit はまだ &c を持っています
ヒント2(アプローチ)
  • 引数つきのアクションは $a:ident $( ( $($arg:expr),* ) )? で受け、$Action::$a $( ( $($arg),* ) )? で展開します。入れ子は「行・省略可能な / [..]・並び・省略可能な (..)・引数の並び」の5段です
  • ヘッダ側も、variant の型を $A:ident $( ( $($T:ty),* ) )? で受けます。ty の後ろには , を置けます(follow-set)
  • 確保回数は GlobalAlloc を実装した構造体で alloc を数えます。テストは並列に走るので、テストでは thread_local! の Cell に数えると他のテストの確保が混ざりません
  • SmallVec<[Act; 3]> の 3 は「1行の最大アクション数」。今日の表では [RecordFailure, Audit(..), SoundAlarm] の3個が最大です
ヒント3(コード骨格)
// 土台: コールバック
pub fn on_emit(self, ev: $Event, $c: &$Ctx, emit: &mut impl FnMut($Action))
    -> Result<Self, Rejected<Self, $Event>>
{
    match (self, ev) {
        $( ($s, $e) $(if $g)? => {
            $( $( emit($Action::$a $( ( $($arg),* ) )?); )* )?
            Ok($to)
        } )*
        (state, event) => Err(Rejected { state, event }),
    }
}

// その上に Vec 版(SmallVec 版も同じ3行)
pub fn on_vec(s: Door, ev: Ev, c: &Ctx) -> Result<(Door, Vec<Act>), Rej> {
    let mut v = Vec::new();
    let to = s.on_emit(ev, c, &mut |a| v.push(a))?;
    Ok((to, v))
}

✅ 模範解答

Cargo.toml の依存(ローカルのレジストリにある版をそのまま使い、--offline でビルド):

[dependencies]
smallvec = "=1.16.1"

src/lib.rs(cargo test --offline -- --nocapture --test-threads=1 で検証できます):

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

/// 値を持てるアクションの fsm!。判断(on_emit)はアクションを emit に渡すだけで、実行しない
#[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 $( ( $($T:ty),* ) )? ),* $(,)? }
        $( ($s:pat, $e:pat) $(if $g:expr)? => $to:ident
           $( / [ $( $a:ident $( ( $($arg:expr),* ) )? ),* $(,)? ] )? ; )*
    ) => {
        #[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 $( ( $($T),* ) )? ),* }

        impl $State {
            /// 純粋な判断。文脈は &$Ctx で読むだけ。アクションは引数の値をこの時点で評価して emit に渡す
            #[deny(unused_variables, unreachable_patterns)]
            pub fn on_emit(self, ev: $Event, $c: &$Ctx, emit: &mut impl FnMut($Action))
                -> ::core::result::Result<Self, $crate::Rejected<Self, $Event>>
            {
                #[allow(unused_imports)]
                use {$State::*, $Event::*};
                match (self, ev) {
                    $( ($s, $e) $(if $g)? => {
                        $( $( emit($Action::$a $( ( $($arg),* ) )?); )* )?
                        ::core::result::Result::Ok($to)
                    } )*
                    (state, event) => ::core::result::Result::Err($crate::Rejected { state, event }),
                }
            }
        }
    };
}

pub mod door {
    use smallvec::SmallVec;

    #[derive(Debug, Clone, PartialEq, Eq)]
    pub struct Ctx {
        pub entered: Option<String>,
        pub expected: String,
        pub failures: 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, is_admin }
        }
        pub fn code_ok(&self) -> bool {
            self.entered.as_deref() == Some(self.expected.as_str())
        }
        /// 文脈を書き換えるアクションだけを実行する。Audit は文脈の外(監査ログ)へ
        pub fn apply(&mut self, a: Act, log: &mut Vec<(u32, bool)>) {
            match a {
                Act::RecordFailure => self.failures += 1,
                Act::ResetFailures => self.failures = 0,
                Act::SoundAlarm => {}
                Act::Audit(n, ok) => log.push((n, ok)),
            }
        }
    }

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

        // Audit(n, ok): n = この試行を数えた後の連続失敗回数(判断の時点の値で固定する)
        (Locked, Unlock) if c.failures >= 3     => Alarm  / [SoundAlarm, Audit(c.failures, false)];
        (Locked, Unlock) if c.code_ok()         => Closed / [ResetFailures, Audit(0, true)];
        (Locked, Unlock) if c.failures + 1 >= 3 => Alarm  / [RecordFailure, Audit(c.failures + 1, false), SoundAlarm];
        (Locked, Unlock)                        => Locked / [RecordFailure, Audit(c.failures + 1, false)];
        (Closed, Lock)                          => Locked;
        (Closed, Push)                          => Open;
        (Open, Pull)                            => Closed;
        (Alarm, Reset) if c.is_admin            => Locked / [ResetFailures];
    }

    pub type Rej = crate::Rejected<Door, Ev>;

    /// (c-2) Vec に集めて返す
    pub fn on_vec(s: Door, ev: Ev, c: &Ctx) -> Result<(Door, Vec<Act>), Rej> {
        let mut v = Vec::new();
        let to = s.on_emit(ev, c, &mut |a| v.push(a))?;
        Ok((to, v))
    }
    /// (c-3) SmallVec<[Act; 3]> に集めて返す(1行の最大アクション数 3 までは確保しない)
    pub fn on_small(s: Door, ev: Ev, c: &Ctx) -> Result<(Door, SmallVec<[Act; 3]>), Rej> {
        let mut v = SmallVec::new();
        let to = s.on_emit(ev, c, &mut |a| v.push(a))?;
        Ok((to, v))
    }
    /// 呼び出し側の1ステップ(SmallVec 版で集めてから実行)
    pub fn fire(s: Door, ev: Ev, c: &mut Ctx, log: &mut Vec<(u32, bool)>) -> Result<Door, Rej> {
        let (to, acts) = on_small(s, ev, c)?;
        for a in acts { c.apply(a, log); }
        Ok(to)
    }
    /// (c-4) コールバック: Ctx を書き換えるアクションは同じ Ctx に今は適用できない(on_emit が &Ctx を借りている)。
    /// 文脈の外へ出すもの(Audit)だけをその場で流し、文脈を変えるものは後で適用する
    pub fn fire_cb(s: Door, ev: Ev, c: &mut Ctx, log: &mut Vec<(u32, bool)>) -> Result<Door, Rej> {
        let mut pending: SmallVec<[Act; 3]> = SmallVec::new();
        let to = s.on_emit(ev, c, &mut |a| match a {
            Act::Audit(n, ok) => log.push((n, ok)),
            other => pending.push(other),
        })?;
        for a in pending { c.apply(a, log); }
        Ok(to)
    }
}

/// (c-1') 値を持たせず、実行時に文脈から読む(遅延束縛)。Day 066 と同じ unit variant だけ
pub mod late {
    #[derive(Debug, Clone, PartialEq, Eq)]
    pub struct Ctx { pub ok: bool, pub failures: u32, pub log: Vec<u32> }
    impl Ctx {
        pub fn apply(&mut self, a: Act) {
            match a {
                Act::RecordFailure => self.failures += 1,
                Act::AuditFailures => self.log.push(self.failures), // 実行した時点の値を読む
            }
        }
    }
    crate::fsm! {
        context c: Ctx;
        state  St { Locked, Open }
        event  Ev { TryA, TryB }
        action Act { RecordFailure, AuditFailures }
        (Locked, TryA) if c.ok => Open;
        (Locked, TryA) => Locked / [RecordFailure, AuditFailures];  // 数えてから記録
        (Locked, TryB) => Locked / [AuditFailures, RecordFailure];  // 記録してから数える
    }
    pub fn fire(s: St, ev: Ev, c: &mut Ctx) -> St {
        let mut v: Vec<Act> = Vec::new();
        let to = s.on_emit(ev, c, &mut |a| v.push(a)).unwrap_or(s);
        for a in v { c.apply(a); }
        to
    }
}

#[cfg(test)]
mod alloc_count {
    use std::alloc::{GlobalAlloc, Layout, System};
    use std::cell::Cell;
    thread_local! { pub static N: Cell<usize> = const { Cell::new(0) }; }
    pub struct Counting;
    unsafe impl GlobalAlloc for Counting {
        unsafe fn alloc(&self, l: Layout) -> *mut u8 {
            let _ = N.try_with(|n| n.set(n.get() + 1));
            unsafe { System.alloc(l) }
        }
        unsafe fn dealloc(&self, p: *mut u8, l: Layout) { unsafe { System.dealloc(p, l) } }
    }
    #[global_allocator]
    static A: Counting = Counting;
    pub fn count<R>(f: impl FnOnce() -> R) -> (R, usize) {
        let b = N.with(|n| n.get());
        let r = f();
        (r, N.with(|n| n.get()) - b)
    }
}

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

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

    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: 判断は純粋(720 通り、Vec 版と SmallVec 版で同じ結果、文脈は不変)
    #[test]
    fn s8_pure_and_same_across_sinks() {
        let mut n = 0;
        for c in contexts() {
            for s in STATES {
                for e in EVENTS {
                    let before = c.clone();
                    let a = on_vec(s, e, &c);
                    let b = on_vec(s, e, &c);
                    let sm = on_small(s, e, &c).map(|(t, v)| (t, v.to_vec()));
                    assert_eq!(a, b);
                    assert_eq!(a, sm);
                    assert_eq!(c, before);
                    n += 1;
                }
            }
        }
        assert_eq!(n, 720);
    }

    /// S9: 3回目の誤りの列(順序込み)
    #[test]
    fn s9_actions_in_order() {
        let c = Ctx::new(Some("0000"), 2, false);
        let (to, v) = on_vec(Door::Locked, Ev::Unlock, &c).unwrap();
        assert_eq!(to, Door::Alarm);
        assert_eq!(v, vec![Act::RecordFailure, Act::Audit(3, false), Act::SoundAlarm]);
    }

    /// S10': 「同じアドレス」はもう言えない。代わりに確保回数を数える
    #[test]
    fn s10_allocations_per_call() {
        let c = Ctx::new(Some("0000"), 2, false);
        let (_, nv) = count(|| on_vec(Door::Locked, Ev::Unlock, &c).unwrap());
        let (_, ns) = count(|| on_small(Door::Locked, Ev::Unlock, &c).unwrap());
        let (_, ne) = count(|| on_vec(Door::Closed, Ev::Push, &c).unwrap());
        println!("allocs: vec(3 actions)={nv} small(3 actions)={ns} vec(0 actions)={ne}");
        println!("size_of Act={} SmallVec<[Act;3]>={} Vec<Act>={}",
            std::mem::size_of::<Act>(), std::mem::size_of::<smallvec::SmallVec<[Act; 3]>>(),
            std::mem::size_of::<Vec<Act>>());
        assert_eq!(ns, 0);
        assert!(nv >= 1);
        assert_eq!(ne, 0);
    }

    /// コールバック版と SmallVec 版は同じ結果(文脈と監査ログ)
    #[test]
    fn s11_callback_matches_collect() {
        for c0 in contexts() {
            for s in STATES {
                for e in EVENTS {
                    let (mut c1, mut l1) = (c0.clone(), vec![]);
                    let (mut c2, mut l2) = (c0.clone(), vec![]);
                    assert_eq!(fire(s, e, &mut c1, &mut l1), fire_cb(s, e, &mut c2, &mut l2));
                    assert_eq!((c1, l1), (c2, l2));
                }
            }
        }
    }

    /// S12: 遅延束縛 (c-1') では、表の中のアクションの順序が記録される値を変える
    #[test]
    fn s12_late_binding_is_order_sensitive() {
        use super::late::*;
        let mut a = Ctx { ok: false, failures: 0, log: vec![] };
        fire(St::Locked, Ev::TryA, &mut a);
        let mut b = Ctx { ok: false, failures: 0, log: vec![] };
        fire(St::Locked, Ev::TryB, &mut b);
        println!("late: TryA(数えて記録) log={:?}  TryB(記録して数える) log={:?}", a.log, b.log);
        assert_eq!(a.log, vec![1]);
        assert_eq!(b.log, vec![0]);
    }
}

テスト結果(実測、rustc 1.96.0、edition 2024、Apple M1・8GB)

running 9 tests
test tests::s10_allocations_per_call ... allocs: vec(3 actions)=1 small(3 actions)=0 vec(0 actions)=0
size_of Act=8 SmallVec<[Act;3]>=40 Vec<Act>=24
ok
test tests::s11_callback_matches_collect ... ok
test tests::s12_late_binding_is_order_sensitive ... late: TryA(数えて記録) log=[1]  TryB(記録して数える) log=[0]
ok
test tests::s1_correct_code_opens ... ok
test tests::s4_third_failure_alarms_and_audits ... ok
test tests::s5_lockout_beats_correct_code ... ok
test tests::s7_only_admin_resets ... ok
test tests::s8_pure_and_same_across_sinks ... ok
test tests::s9_actions_in_order ... ok

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

cargo clippy --offline --all-targets は src/lib.rs(本体とテスト)で警告 0 件(実測。最初は S8 の中の .map(|(t, v)| (t, v)) に map_identity が1件出たので消した)。Part 4 の examples/bench.rs には2件残しています(i % 2 == 0 への manual_is_multiple_of と、関数ポインタの配列への type_complexity)。計測の条件を Day 066 と揃えるため、計測コードは直していません。

Part 1 の答え

案S8 純粋さS9 順序の検査S10 確保ゼロA-1「判断した時点の値」
(c-1') 遅延束縛○(&Ctx のまま)○(列は &'static)○(Day 066 と同じ)× 値は実行した時点。表の中のアクションの順序で値が変わる(S12: [1] と [0]、実測)
(c-2) Vec○○× 3アクションの行で1回確保(S10 実測)。1,000万ステップで 9,999,999 回(Part 4)○
(c-3) SmallVec○○(to_vec() で比べる)条件付き 3個以下なら 0 回(実測)。4個目で "spill"(ヒープへ移る、smallvec のドキュメント)○
(c-4) コールバック○(on_emit は &Ctx)条件付き(集める関数を通せば比べられる。S8・S9 は Vec 版を通して書いた)○ 0 回(Part 4 実測)○ ただし文脈を変えるアクションをその場で適用できない(Part 3-1、E0500)

読み取ること:

  • A-1 を満たさないのは (c-1') だけ。 しかもこれはコンパイラもテストも止めません。表の行で RecordFailure と AuditFailures を入れ替えると、監査ログの値が1つずれる。S12 は「ずれる」ことをわざわざ確かめるテストで、普通のテストは書かれません。監査ログが1ずれる不具合は、障害が起きて監査ログを読み返すまで誰も気づかない種類の不具合です
  • 値を引数として判断の時点で評価する (c-2)〜(c-4) は、Audit(c.failures + 1, false) の c.failures を on_emit の中で読み、その値を Act の中にコピーします。後で RecordFailure が文脈を変えても、Act の中の値は変わりません。これが「スナップショット」です

Part 2 の答え: 土台はコールバック

  1. コールバック (c-4) を土台にする。 on_emit から Vec・SmallVec は3行で作れます(模範解答の on_vec・on_small)。逆に、Vec を土台にすると、SmallVec 版もコールバック版も「確保が起きた後」にしか作れません。&'static [Act](Day 066)は値を持てないので土台になれません。情報も性能も落とさずに他を作れる形が、土台の条件です
  2. 入力規則(模範解答の該当部分):
action $Action:ident { $( $A:ident $( ( $($T:ty),* ) )? ),* $(,)? }
$( ($s:pat, $e:pat) $(if $g:expr)? => $to:ident
   $( / [ $( $a:ident $( ( $($arg:expr),* ) )? ),* $(,)? ] )? ; )*

$arg は5段の入れ子(行 → / [..] の有無 → 並び → (..) の有無 → 引数の並び)。展開側も同じ5段で包みます。$arg:expr の後ろは , か ) なので follow-set の制限に触れません(expr の後ろに置けるのは => , ;。) は区切りの閉じなので問題にならない)。

Part 3 の答え(実測)

#壊し方エラー指した場所読み取ること
1emit の中で同じ c に適用**E0500 closure requires unique access to *c but it is already borrowed**呼び出し側の fire_cb の行(on_emit の c とクロージャの両方に下線)予想しがちな E0502 ではなく E0500(クロージャが &mut を捕まえる専用の番号)
2Audit(c.failures + 1)**E0061 this enum variant takes 2 arguments but 1 argument was supplied**マクロ定義の展開の行(35 行目)。表の行ではなく、in this macro invocation がマクロ呼び出し全体(73〜88 行)を囲むhelp: がマクロ定義の行を書き換える提案を出す

1 の出力:

error[E0500]: closure requires unique access to `*c` but it is already borrowed --> src/lib.rs:113:40 | 113 | let to = s.on_emit(ev, c, &mut |a| c.apply(a, log))?; | ------- - ^^^ - second borrow occurs due to use of `*c` in closure | | | | | | | closure construction occurs here | | borrow occurs here | first borrow later used by call

2 の出力(抜粋):

error[E0061]: this enum variant takes 2 arguments but 1 argument was supplied --> src/lib.rs:35:36 | 35 | $( $( emit($Action::$a $( ( $($arg),* ) )?); )* )? | ^^^^^^^^^^^----------------- argument #2 of type `bool` is missing ... 73 | / crate::fsm! { ... | 88 | | } | |_____- in this macro invocation | note: tuple variant defined here --> src/lib.rs:77:65 | 77 | action Act { RecordFailure, ResetFailures, SoundAlarm, Audit(u32, bool) } | ^^^^^ help: provide the argument | 35 - $( $( emit($Action::$a $( ( $($arg),* ) )?); )* )? 35 + $( $( emit($Action::$a(c.failures + 1, /* bool */) )?); )* )?

読み取ること:

  • 1 は今日の設計の芯です。 on_emit が &Ctx を持っている間は、emit の中で &mut Ctx を作れない。だから (c-4) では、**文脈の外へ出すもの(Audit → 監査ログ)はその場で流し、文脈を変えるもの(RecordFailure など)はいったん溜めて後で適用する**、という二手に分ける必要があります(模範解答の fire_cb)。「判断の最中に文脈を変えない」という Day 066 の性質を、借用検査がコールバック経由でも守っている、と読めます。c を RefCell にすれば通りますが、それは Day 065 の Cell の穴を開け直すことです
  • 2 は今日の設計の代償です。 Day 066 の E0599(アクション名の打ち間違い)は表の行を指しましたが、引数の数の誤りは型検査の段階で見つかるため、エラーはマクロ定義の展開の行を指し、表のどの行が悪いかは help: の中の c.failures + 1 から推測するしかありません。表が 50 行になったら探すのは手間です。しかも help: の提案はマクロ定義を書き換えるもので、そのまま適用してはいけません(Day 066 の E0594 と同じ型の罠)

Part 4 の答え(実測、ただし測定機はかなり騒がしい)

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

use std::alloc::{GlobalAlloc, Layout, System};
use std::hint::black_box;
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
use std::time::Instant;
use valact::door::*;

static ALLOCS: AtomicUsize = AtomicUsize::new(0);
struct Counting;
unsafe impl GlobalAlloc for Counting {
    unsafe fn alloc(&self, l: Layout) -> *mut u8 { ALLOCS.fetch_add(1, Relaxed); unsafe { System.alloc(l) } }
    unsafe fn dealloc(&self, p: *mut u8, l: Layout) { unsafe { System.dealloc(p, l) } }
}
#[global_allocator]
static A: Counting = Counting;

const N: u32 = 10_000_000;

// 監査ログは件数と合計だけ持つ(ログ自体の確保を計測から外すため)
#[derive(Default)]
struct Sink { n: u64, sum: u64 }

fn step_common(c: &mut Ctx, i: u32, s: Door) -> Ev {
    c.is_admin = i % 2 == 0;
    if s == Door::Alarm { Ev::Reset } else { Ev::Unlock }
}
fn apply(c: &mut Ctx, a: Act, k: &mut Sink) {
    match a {
        Act::RecordFailure => c.failures += 1,
        Act::ResetFailures => c.failures = 0,
        Act::SoundAlarm => {}
        Act::Audit(n, ok) => { k.n += 1; k.sum += n as u64 + ok as u64; }
    }
}

fn run_vec(c: &mut Ctx, k: &mut Sink) {
    let mut s = Door::Locked;
    for i in 0..N {
        let ev = step_common(c, i, s);
        let (to, acts) = on_vec(black_box(s), black_box(ev), c).unwrap_or((s, Vec::new()));
        for a in acts { apply(c, a, k); }
        s = to;
    }
}
fn run_small(c: &mut Ctx, k: &mut Sink) {
    let mut s = Door::Locked;
    for i in 0..N {
        let ev = step_common(c, i, s);
        let (to, acts) = on_small(black_box(s), black_box(ev), c).unwrap_or((s, Default::default()));
        for a in acts { apply(c, a, k); }
        s = to;
    }
}
fn run_cb(c: &mut Ctx, k: &mut Sink) {
    let mut s = Door::Locked;
    for i in 0..N {
        let ev = step_common(c, i, s);
        let mut pend: [Option<Act>; 3] = [None; 3];
        let mut m = 0;
        let to = black_box(s).on_emit(black_box(ev), c, &mut |a| match a {
            Act::Audit(n, ok) => { k.n += 1; k.sum += n as u64 + ok as u64; }
            other => { pend[m] = Some(other); m += 1; }
        }).unwrap_or(s);
        for a in pend.into_iter().flatten() { apply(c, a, k); }
        s = to;
    }
}

fn main() {
    let runs: [(&str, fn(&mut Ctx, &mut Sink)); 3] = [("vec", run_vec), ("smallvec", run_small), ("callback", run_cb)];
    for (name, f) in runs {
        let mut ts = Vec::new();
        let (mut audits, mut allocs) = (0, 0);
        for _ in 0..5 {
            let mut c = Ctx::new(Some("0000"), 0, false);
            let mut k = Sink::default();
            let a0 = ALLOCS.load(Relaxed);
            let t = Instant::now();
            f(&mut c, &mut k);
            let el = t.elapsed().as_secs_f64() * 1e3;
            allocs = ALLOCS.load(Relaxed) - a0;
            ts.push(el);
            audits = k.n;
            black_box(k.sum);
        }
        ts.sort_by(|a, b| a.partial_cmp(b).unwrap());
        println!("{name:9} median {:7.1} ms (min {:7.1} / max {:7.1}) audits={audits} allocs/run={allocs}", ts[2], ts[0], ts[4]);
    }
}

結果(Apple M1・8GB、2回実行。測定直後の uptime の load average は 16.07 で、他の作業がかなり走っていた):

vec       median  1348.2 ms (min   694.7 / max  2298.8) audits=7500000 allocs/run=9999999
smallvec  median   141.5 ms (min   100.4 / max   165.3) audits=7500000 allocs/run=0
callback  median    96.7 ms (min    89.3 / max   140.0) audits=7500000 allocs/run=0
vec       median   689.6 ms (min   528.0 / max  1880.0) audits=7500000 allocs/run=9999999
smallvec  median   108.4 ms (min   101.1 / max   127.9) audits=7500000 allocs/run=0
callback  median   134.0 ms (min   110.5 / max   148.8) audits=7500000 allocs/run=0
案確保回数 / 1,000万ステップ(実測・ぶれない)中央値(実測・ぶれる)1ステップあたり(導出)
(c-2) Vec9,999,999690〜1,348 ms約 69〜135 ns
(c-3) SmallVec0108〜142 ms約 11〜14 ns
(c-4) コールバック097〜134 ms約 10〜13 ns
  • 3つとも監査ログ 7,500,000 件で一致(同じ仕事をしている確認)
  • 確保回数は機械の騒がしさに左右されない数字なので、こちらを主な根拠にします。Vec 版の 9,999,999 は「アクションが1個以上ある行」の回数です。1,000万ステップのうち、i = 3 の1回だけ非管理者の Reset が拒否され(アクション0個、Vec::new() は確保しない)、残りはすべて確保が起きる(導出: 3回失敗 → 警報 → 管理者が解除、の4ステップ周期で、最初の周期の解除だけが偶数ステップに当たらず拒否される)
  • 時間は、Vec 版がほかの約 5〜10 倍。SmallVec とコールバックの差は、このぶれの中では判定できません(1回目は callback が速く、2回目は smallvec が速い)。それより細かい主張はしません
  • Day 066(静的スライス版 191〜222 ms)との比較はしません。機械の負荷が違いすぎるため(あちらは load の記録なし)

🪜 Step-by-Step 解説

Step 1: 値を「いつ」評価するかを決める

要件 A-1 の核心は「判断した時点の値」です。Rust で言えば、**値を Act の中にコピーするのは on_emit の中**(&Ctx しか無い時点)で、apply の中ではない、ということです。(c-1') の遅延束縛は apply の中で self.failures を読むので、その前に実行された RecordFailure の影響を受けます(S12 で [RecordFailure, AuditFailures] → 1、逆順 → 0)。

Step 2: 土台をコールバックにして、入れ物は呼び出し側が選ぶ

マクロが生成するのは on_emit だけ。入れ物(Vec・SmallVec・その場で流す)は呼び出し側の数行で決めます。こうすると、**マクロは入れ物のクレート(smallvec)に依存しない**。Day 067 で読んだ bitflags が依存を増やさないことを優先していたのと同じ判断です。

Step 3: 5段の入れ子で引数つきアクションを受ける

$( / [ $( $a:ident $( ( $($arg:expr),* ) )? ),* $(,)? ] )?

展開側:

$( $( emit($Action::$a $( ( $($arg),* ) )?); )* )?

$arg の中の c は、呼び出し側が context c: Ctx; で渡した識別子なので、ガードと同じく衛生性(hygiene)の問題は起きません(マクロの中で c という名前を作っているのではなく、呼び出し側の名前をそのまま受け取っているため)。

Step 4: コールバック版は「外へ出すもの」と「文脈を変えるもの」に分ける

E0500(Part 3-1)が示すとおり、emit の中では文脈を書き換えられません。fire_cb は Audit だけをその場で監査ログへ流し、ほかは小さな入れ物に溜めて後で apply します。S11 で、fire(SmallVec 版)と fire_cb が 720 通りすべてで同じ文脈・同じログになることを確かめています。

Step 5: S10 を「同じアドレス」から「確保回数」に書き換える

Day 066 の S10 は「静的領域にあるから同じアドレス」で確保ゼロを示しました。値を持つと静的領域には置けないので、この証明方法は使えません。代わりに #[global_allocator] で確保を数えます。テストでは thread_local! の Cell に数えて、並列に走る他のテストの確保を混ぜないようにしました(const { Cell::new(0) } で初期化し、確保関数の中で遅延初期化の確保が起きないようにしている)。

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

Day 066 の静的スライスは「値を持たない」ことと引き換えに、型だけで純粋さと確保ゼロを手に入れていました。値を持たせた瞬間、そのどちらかを手放すか、別の仕組みで守り直す必要が出ます。今日の結論は、**純粋さは &Ctx で型のまま守り(E0500 が証拠)、確保ゼロは入れ物の選択に委ね、確保回数のテストで守り直す**です。

これは「ゼロコスト抽象化」の正直な形でもあります。コールバックの impl FnMut は単相化(呼び出し側ごとに専用のコードを生成)されるので、on_emit を土台にしても余分な間接呼び出しはありません(Part 4 で SmallVec 版・コールバック版とも 1ステップ約 10〜14 ns)。コストが出るのは Vec を選んだときだけで、それは呼び出し側に見える選択です。

ADR-068(Part 5 の答えの例)

ADR-068 値を持つアクション(監査ログ Audit(n, ok))の入れ物
決定: fsm! は on_emit(コールバック)だけを生成する。扉の fire は SmallVec<[Act; 3]> で集めて適用する。
理由: A-1「判断した時点の値」を満たし、確保 0 回(1,000万ステップで実測)。Vec・SmallVec はその上に3行で作れる。
採らなかった案:
  (c-1') 遅延束縛: 監査値が表の中のアクション順で変わる(S12: 1 と 0)。コンパイラもテストも止めない。
  (c-2) Vec を既定に: 1,000万ステップで確保 9,999,999 回、時間はほかの約 5〜10 倍(騒がしい機械での実測)。no_std の扉コントローラでは使えない。
  (c-4) その場で全部適用: E0500。RefCell で通すのは Day 065 の穴を開け直すこと。
前提が変わったら: 扉の判断がサーバ側で動き、人の操作速度(毎秒数回)なら Vec で十分(1回 100 ns 前後の差は無視できる)。
  1行のアクションが 4 個以上になったら SmallVec の 3 を上げるか、spill を確保テストで検出する。
  引数の誤り(E0061)が表のどの行かを示せないのが問題になる規模(表が数十行)になったら proc-macro(マクロ②)で行ごとに検査する。

迷った他案と採らなかった理由(今日の問題の作り方について)

案中身採らなかった理由
(A) 今日の形: 4案を1つのマクロ(土台=コールバック)の上に作り、同じテストで比べる実装は1つ、入れ物だけ差し替え採用。 同じ表・同じ仕様テストで比べられる。マクロを4つ書くと、比べているのがマクロの差か入れ物の差か分からなくなる
(B) arrayvec(固定長、確保なし)も比べるDay 066 の (c-3) 候補ローカルのレジストリにあるのは 0.5.2(古い版)だけで、今の版と API が違う可能性がある。SmallVec で「確保なしの入れ物」の性質は確かめられるので見送った
(C) 入れ子の状態(Alarm の中に鳴動中/消音中)を設計するDay 067 の次回予定のもう一方値を持つアクションは ADR-066・ADR-067 の両方が「要るなら Vec かコールバック」と止めていた。先にこちらを決めないと、入れ子の状態の entry/exit アクションに値を持たせるかの議論ができない
(D) 静かな機械(Mac mini)で計測を取り直してから書くDay 066 の宿題今日の主な根拠は確保回数(機械の負荷に左右されない)にした。時間の比較は「Vec が約 5〜10 倍」より細かく主張しないことで対処

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

エラー出た原因読み方正しい直し方
E0500 closure requires unique access to *c but it is already borrowedemit の中で文脈を書き換えた「on_emit に &c を渡している間に、クロージャが &mut c を捕まえた」。下線が3か所(on_emit・c・クロージャ)に付く文脈を変えるアクションは溜めて後で適用(fire_cb)。RefCell で通さない
E0061 this enum variant takes 2 arguments but 1 argument was supplied表の行で引数の数を間違えたマクロ定義の展開の行を指す。どの表の行かは help: の中の式(c.failures + 1)で探す表の行を直す。help: のマクロ定義の書き換えは採らない
E0515 cannot return reference to temporary value値を持つアクションを Day 066 の &'static [Act] で返したDay 066 Part 3-3 と同じ今日の on_emit に移る
clippy::map_identityテストで .map(|(t, v)| (t, v)) と書いた何もしない map消す

🔀 他言語との比較

観点Rust(今日の on_emit)TypeScript(XState v5)GoJava
値を持つアクションenum Act { Audit(u32, bool) }、判断の時点で値をコピーアクションのパラメータ(params)を関数で渡すstruct か interface の値を返すsealed interface + record
判断の最中に文脈を書き換えられるか型で止まる(E0500、実測)言語では止まらない(context はただのオブジェクト)ポインタで渡せば書ける書ける(final は再代入だけを止める)
確保入れ物で選ぶ(SmallVec・コールバックは 0 回、実測)配列とオブジェクトを毎回生成(GC)スライスはエスケープ解析次第record は毎回生成(JIT のエスケープ解析で消えることもある)
遅延束縛の罠(S12)起こりうる(実測)起こりうる(アクションの関数が実行時に context を読む)起こりうる起こりうる

Rust の列は今日実行した結果です。XState の params は Day 066 で読んだ Actions のページの範囲の記憶で、今日は取得し直していません。Go・Java の列は言語仕様の記憶に基づく要旨で、今日は実行していません。

🏭 実務での使いどころ

  • 監査ログは「判断の時点のスナップショット」で残す。 決済の承認(「承認した時点の残高」)、ロックアウト(「ロックした時点の失敗回数」)、価格の確定(「注文時の単価」)はすべて同じ形です。遅延束縛で書くと、後から別のアクションが値を変えたときに、ログがもっともらしく間違う
  • イベントソーシング: イベント(今日の Audit(n, ok))は値をすべて持ち、後から再生しても同じ結果になるようにします。イベントが「後で文脈を読みに行く」形だと、再生した時点の文脈で値が変わってしまう。今日の S12 はその縮図です
  • 組み込みの扉コントローラ: no_std では Vec が使えないので、SmallVec(no_std 対応、smallvec のドキュメントに no_std の節がある)か固定長の配列か、コールバックになります。今日のコールバック版のベンチの [Option<Act>; 3] は、外部クレートなしで書ける形の例です
  • ライブラリの API 設計: 「何を返すか」を1つに決めず、**最も一般的な形(コールバック)を中核にし、便利な形(Vec を返す)はその上に薄く載せる**。標準ライブラリの Iterator と collect() の関係と同じです

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「値は apply で文脈から読めば、アクションに値を持たせなくてよい」値を持たせると確保が要るので避けたい実行時点の値になり、表の中のアクションの順序で監査値が変わる(S12: 1 と 0、実測)。コンパイラもテストも止めない
「コールバックなら、受け取ったアクションをその場で文脈に適用すればよい」1回で済むので速そうE0500 で止まる(実測)。文脈を変えるものは溜めて後で適用する
「SmallVec なら絶対に確保しない」名前からインラインの容量(今日は 3)を超えると spill してヒープを使う(smallvec のドキュメント)。容量はマクロが数えていないので、行を足す人が守る必要がある
「Vec は遅いので使ってはいけない」ベンチの数字だけを見る1回の差は 100 ns 前後(導出)。人が扉を操作する速度なら無視できる。問題になるのは no_std と、1秒に数百万回回す場合
「引数の誤りも、表の行を指してくれる」Day 066 の E0599 は表の行を指した引数の数の誤り(E0061)は型検査の段階で出るので、マクロ定義の展開の行を指す(実測)

📎 一次資料(参照日: 2026-10-08)

  • smallvec 1.16.1 のソース(ローカル ~/.cargo/registry/src/index.crates.io-*/smallvec-1.16.1/src/lib.rs、今回直接読んだ)
     - 8〜10 行目: "These store a certain number of elements inline, and fall back to the heap for larger allocations."
     - 789〜792 行目: "If the data exceeds this limit, the SmallVec will "spill" its data onto the heap, allocating a new buffer to hold it."
     - 12 行目に ## no_std support の節がある
  • The Rust Reference, Destructors — Constant promotion(Day 066 で取得・照合済み。今日は取得し直していない): https://doc.rust-lang.org/reference/destructors.html
  • rustc --explain E0500・E0061: 今日は実行して出たエラー文だけを使い、説明文は読んでいない

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

扱わなかったこと理由・次の予定
SmallVec の spill(4個目のアクション)を確保回数のテストで実測ドキュメントの記述のみ。行を足して count で 1 になることを確かめる
E0061 を表の行で報告する方法(variant ごとに引数の数を検査する補助関数を生成する、など)macro_rules! で variant ごとの arity を検査するのは重い。proc-macro(マクロ②)の題材にする
静かな機械(Mac mini)での時間の取り直し今日は確保回数を主な根拠にした。Day 066 の静的スライス版と同じ条件で並べる
arrayvec 版ローカルに古い 0.5.2 しか無い
入れ子の状態(Alarm の中の鳴動中/消音中)と statig の superstate の比較次の設計の日に
Day 067 以前からの持ち越し(rust-fsm・statig 本体のビルド、cargo expand、bitflags_match! の ?・break、24 バイトの判別値の位置、TS・Go・Java の比較列の実行)今日は値を持つアクションの設計に集中

3 問の自己点検

  1. 同じ指示を AI に出せば数時間で出るか — 「Vec と SmallVec とコールバックの比較」の一般論は出る。ただし、遅延束縛で監査値が 1 と 0 に分かれること(S12)、コールバックで文脈を直接書き換えると E0502 ではなく E0500 になること、引数の数の誤りが表の行ではなくマクロ定義の展開の行を指し help: がマクロ定義を書き換えようとすること、1,000万ステップで Vec 版が 9,999,999 回確保すること(とその1回の差の理由)は、ビルドと計測で得た結果
  2. AI に指示するだけでは出ない部分 — 監査要件 A-1「判断した時点の値」を仕様として先に置き、それで (c-1') を落とした判断。「土台はコールバック、入れ物は呼び出し側」という、マクロの生成物を1つに絞る判断と、その根拠(土台から他を作れるが逆は作れない)。そして「扉の判断がサーバ側なら Vec で十分」という、ベンチの数字を前提で打ち消す記録
  3. 世界の実物と比べて何割か — XState のアクションの機能(params・assign・entry/exit・遅延・raise/sendTo など)のうち、扱ったのは「値を持つアクション」と「純粋な遷移関数がアクションを返す」の2つ。smallvec の API のうち使ったのは new・push・to_vec・反復の4つで、spill は実測していない

➡️ 次のステップ

  • 発展: SmallVec の容量を超える行(アクション4個)を足し、確保テストで spill を検出する。容量の 3 をマクロに数えさせる(Day 059 の数え上げ)か、const で宣言させて行ごとに検査するかを比べる
  • 次回予告: マクロ① の残り期間(〜10/24)で、入れ子の状態(Alarm の中に鳴動中/消音中)を自作 fsm! で表す設計を statig の superstate と比べ、自作で作るべきでない線を決める。または、今日の on_emit 版で E0061 が出る場所を表の行に寄せる工夫(デバッグ・コードレビュー)

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

Part 1 の予想表と実際(特に (c-1') の A-1、(c-4) の S10)

Part 3 の予想(E0500 か E0502 か、E0061 がどこを指すか)と実際

自分の回答(土台の選択、ADR-068)

気づき・メモ