フェーズ: 上級者 (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回呼んで同じ結果」「文脈を変えない」 |
| 順序の検査 | S9 | 3回目の誤りでアクション列が仕様どおりの順 |
| 確保ゼロ | 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) Vec | on() が Vec<Act> を返す。Act::Audit(u32, bool) を持てる |
(c-3) SmallVec | smallvec::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つの形をマクロが生成し、残りはその上に数行で作るとします。
- どの形を「土台」にすべきか。土台から他の案を作れるか、逆は作れるか、を理由に答えてください
- 土台の形で、表の行に
Audit(c.failures + 1, false)のような引数つきのアクションを書けるように、fsm!の入力規則を書いてください。$Action::$aと完全修飾で書く Day 066 の方針は保ちます
Part 3: 壊して確かめる(予想してから実行)
次の2つを試す前に、エラー番号と、エラーがどこ(表の行か、マクロ定義か)を指すかを予想してください。
- (c-4) で、
emitの中でRecordFailureを**同じcにすぐ適用**する:s.on_emit(ev, c, &mut |a| c.apply(a, log)) - 表の行で引数の数を間違える:
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 の答え: 土台はコールバック
- コールバック (c-4) を土台にする。
on_emitからVec・SmallVecは3行で作れます(模範解答のon_vec・on_small)。逆に、Vecを土台にすると、SmallVec版もコールバック版も「確保が起きた後」にしか作れません。&'static [Act](Day 066)は値を持てないので土台になれません。情報も性能も落とさずに他を作れる形が、土台の条件です - 入力規則(模範解答の該当部分):
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 の答え(実測)
| # | 壊し方 | エラー | 指した場所 | 読み取ること |
|---|---|---|---|---|
| 1 | emit の中で同じ c に適用 | **E0500 closure requires unique access to *c but it is already borrowed** | 呼び出し側の fire_cb の行(on_emit の c とクロージャの両方に下線) | 予想しがちな E0502 ではなく E0500(クロージャが &mut を捕まえる専用の番号) |
| 2 | Audit(c.failures + 1) | **E0061 this enum variant takes 2 arguments but 1 argument was supplied** | マクロ定義の展開の行(35 行目)。表の行ではなく、in this macro invocation がマクロ呼び出し全体(73〜88 行)を囲む | help: がマクロ定義の行を書き換える提案を出す |
1 の出力:
2 の出力(抜粋):
読み取ること:
- 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) Vec | 9,999,999 | 690〜1,348 ms | 約 69〜135 ns |
| (c-3) SmallVec | 0 | 108〜142 ms | 約 11〜14 ns |
| (c-4) コールバック | 0 | 97〜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 borrowed | emit の中で文脈を書き換えた | 「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) | Go | Java |
|---|---|---|---|---|
| 値を持つアクション | 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, theSmallVecwill "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 問の自己点検
- 同じ指示を AI に出せば数時間で出るか — 「Vec と SmallVec とコールバックの比較」の一般論は出る。ただし、遅延束縛で監査値が 1 と 0 に分かれること(S12)、コールバックで文脈を直接書き換えると E0502 ではなく E0500 になること、引数の数の誤りが表の行ではなくマクロ定義の展開の行を指し
help:がマクロ定義を書き換えようとすること、1,000万ステップで Vec 版が 9,999,999 回確保すること(とその1回の差の理由)は、ビルドと計測で得た結果 - AI に指示するだけでは出ない部分 — 監査要件 A-1「判断した時点の値」を仕様として先に置き、それで (c-1') を落とした判断。「土台はコールバック、入れ物は呼び出し側」という、マクロの生成物を1つに絞る判断と、その根拠(土台から他を作れるが逆は作れない)。そして「扉の判断がサーバ側なら Vec で十分」という、ベンチの数字を前提で打ち消す記録
- 世界の実物と比べて何割か — 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 が出る場所を表の行に寄せる工夫(デバッグ・コードレビュー)