フェーズ: 上級者 (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: 予想する
実装する前に、次を予想してください。
SmallVec<[Act; 3]>のまま A-2 の行(4個)をon_emitで集めたとき、確保は何回起きるか- Day 068 の 720 通り(入力 3 × 失敗回数 0〜5 × 管理者か否か 2 × 状態 4 × イベント 5)を全部回すと、容量 3 版で確保が起きるのは何通りか。どの組み合わせか
- 容量を 3 → 4 にすると
size_of::<SmallVec<[Act; N]>>()は何バイト増えるか(Actは 8 バイト)
Part 2: 実装する
fsm! を次の2点で拡張してください。
- 案A: 各行の
(行の文字列, アクション数)をDoor::ROW_ACTIONS: &[(&str, usize)]として、最大値をDoor::MAX_ACTIONS: usizeとして生成する。どちらもconstで、実行時のコストは 0 - 案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] |
| S15 | 720 通りで、容量 3 版と案A 版の結果(遷移先と列)が一致し、案A 版の確保は合計 0 回 |
| S16 | ROW_ACTIONS が表の 8 行と一致し、各型の大きさを表示する |
Part 3: 壊して確かめる(予想してから実行)
- 案B で
max 4をmax 3にする。エラー番号と、エラー文に何が出るか - 案B の
panic!のメッセージをpanic!("too many actions in row: {}", rows[i].0)に変えて、行の前に説明を足す。通るか - 案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 の答え
| # | 答え | 出所 |
|---|---|---|
| 1 | 1 回(4個目の push で spill)。spilled=true、inline_size=3 | 実測(S13) |
| 2 | 4 通り。確保の合計も 4 回。(Locked, Unlock) で失敗回数 2・暗証番号が正しくない(None か "0000")・管理者か否か 2 → 1 × 2 × 2 = 4 | 回数は実測(S15)、組み合わせは表のガードから導出 |
| 3 | 8 バイト(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 の答え(実測)
| # | 壊し方 | 結果 | 指した場所 | 読み取ること |
|---|---|---|---|---|
| 1 | max 3 | E0080 evaluation panicked: (Locked, Unlock) if c.failures + 1 >= 3 | マクロ定義の panic! の行。ただしエラー文の1行目に表の行がそのまま出る | Day 068 の E0061 と違い、どの行が悪いかがエラー文で分かる |
| 2 | panic!("too many actions in row: {}", ..) | E0015 cannot call non-const formatting macro in constants | panic! の行 | 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 の出力:
2 の出力:
3 の出力(1件目):
追加で、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 constants | const の中の panic! に書式つきの説明を足した | const で許される panic! は panic!("{}", s) だけ | 説明は concat! で文字列リテラルの段階で付ける |
E0277 [Act; N]: smallvec::Array is not satisfied | MAX_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++ | Go | TypeScript |
|---|---|---|---|---|
| 「表から数えた数」を型に入れる | マクロで数え、関連定数を [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.rs1052〜1056 行目:spilled()のドキュメント "Returnstrueif the data has spilled into a separate heap-allocated buffer." と、実装self.capacity > Self::inline_capacity()src/lib.rs2572〜2577 行目:#[cfg(not(feature = "const_generics"))]の下でimpl_array!(0, 1, ..., 32, 36, 0x40, ...)。33 が無いことを確認(Part 3-3 の E0277 の理由)src/lib.rs2549〜2551 行目:#[cfg(feature = "const_generics")]の下でunsafe impl<T, const N: usize> Array for [T; N]Cargo.toml52〜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 問の自己点検
- 同じ指示を AI に出せば数時間で出るか — 「SmallVec の容量を定数で持つ」一般論は出る。ただし、容量 3 のままで spill するのが 720 通り中ちょうど 4 通りであること、宣言を超えた行を E0080 が字面で名指しすること、const の
panic!に説明を足すと E0015 になること、マクロが数えた容量が 33 になると smallvec の既定の実装に無く E0277 が利用側の型を指すこと、const_genericsを有効にすると 280 バイトで黙って通ることは、ビルドと実行で得た結果 - AI に指示するだけでは出ない部分 — 案A と案B がそれぞれ別のものを守る(spill と膨らみ)ことを壊して確かめ、「数えるのは機械、上限を決めるのは人」という分担で併用を選んだ判断。ADR-068 の宿題(spill 未実測)を回収し、「人が書く数字を1か所にする」ことを設計の基準にした点
- 世界の実物と比べて何割か — smallvec の API のうち使ったのは
new・push・spilled・inline_size・as_slice・to_vecの6つとconst_generics機能。C++ のstatic_assertや LLVMSmallVectorとの比較は記憶ベースで、実在の状態機械クレート(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 で比べる設計