Day 069 — 2026-10-09 | マクロ① macro_rules! ⑭ SmallVec の容量を「誰が数えるか」:4個目のアクションで spill を確保回数で捕まえ、マクロに数えさせる案(MAX_ACTIONS)と上限を宣言させる案(max N)を実装して比べる(容量3のままだと 720 通り中 4 通りで確保1回/宣言を超えた行は E0080 が行の文字列を名指し/数えた容量が 33 になると E0277)

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

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

Day 068 で、扉の fsm! は on_emit(コールバック)だけを生成し、呼び出し側が SmallVec<[Act; 3]> に集める形に決めました(ADR-068)。3 は「今日の表で1行のアクションが最大3個」という人間が数えた数で、ADR-068 には「1行のアクションが 4 個以上になったら SmallVec の 3 を上げるか、spill を確保テストで検出する」と書いて止めました。

今日、その 4 個目が来ます。

要件 A-2: 3回目の失敗で警報を鳴らすとき、警備室にも通知すること(NotifyGuard)。

警報の行は [RecordFailure, Audit(c.failures + 1, false), SoundAlarm, NotifyGuard] の4個になります。SmallVec<[Act; 3]> は4個目を入れた時点で中身をヒープへ移します。smallvec のソースはこれを "spill" と呼び、spilled() で確かめられます(一次資料参照)。コンパイラは何も言いません。 ADR-068 の「確保 0 回」は、表に1行足しただけで黙って崩れます。

これは「型の中の数字」と「表の中の数」が別々に書かれていることが原因です。直し方は2つ考えられます。

案中身守るもの
A: マクロに数えさせるマクロが各行のアクション数を数え、最大値を Door::MAX_ACTIONS として公開する。型は SmallVec<[Act; Door::MAX_ACTIONS]>spill が起きない(容量が常に表に追従する)
B: 上限を宣言させる表のヘッダに action Act max 4 { .. } と書かせ、超える行があればコンパイルを止める構造体の大きさが黙って増えない(予算を人が決める)

Day 059 で macro_rules! の数え上げ(再帰と 0 $(+ 1)*)を扱いました。今日はそれを定数評価(const)と組み合わせて、数えた結果をコンパイル時の検査に使います。

📝 問題

環境は rustc 1.96.0、cargo new --lib capfsm(edition 2024)、依存は smallvec = "=1.16.1"(ローカルのレジストリにある版、--offline)。Day 068 の fsm! と扉の表を出発点にし、Act に NotifyGuard を足して警報の行を4個にします。

Part 1: 予想する

実装する前に、次を予想してください。

  1. SmallVec<[Act; 3]> のまま A-2 の行(4個)を on_emit で集めたとき、確保は何回起きるか
  2. Day 068 の 720 通り(入力 3 × 失敗回数 0〜5 × 管理者か否か 2 × 状態 4 × イベント 5)を全部回すと、容量 3 版で確保が起きるのは何通りか。どの組み合わせか
  3. 容量を 3 → 4 にすると size_of::<SmallVec<[Act; N]>>() は何バイト増えるか(Act は 8 バイト)

Part 2: 実装する

fsm! を次の2点で拡張してください。

  1. 案A: 各行の (行の文字列, アクション数) を Door::ROW_ACTIONS: &[(&str, usize)] として、最大値を Door::MAX_ACTIONS: usize として生成する。どちらも const で、実行時のコストは 0
  2. 案B: ヘッダに省略可能な max N を受け、書かれたときだけ「N を超える行があればコンパイルエラー。エラー文にその行が出る」検査を生成する

そのうえで、次の仕様テストを通してください(確保回数は Day 068 の thread_local! のカウンタを使い、今日は realloc も数える)。

テスト中身
S13容量 3 の版で A-2 の行を通すと spilled() == true、確保 1 回
S14案A の版(SmallVec<[Act; Door::MAX_ACTIONS]>)では同じ行で確保 0 回、列は [RecordFailure, Audit(3, false), SoundAlarm, NotifyGuard]
S15720 通りで、容量 3 版と案A 版の結果(遷移先と列)が一致し、案A 版の確保は合計 0 回
S16ROW_ACTIONS が表の 8 行と一致し、各型の大きさを表示する

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

  1. 案B で max 4 を max 3 にする。エラー番号と、エラー文に何が出るか
  2. 案B の panic! のメッセージを panic!("too many actions in row: {}", rows[i].0) に変えて、行の前に説明を足す。通るか
  3. 案A だけ(max なし)で、33 個のアクションを持つ行を足す。何が起きるか。どこを指すか

Part 4: 判断を記録する

ADR-069「アクションの容量を誰が決めるか」を 10 行以内で。A だけ・B だけ・A と B の併用のどれを採るか、却下理由、前提が変わったら判断がどう変わるかを含めること。

💡 ヒント

ヒント1(方向性)
  • 「数える」のはマクロ(構文の段階)、「最大値を取る」「上限と比べる」のは定数評価(const の中の while)、と段階を分けるとマクロが単純になります。macro_rules! の中で最大値を求めようとすると再帰が深くなります
  • const の中では for が使えません(Iterator が const でないため)。while と添字で回します
  • コンパイルを止めたいときは、const _: () = { ... panic!(...) ... }; を置きます。定数の評価が panic すると、それがコンパイルエラーになります
ヒント2(アプローチ)
  • 1行のアクション数は、行の中の $a の繰り返しを 0usize $( $( + $crate::__one!($a) )* )? と展開すれば数えられます(__one! は何を受けても 1usize を返す補助マクロ)
  • 行の文字列は stringify!(($s, $e) $(if $g)?) で作れます。$s:pat を stringify! に渡すと、書いたとおりの字面が出ます
  • max N は $( max $cap:literal )? で受けます。literal の後ろには何でも置けます(expr だと follow-set で { を置けない)
  • smallvec 1.x は、const_generics 機能を有効にしない限り、Array を特定の長さの配列だけに実装しています。ソースの impl_array! の呼び出しを見てください
ヒント3(コード骨格)
impl $State {
    pub const ROW_ACTIONS: &'static [(&'static str, usize)] = &[
        $( ( stringify!(($s, $e) $(if $g)?), 0usize $( $( + $crate::__one!($a) )* )? ) ),*
    ];
    pub const MAX_ACTIONS: usize = { /* while で最大値 */ };
}
$(
    const _: () = { /* while で rows[i].1 > $cap なら panic!("{}", rows[i].0) */ };
)?

✅ 模範解答

Cargo.toml:

[package]
name = "capfsm"
version = "0.1.0"
edition = "2024"

[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,
}

#[doc(hidden)]
#[macro_export]
macro_rules! __one {
    ($x:tt) => { 1usize };
}

/// Day 068 の fsm! に「1行のアクション数」を数える機能を足したもの。
/// - 案A: MAX_ACTIONS(全行の最大アクション数)をマクロが数えて const で公開する
/// - 案B: ヘッダに `max N` を書いたときだけ、各行が N 以下かをコンパイル時に検査する
#[macro_export]
macro_rules! fsm {
    (
        context $c:ident : $Ctx:ty ;
        state  $State:ident  { $($S:ident),+ $(,)? }
        event  $Event:ident  { $($E:ident),+ $(,)? }
        action $Action:ident $( max $cap:literal )? { $( $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 {
            /// 各行の (行の文字列, アクション数)。表の順
            pub const ROW_ACTIONS: &'static [(&'static str, usize)] = &[
                $( (
                    stringify!(($s, $e) $(if $g)?),
                    0usize $( $( + $crate::__one!($a) )* )?
                ) ),*
            ];
            /// 案A: 全行の最大アクション数(マクロが数える)
            pub const MAX_ACTIONS: usize = {
                let rows = Self::ROW_ACTIONS;
                let (mut i, mut m) = (0, 0);
                while i < rows.len() {
                    if rows[i].1 > m { m = rows[i].1; }
                    i += 1;
                }
                m
            };

            #[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 }),
                }
            }
        }

        // 案B: 宣言した上限を超える行があればコンパイルを止め、その行を名指しする
        $(
            const _: () = {
                let rows = $State::ROW_ACTIONS;
                let mut i = 0;
                while i < rows.len() {
                    if rows[i].1 > $cap {
                        panic!("{}", rows[i].0);
                    }
                    i += 1;
                }
            };
        )?
    };
}

/// 要件 A-2: 3回目の失敗で警報を鳴らすとき、警備室にも通知する → その行だけアクションが4個になる
/// (Part 3 で max を差し替えるため、表を1か所に置いてヘッダの max だけ外から渡す)
#[macro_export]
macro_rules! door_table {
    ($($max:tt)*) => {
        $crate::fsm! {
            context c: Ctx;
            state  Door { Locked, Closed, Open, Alarm }
            event  Ev   { Unlock, Lock, Push, Pull, Reset }
            action Act $($max)* { RecordFailure, ResetFailures, SoundAlarm, NotifyGuard, Audit(u32, bool) }

            (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, NotifyGuard];
            (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 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())
        }
    }

    crate::door_table!(max 4);

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

    /// Day 068 のまま、容量を手で 3 と書いた版(A-2 の行で spill する)
    pub type Acts3 = SmallVec<[Act; 3]>;
    /// 案A: 容量をマクロが数えた MAX_ACTIONS にする
    pub type ActsA = SmallVec<[Act; Door::MAX_ACTIONS]>;

    pub fn on_small3(s: Door, ev: Ev, c: &Ctx) -> Result<(Door, Acts3), Rej> {
        let mut v = Acts3::new();
        let to = s.on_emit(ev, c, &mut |a| v.push(a))?;
        Ok((to, v))
    }
    pub fn on_small_a(s: Door, ev: Ev, c: &Ctx) -> Result<(Door, ActsA), Rej> {
        let mut v = ActsA::new();
        let to = s.on_emit(ev, c, &mut |a| v.push(a))?;
        Ok((to, v))
    }
}

#[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 realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 {
            let _ = N.try_with(|n| n.set(n.get() + 1));
            unsafe { System.realloc(p, l, new) }
        }
        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::*;
    use std::mem::size_of;

    fn third_failure() -> Ctx { Ctx::new(Some("0000"), 2, false) }

    /// S13: 容量 3 のまま A-2 の行(4個)を通すと spill して確保が1回起きる
    #[test]
    fn s13_cap3_spills_on_four_actions() {
        let c = third_failure();
        let ((to, v), n) = count(|| on_small3(Door::Locked, Ev::Unlock, &c).unwrap());
        println!("cap3: len={} inline_size={} spilled={} allocs={n}", v.len(), v.inline_size(), v.spilled());
        assert_eq!(to, Door::Alarm);
        assert!(v.spilled());
        assert_eq!(n, 1);
    }

    /// S14: 案A(MAX_ACTIONS)なら同じ行で確保 0
    #[test]
    fn s14_counted_cap_never_spills() {
        let c = third_failure();
        let ((_, v), n) = count(|| on_small_a(Door::Locked, Ev::Unlock, &c).unwrap());
        println!("capA: MAX_ACTIONS={} inline_size={} spilled={} allocs={n}", Door::MAX_ACTIONS, v.inline_size(), v.spilled());
        assert_eq!(Door::MAX_ACTIONS, 4);
        assert!(!v.spilled());
        assert_eq!(n, 0);
        assert_eq!(v.as_slice(), &[Act::RecordFailure, Act::Audit(3, false), Act::SoundAlarm, Act::NotifyGuard]);
    }

    /// S15: 720 通りすべてで、案A は1度も確保しない。容量3 は A-2 の行に入った回数だけ確保する
    #[test]
    fn s15_allocation_census_720() {
        let (mut total, mut a3, mut aa, mut spill_rows) = (0, 0, 0, 0);
        for e in [None, Some("1234"), Some("0000")] {
            for f in 0..6 {
                for adm in [false, true] {
                    let c = Ctx::new(e, f, adm);
                    for s in [Door::Locked, Door::Closed, Door::Open, Door::Alarm] {
                        for ev in [Ev::Unlock, Ev::Lock, Ev::Push, Ev::Pull, Ev::Reset] {
                            let (r3, n3) = count(|| on_small3(s, ev, &c));
                            let (ra, na) = count(|| on_small_a(s, ev, &c));
                            assert_eq!(r3.map(|(t, v)| (t, v.to_vec())), ra.map(|(t, v)| (t, v.to_vec())));
                            if n3 > 0 { spill_rows += 1; }
                            a3 += n3;
                            aa += na;
                            total += 1;
                        }
                    }
                }
            }
        }
        println!("census: cases={total} allocs cap3={a3} capA={aa} spilled_cases={spill_rows}");
        assert_eq!(total, 720);
        assert_eq!(aa, 0);
    }

    /// S16: 行ごとのアクション数と、容量を上げた代償(大きさ)
    #[test]
    fn s16_row_counts_and_sizes() {
        for (row, n) in Door::ROW_ACTIONS { println!("{n}  {row}"); }
        println!("size_of Act={} Acts3={} ActsA={} Vec={}",
            size_of::<Act>(), size_of::<Acts3>(), size_of::<ActsA>(), size_of::<Vec<Act>>());
        assert_eq!(Door::ROW_ACTIONS.len(), 8);
    }
}

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

running 4 tests
cap3: len=4 inline_size=3 spilled=true allocs=1
.capA: MAX_ACTIONS=4 inline_size=4 spilled=false allocs=0
.census: cases=720 allocs cap3=4 capA=0 spilled_cases=4
.2  (Locked, Unlock) if c.failures >= 3
2  (Locked, Unlock) if c.code_ok()
4  (Locked, Unlock) if c.failures + 1 >= 3
2  (Locked, Unlock)
0  (Closed, Lock)
0  (Closed, Push)
0  (Open, Pull)
1  (Alarm, Reset) if c.is_admin
size_of Act=8 Acts3=40 ActsA=48 Vec=24
.
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

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

Part 1 の答え

#答え出所
11 回(4個目の push で spill)。spilled=true、inline_size=3実測(S13)
24 通り。確保の合計も 4 回。(Locked, Unlock) で失敗回数 2・暗証番号が正しくない(None か "0000")・管理者か否か 2 → 1 × 2 × 2 = 4回数は実測(S15)、組み合わせは表のガードから導出
38 バイト(40 → 48)。Act 1個分実測(S16)

720 通りのうち 4 通り、つまり 0.56% です(導出)。普通のテスト(S1〜S12)は「3回目の失敗」を1回は通るので、確保回数を数えるテストさえあれば必ず捕まります。逆に、確保を数えるテストが無ければ、どのテストも落ちない。

Part 2 の要点

  • 数えるのはマクロ、比べるのは定数評価。 マクロは各行を 0usize + 1usize + 1usize + ... という式に展開するだけで、最大値も上限の比較も const の while が行います。macro_rules! の再帰で最大値を求める必要はありません
  • $a は「行 → / [..] の有無 → 並び」の3段の入れ子の中にあります。0usize $( $( + $crate::__one!($a) )* )? は、その3段のうち内側2段だけを展開に使い、外側(行)は ROW_ACTIONS の $( ... ),* が受け持ちます。$arg(4〜5段目)は使っていなくてもよい(深い段の変数を使わないのは許される)
  • 案B の $( const _: () = { ... $cap ... }; )? は、$cap の有無(0回か1回)で繰り返しが決まります。$cap は行の繰り返しの外で使っているので、行の繰り返しと深さがぶつかりません。行ごとの検査は ROW_ACTIONS を while で回して行います

Part 3 の答え(実測)

#壊し方結果指した場所読み取ること
1max 3E0080 evaluation panicked: (Locked, Unlock) if c.failures + 1 >= 3マクロ定義の panic! の行。ただしエラー文の1行目に表の行がそのまま出るDay 068 の E0061 と違い、どの行が悪いかがエラー文で分かる
2panic!("too many actions in row: {}", ..)E0015 cannot call non-const formatting macro in constantspanic! の行const の中の panic! で許されるのは panic!("{}", s)(s: &str)の形だけ。説明文を足すとコンパイルできない
3案A だけで 33 個の行E0277 the trait bound [Act; 33]: smallvec::Array is not satisfied が 3 件利用側の on_small_a の戻り値の型・ActsA::new() など。表の行は指さないsmallvec 1.x は const_generics 機能なしだと Array を 0〜32, 36, 0x40, … にしか実装していない

1 の出力:

error[E0080]: evaluation panicked: (Locked, Unlock) if c.failures + 1 >= 3 --> src/lib.rs:75:25 | 75 | panic!("{}", rows[i].0); | ^^^^^^^^^^^^^^^^^^^^^^^ evaluation of `door::_` failed here ... 125 | crate::door_table!(max 3); | ------------------------- in this macro invocation

2 の出力:

error[E0015]: cannot call non-const formatting macro in constants --> src/lib.rs:75:25 | 75 | panic!("too many actions in row: {}", rows[i].0); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = note: calls in constants are limited to constant functions, tuple structs and tuple variants

3 の出力(1件目):

error[E0277]: the trait bound `[Act; 33]: smallvec::Array` is not satisfied --> src/lib.rs:139:66 139 | pub fn on_small_a(s: Door, ev: Ev, c: &Ctx) -> Result<(Door, ActsA), Rej> { | ^^^^^ the trait `smallvec::Array` is not implemented for `[Act; 33]` = help: the following other types implement trait `smallvec::Array`: [T; 0] [T; 1] ... and 45 others

追加で、3 の状態のまま smallvec の const_generics 機能を有効にするとコンパイルは通り、size_of::<SmallVec<[Act; 33]>>() は 280 バイトになりました(実測。容量 3 の 40 バイトの 7 倍)。つまり案A だけだと、33 個の行を足した人は E0277 で止まるが、32 個までなら黙って通り、構造体が 264 バイトに膨らむ(32 × 8 + 8、導出)。扉のマイコンのスタックが数 KB なら無視できない増え方です。

読み取ること:

  • 案A は spill を消すが、大きさの増加を黙らせる。 「確保 0 回」は守られる代わりに、1行の変更でスタックに載る構造体が数倍になる。その増加はレビューの差分に max も型も出てこないので見えない
  • 案B は増加を止めるが、spill を消さない。 max 4 と書いても、型が SmallVec<[Act; 3]> のままなら S13 と同じく spill する。宣言(max)と型(3)の2か所に数字があり、ずれる余地が残る
  • だから併用します。型は Door::MAX_ACTIONS(案A、spill が起きない)、上限は max 4(案B、増加を止めて行を名指しする)。数字は max 4 の1か所だけで、MAX_ACTIONS は表から数えた値なので、ずれる場所がありません。模範解答の door はこの形です

Part 4 の答えの例: ADR-069

ADR-069 アクションの容量を誰が決めるか
決定: 型の容量はマクロが数えた Door::MAX_ACTIONS(案A)、増やしてよい上限は表のヘッダの max N(案B)。両方使う。
理由: 案A で spill は構造的に起きない(720 通りで確保 0、実測)。案B で上限を超える行は E0080 が行の字面を名指しして止まる(実測)。
       人が書く数字は max N の1か所だけになる。
採らなかった案:
  手で書いた SmallVec<[Act; 3]>(Day 068): 4個目の行で黙って spill(720 通り中 4 通りで確保、実測)。確保テストが無いと気づけない。
  案A だけ: 32 個までは黙って通り構造体が膨らむ(40→最大 264 バイト、導出)。33 個で E0277 が利用側の型を指し、表の行を指さない。
  案B だけ: max と型の容量の2か所に数字があり、ずれると spill する。
  smallvec の const_generics を有効化: 33 の壁は消えるが、膨らみを止める仕組みにはならない(280 バイトで通った、実測)。
前提が変わったら: 扉の判断がサーバ側で動きスタックに余裕があるなら max は不要(案A だけで足りる)。
  E0080 で済まない詳しい説明(「上限 4 に対して 5 個」)が必要になったら proc-macro(マクロ②)で compile_error! を行の位置に出す。

🪜 Step-by-Step 解説

Step 1: spill を「数」で捕まえる(S13・S15)

SmallVec が spill しても、値も順序も正しいままです。S15 で容量 3 版と案A 版の結果(遷移先と列)が 720 通りすべて一致したのがその証拠です。正しさのテストでは spill は見えません。 見えるのは確保の回数だけです。Day 068 のカウンタに realloc も足したのは、spill した後にさらに増える場合(5個目以降)も数えるためです。今日の 4個の行では realloc は呼ばれていません(確保 1 回=alloc 1 回)。

Step 2: マクロは「式」を作るだけにする(案A)

pub const ROW_ACTIONS: &'static [(&'static str, usize)] = &[
    $( ( stringify!(($s, $e) $(if $g)?), 0usize $( $( + $crate::__one!($a) )* )? ) ),*
];

警報の行はこう展開されます(概念上): ("(Locked, Unlock) if c.failures + 1 >= 3", 0usize + 1usize + 1usize + 1usize + 1usize)。足し算はコンパイラの定数評価が行うので、実行時のコストは 0 です。__one! を #[macro_export] して $crate::__one! で呼ぶのは、Day 060 で確かめた「別クレートから使われても補助マクロが見つかる」ためです。#[doc(hidden)] で利用者のドキュメントには出しません。

Step 3: 最大値と上限の比較は const の while で書く

const の中では for x in rows が書けません(IntoIterator が const でないため)。while i < rows.len() と添字で回します。MAX_ACTIONS は関連定数なので、型の中で SmallVec<[Act; Door::MAX_ACTIONS]> と使えます。ジェネリックな型引数を含まない具体的な定数なので、generic_const_exprs(nightly)は要りません。

Step 4: const _ の panic をエラー文にする(案B)

const _: () = { ... }; は名前の無い定数で、置いただけで評価されます。評価中に panic! すると E0080 になり、panic のメッセージがエラー文の1行目に出ます。メッセージに stringify! で作った行の字面を渡せば、エラー文が行を名指しします。ただし const の中の panic! が受け付けるのは panic!("{}", s) の形だけで、説明を足すと E0015 になります(Part 3-2)。行の字面に説明を含めたいなら、stringify! の側で concat!("too many actions: ", stringify!(..)) として文字列リテラルの段階でくっつけるのが1つの手です(今日は未実施)。

Step 5: 2つの数字を1つにする

Part 3 の読み取りのとおり、案A は spill を、案B は膨らみを止めます。併用すると、人が書く数字は max 4 の1か所になり、MAX_ACTIONS は表から数えた値なので、ずれる場所がありません。型の中の数字を人に書かせないのが今日の要点です。

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

Rust の「ゼロコスト抽象化」は、コストが消えるという意味ではなく、使わなければ払わない、使うなら手で書くより悪くないという意味です。SmallVec<[Act; 3]> の 3 は、そのコストの境目を人が手で書いた数字でした。境目を越えたときのコスト(確保)は払われ、しかも黙って払われます。

今日の解決は、境目の数字を表から導出する(案A)ことで、境目を越えられないようにしたことです。これはマクロでしかできない仕事です。fn では表の行数も各行のアクション数も見えません。表を構文として受け取るマクロだけが数えられ、定数評価に渡せます。

同時に、導出に任せきると「表が育つとコストも黙って育つ」ので、予算(max)を人に宣言させます。数えるのは機械、上限を決めるのは人、という分担です。Day 063 の ADR-063 の基準で言えば、行の字面を使う(③ stringify!)と、関連定数と const _ という item を生成する(④)に当たり、関数では書けない仕事です。

迷った他案と採らなかった理由(今日の実装について)

案中身採らなかった理由
(A) 今日の形: ROW_ACTIONS を生成し、最大値と上限比較は const の whileマクロは式を作るだけ採用。 マクロが単純で、行の字面もエラー文に出せる
(B) macro_rules! の再帰で最大値を求めるDay 059 の数え上げを延長行ごとに比べる再帰が要り、行数に比例して再帰が深くなる(Day 059 で既定の上限 128 に約 125 行で当たることを実測済み)。定数評価に任せれば再帰は 0 段
(C) SmallVec をやめ、[Option<Act>; MAX_ACTIONS] を自作するDay 068 のベンチの形依存は減るが、push・反復・Debug を自作することになる。今日の論点(容量を誰が決めるか)は SmallVec のままでも同じなので見送った
(D) max を必須にする宣言を強制Day 068 までの表(max なし)が全部壊れる。省略可能にし、省略時は案A だけが効く
(E) max $cap:expr で受ける定数名(MAX_STACK_ACTIONS など)も書けるexpr の後ろには { を置けない(follow-set、Day 066 で / について実測した制限と同じ)。literal にした

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

エラー出た原因読み方正しい直し方
E0080 evaluation panicked: (行の字面)案B の上限を超える行がある1行目の字面が問題の行。下線はマクロ定義の panic! を指すが、そこは直さない行のアクションを減らすか、意図して max を上げる(構造体が 8 バイト × 差分だけ大きくなる)
E0015 cannot call non-const formatting macro in constantsconst の中の panic! に書式つきの説明を足したconst で許される panic! は panic!("{}", s) だけ説明は concat! で文字列リテラルの段階で付ける
E0277 [Act; N]: smallvec::Array is not satisfiedMAX_ACTIONS が smallvec の実装していない長さ(33 など)になった利用側の型を指す。表のどの行かは ROW_ACTIONS を表示して探す行を減らすか、max を宣言して E0080 で止める。const_generics 機能の有効化は大きさの問題を残す
variable 'cap' is still repeating at this depth 系$cap を行の繰り返しの中で使おうとした$cap の繰り返し(0〜1回)と行の繰り返しの深さが合わない$cap は行の繰り返しの外の $( const _ ... )? で使い、行は ROW_ACTIONS を while で回す(今日の実装では起きていない。設計時に避けた形)

🌐 他言語との比較

観点Rust(今日)C++GoTypeScript
「表から数えた数」を型に入れるマクロで数え、関連定数を [T; N] の長さに使う(実測)constexpr 関数で数え、std::array<T, N> やテンプレート引数に使える配列の長さは定数式なら書けるが、表を数えるのは go generate などコード生成の仕事型に数を入れる仕組みは無い(タプル型の長さは型レベルで扱えるが実行時の配列には効かない)
コンパイル時に止めるconst _ の panic! → E0080(実測)static_assert(cond, "msg")無い(コード生成器かテストで止める)無い(型エラーを無理に起こす技巧はある)
小さいときだけ確保しない入れ物SmallVec(外部クレート)boost::container::small_vector、LLVM の SmallVector無い(スライスのエスケープ解析次第)無い(GC)

Rust の列は今日実行した結果です。C++・Go・TypeScript の列は言語仕様の記憶に基づく要旨で、今日は実行していません。LLVM の SmallVector が「小さいサイズはインライン、超えたらヒープ」の同じ設計であることは、smallvec と同系統の発想として知られていますが、今日はソースを読んでいません。

🏭 実務での使いどころ

  • 組み込み・リアルタイム処理の「確保ゼロ」の維持: 確保ゼロは一度達成しても、表や設定に1行足しただけで崩れます。今日の S15 のように確保回数をテストで数えるのが、崩れたことに気づく唯一の手段です。ゲームエンジンや音声処理(1フレームの中で確保しないことが要件)でも同じ形のテストを置きます
  • コード生成されたテーブルの容量: パーサ生成器や状態機械の生成器は、表から「最大の深さ」「最大の出力数」を数えて固定長のバッファを作ります。今日の MAX_ACTIONS はその最小版です
  • 予算をコードに置く: 「この構造体は 64 バイトまで」「1行のアクションは 4 個まで」といった予算を、文書ではなく const _ の assert でコードに置くと、超えた人が PR の段階で止まります。標準ライブラリやパフォーマンスに敏感なクレートでも、型の大きさを const _: () = assert!(size_of::<T>() == N); で固定する書き方が使われます(今日は実在の例を照合していません)

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「spill してもテストが通るなら問題ない」値と順序は正しいまま正しさのテストでは見えない(S15 で 720 通りすべて一致)。確保ゼロが要件なら確保回数のテストが要る
「容量をマクロに数えさせれば全部解決」spill が消えるので大きさが黙って増える(4個で 48 バイト、33 個なら 280 バイト、実測)。33 個以上は smallvec の既定の実装に無く E0277
「const の panic には好きなメッセージを書ける」実行時の panic! と同じ見た目panic!("{}", s) の形だけ。説明を足すと E0015(実測)
「max 4 を宣言すれば spill しない」上限が守られるので型の容量が 3 のままなら spill する。型は MAX_ACTIONS から作る
「マクロで最大値を求めるには再帰が要る」Day 059 の数え上げの延長で考えるマクロは式を作るだけでよい。最大値は const の while で求められる

🔗 一次資料(参照日: 2026-10-09)

  • smallvec 1.16.1 のソース(ローカル ~/.cargo/registry/src/index.crates.io-*/smallvec-1.16.1/、今日直接読んだ)
    • src/lib.rs 1052〜1056 行目: spilled() のドキュメント "Returns true if the data has spilled into a separate heap-allocated buffer." と、実装 self.capacity > Self::inline_capacity()
    • src/lib.rs 2572〜2577 行目: #[cfg(not(feature = "const_generics"))] の下で impl_array!(0, 1, ..., 32, 36, 0x40, ...)。33 が無いことを確認(Part 3-3 の E0277 の理由)
    • src/lib.rs 2549〜2551 行目: #[cfg(feature = "const_generics")] の下で unsafe impl<T, const N: usize> Array for [T; N]
    • Cargo.toml 52〜54 行目: [features] に const_generics = []
  • The Rust Reference(Constant evaluation、const _ の扱い): 今日は取得していない。const の panic! が "{}" の形に限られることは、E0015 の実測だけを根拠にしている
  • rustc --explain E0080・E0015・E0277: 今日は実行して出たエラー文だけを使い、説明文は読んでいない

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

扱わなかったこと理由・次の予定
時間の計測(容量 3 と 4 の SmallVec で 1,000万ステップ)今日の論点は確保回数(機械の負荷に左右されない)。測定機は M1 8GB で load average 7.56(実行時の uptime)。静かな機械での取り直しは Day 066・068 の宿題と一緒に
concat! で E0080 のメッセージに説明を足す形Step 4 で案として書いたのみ。未実行
E0080 の下線をマクロ定義ではなく表の行に向ける方法macro_rules! では行の位置(span)を panic! に渡せない。proc-macro(マクロ②)の compile_error! の題材
const _: () = assert!(size_of::<T>() == N) を使う実在クレートの照合記憶ベースの言及に留めた
C++ static_assert・LLVM SmallVector の比較列の実行・ソース確認記憶ベース
Day 068 以前からの持ち越し(E0061 を表の行で報告、arrayvec 版、入れ子の状態と statig の superstate、rust-fsm・statig 本体のビルド、cargo expand)今日は容量の問題に集中

3 問の自己点検

  1. 同じ指示を AI に出せば数時間で出るか — 「SmallVec の容量を定数で持つ」一般論は出る。ただし、容量 3 のままで spill するのが 720 通り中ちょうど 4 通りであること、宣言を超えた行を E0080 が字面で名指しすること、const の panic! に説明を足すと E0015 になること、マクロが数えた容量が 33 になると smallvec の既定の実装に無く E0277 が利用側の型を指すこと、const_generics を有効にすると 280 バイトで黙って通ることは、ビルドと実行で得た結果
  2. AI に指示するだけでは出ない部分 — 案A と案B がそれぞれ別のものを守る(spill と膨らみ)ことを壊して確かめ、「数えるのは機械、上限を決めるのは人」という分担で併用を選んだ判断。ADR-068 の宿題(spill 未実測)を回収し、「人が書く数字を1か所にする」ことを設計の基準にした点
  3. 世界の実物と比べて何割か — smallvec の API のうち使ったのは new・push・spilled・inline_size・as_slice・to_vec の6つと const_generics 機能。C++ の static_assert や LLVM SmallVector との比較は記憶ベースで、実在の状態機械クレート(rust-fsm・statig)が出力の容量をどう扱うかは照合していない

➡️ 次のステップ

  • 発展: E0080 のメッセージを concat!("too many actions (max ", stringify!($cap), "): ", stringify!(($s, $e) ..)) のように文字列リテラルの段階で組み立て、E0015 を避けつつ説明を足す。行ごとの const _ を生成する形にすると、$cap を行の繰り返しの中で使う必要が出る——そのとき深さの制約をどう回避するかを試す
  • 次回予告: マクロ① の残り期間(〜10/24)。次は概念理解かデバッグ(同じタイプが3日続かないよう、今日の実装の次は別タイプ)。候補は (1) Day 068 の E0061 を表の行に寄せる工夫のレビュー、(2) 入れ子の状態(Alarm の中に鳴動中/消音中)を自作 fsm! と statig の superstate で比べる設計

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

Part 1 の予想(確保回数・720 通り中の件数・大きさの増分)と実際

Part 3 の予想(E0080 に何が出るか、E0015、E0277 がどこを指すか)と実際

気づき・メモ