フェーズ: 上級者 (Phase 3) | 問題タイプ: 実装 | テーマ: マクロ①(宣言的マクロ macro_rules!)
📚 背景知識(読んでから問題へ)
Day 065 のコードレビューで見つかった不具合 B2(失敗回数を2回数える)の根は、**ガード(判断)の中で Cell を使って文脈を書き換えた(変更した) ことでした。模範解答では「副作用のあるガードは最後のガード行に1つだけ」という約束**で直しましたが、約束はコメントにしか残らず、次に行を足す人が破れます。
今日は Day 065 で採らなかった案 (c) を実装します。遷移関数は判断だけをして、やるべきこと(アクション)を値として返す。実行は呼び出し側が1か所で行います。
| 役割 | 誰がやるか | 文脈の受け方 |
|---|---|---|
| 判断(どこへ行くか・何をすべきか) | on()(マクロが生成) | &Ctx(読むだけ) |
| 実行(失敗回数を増やす・ブザーを鳴らす) | fire()(呼び出し側が書く) | &mut Ctx |
これは状態機械の世界では定番の形です。XState v5 のドキュメントには、純粋な遷移関数についてこうあります(参照日 2026-10-06、https://stately.ai/docs/pure-transitions 、今回取得して照合)。
"Pure transition functions allow you to compute the next state and actions of a state machine without creating a live actor or executing any side effects."
transition() は [nextState, actions] を返し、アクションは実行しません。今日の on() はその Rust 版です。
Rust ならではの論点はアクションの列をどこに置くかです。Vec<Act> で返すと呼ぶたびにヒープ確保が起きます。今日は &'static [Act] で返します。&[Act::RecordFailure, Act::SoundAlarm] のような定数だけでできた式への参照は、コンパイラが静的領域に置いてくれる(定数昇格、constant promotion)からです。The Rust Reference の条件はこうです(参照日 2026-10-06、https://doc.rust-lang.org/reference/destructors.html 、今回取得して照合)。
"the promoted expression can be evaluated at compile-time and the resulting value does not contain interior mutability or destructors"
📝 問題
環境は rustc 1.96.0、cargo new --lib actfsm(edition 2024)。依存クレートはありません。Day 064 の fsm! を出発点にします。
Part 1: マクロを拡張する
fsm! を次の形の入力を受け取れるように拡張してください。
fsm! {
context c: Ctx;
state Door { Locked, Closed, Open, Alarm }
event Ev { Unlock, Lock, Push, Pull, Reset }
action Act { RecordFailure, ResetFailures, SoundAlarm } // 新規: アクションの enum
(Locked, Unlock) if c.failures >= 3 => Alarm / [SoundAlarm]; // 新規: / [..] でアクション
(Locked, Unlock) if c.code_ok() => Closed / [ResetFailures];
(Locked, Unlock) if c.failures + 1 >= 3 => Alarm / [RecordFailure, SoundAlarm];
(Locked, Unlock) => Locked / [RecordFailure];
(Closed, Lock) => Locked; // / [..] は省略可
(Closed, Push) => Open;
(Open, Pull) => Closed;
(Alarm, Reset) if c.is_admin => Locked / [ResetFailures];
}
要件:
on(self, ev, c: &Ctx)はResult<Step<Self, Act>, Rejected<Self, Ev>>を返す。Stepはto(行き先)とactions: &'static [Act](実行順のアクション列)を持つ/ [..]を省略した行は空の列&[]を返すCtxのfailuresは **Cellではなく素のu32**。ガードからは書き換えられない- 呼び出し側の
fire(s, ev, &mut Ctx)がon()を呼び、返ってきたアクションをCtx::applyで順に実行する
Part 2: 仕様テストを通す
Day 065 の S1〜S7 を fire() 経由で書き直し、さらに次の3本を足して10本すべて通してください。
| # | 仕様 |
|---|---|
| S8 | on() は純粋: 全組み合わせ(入力3種 × 失敗回数0〜5 × 管理者2種 × 状態4 × 事象5 = 720 通り)で、2回呼んで結果が同じ、文脈も変わらない |
| S9 | 3回目の誤りでは、アクション列がちょうど [RecordFailure, SoundAlarm](この順) |
| S10 | 同じ行のアクション列は、呼ぶたびに同じアドレスを指す(ヒープ確保が起きていない証拠) |
Part 3: 壊して確かめる(予想してから実行)
次の3つを試す前に、エラー番号と、コンパイラの help: が何を提案するかを予想してください。
- 行のアクション名を
/ [RecordFailur]と打ち間違える - ガードに
if { c.failures += 1; c.failures >= 3 }と書く(Day 065 の B2 をCell無しで再現しようとする) - アクションに値を持たせて
&[Act::Log(n)](nは実行時の値)を&'static [Act]として返す
Part 4: 測る
--release で、今日の &'static [Act] 版と、同じ結果を Vec<Act> に詰め替えて返す版を、1,000万ステップずつ回して比べてください。5回測って中央値を取ります。
💡 ヒント
ヒント1(方向性)
- 新しい構文は2か所だけです: ヘッダの
action $Action:ident { .. }と、各行の末尾の省略可能な/ [ .. ] - 「省略可能な部分の中の繰り返し」は
$( ... )?の中に$( ... ),*を入れる二重の繰り返しになります fire()の中でon(ev, c)の戻り値を持ったままc.apply()(&mut)を呼べるかどうかは、戻り値がcを借りているかで決まります
ヒント2(アプローチ)
$to:identの後ろに/を置けるか:identの後ろにはどのトークンを置いてもよい(follow-set の制限が無い)。$g:exprの後ろの=>も許されている- 展開側では
&[ $( $( $Action::$a ),* )? ]と書けば、省略時は&[]になります。$Actionは深さ0、$aは深さ3(行・省略可能部分・並びの3段)のメタ変数ですが、浅いメタ変数を深い繰り返しの中で使うのは許されています $Action::$aと完全修飾で書くのは、状態名・事象名・アクション名を同じuseで glob すると名前が衝突しうるからです&[定数, 定数]は定数昇格で&'staticになります。だからStepがcを借りず、fire()でc.apply()が通ります
ヒント3(コード骨格)
macro_rules! fsm {
(
context $c:ident : $Ctx:ty ;
state $State:ident { $($S:ident),+ $(,)? }
event $Event:ident { $($E:ident),+ $(,)? }
action $Action:ident { $($A:ident),* $(,)? }
$( ($s:pat, $e:pat) $(if $g:expr)? => $to:ident $( / [ $($a:ident),* $(,)? ] )? ; )*
) => {
// ... enum 3つ ...
// match の腕:
// ($s, $e) $(if $g)? => Ok($crate::Step { to: $to, actions: &[ $( $( $Action::$a ),* )? ] }),
};
}
pub fn fire(s: Door, ev: Ev, c: &mut Ctx) -> Result<Door, Rejected<Door, Ev>> {
let step = s.on(ev, c)?;
for &a in step.actions { c.apply(a); }
Ok(step.to)
}
✅ 模範解答
src/lib.rs(このまま cargo test で検証できます):
#[derive(Debug, PartialEq, Eq)]
pub struct Rejected<S, E> {
pub state: S,
pub event: E,
}
/// 遷移1回分の結果: 行き先と、呼び出し側が実行するアクションの列
#[derive(Debug, PartialEq, Eq)]
pub struct Step<S: 'static, A: 'static> {
pub to: S,
pub actions: &'static [A],
}
#[macro_export]
macro_rules! fsm {
(
context $c:ident : $Ctx:ty ;
state $State:ident { $($S:ident),+ $(,)? }
event $Event:ident { $($E:ident),+ $(,)? }
action $Action:ident { $($A:ident),* $(,)? }
$( ($s:pat, $e:pat) $(if $g:expr)? => $to:ident $( / [ $($a:ident),* $(,)? ] )? ; )*
) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum $State { $($S),+ }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum $Event { $($E),+ }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum $Action { $($A),* }
impl $State {
/// 純粋な遷移関数: 文脈は読むだけ。副作用はアクションとして返す
#[deny(unused_variables, unreachable_patterns)]
pub fn on(self, ev: $Event, $c: &$Ctx)
-> ::core::result::Result<
$crate::Step<Self, $Action>,
$crate::Rejected<Self, $Event>>
{
#[allow(unused_imports)]
use {$State::*, $Event::*};
match (self, ev) {
$( ($s, $e) $(if $g)? => ::core::result::Result::Ok($crate::Step {
to: $to,
actions: &[ $( $( $Action::$a ),* )? ],
}), )*
(state, event) => ::core::result::Result::Err($crate::Rejected { state, event }),
}
}
}
};
}
pub mod door {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ctx {
pub entered: Option<String>,
pub expected: String,
pub failures: u32, // Cell ではない: on() からは書き換えられない
pub is_admin: bool,
}
impl Ctx {
pub fn new(entered: Option<&str>, failures: u32, is_admin: bool) -> Self {
Ctx { entered: entered.map(String::from), expected: "1234".into(), failures, is_admin }
}
pub fn code_ok(&self) -> bool {
self.entered.as_deref() == Some(self.expected.as_str())
}
/// アクションを1つ実行する(副作用はここだけ)
pub fn apply(&mut self, a: Act) {
match a {
Act::RecordFailure => self.failures += 1,
Act::ResetFailures => self.failures = 0,
Act::SoundAlarm => {} // 実機ならブザー。今日は記録しない
}
}
}
crate::fsm! {
context c: Ctx;
state Door { Locked, Closed, Open, Alarm }
event Ev { Unlock, Lock, Push, Pull, Reset }
action Act { RecordFailure, ResetFailures, SoundAlarm }
(Locked, Unlock) if c.failures >= 3 => Alarm / [SoundAlarm];
(Locked, Unlock) if c.code_ok() => Closed / [ResetFailures];
(Locked, Unlock) if c.failures + 1 >= 3 => Alarm / [RecordFailure, SoundAlarm];
(Locked, Unlock) => Locked / [RecordFailure];
(Closed, Lock) => Locked;
(Closed, Push) => Open;
(Open, Pull) => Closed;
(Alarm, Reset) if c.is_admin => Locked / [ResetFailures];
}
/// 呼び出し側の1ステップ: 判断(on)と実行(apply)をここで分ける
pub fn fire(s: Door, ev: Ev, c: &mut Ctx)
-> Result<Door, crate::Rejected<Door, Ev>>
{
let step = s.on(ev, c)?; // ここでは &Ctx として借りるだけ
for &a in step.actions { // actions は &'static なので c の借用は残らない
c.apply(a);
}
Ok(step.to)
}
}
#[cfg(test)]
mod tests {
use super::door::*;
#[test]
fn s1_correct_code_opens() {
let mut c = Ctx::new(Some("1234"), 0, false);
assert_eq!(fire(Door::Locked, Ev::Unlock, &mut c), Ok(Door::Closed));
}
#[test]
fn s2_no_input_stays_locked() {
let mut c = Ctx::new(None, 0, false);
assert_eq!(fire(Door::Locked, Ev::Unlock, &mut c), Ok(Door::Locked));
}
#[test]
fn s3_one_failure_counts_once() {
let mut c = Ctx::new(Some("0000"), 0, false);
assert_eq!(fire(Door::Locked, Ev::Unlock, &mut c), Ok(Door::Locked));
assert_eq!(c.failures, 1);
}
#[test]
fn s4_third_failure_alarms() {
let mut c = Ctx::new(Some("0000"), 0, false);
let r: Vec<_> = (0..3).map(|_| fire(Door::Locked, Ev::Unlock, &mut c)).collect();
assert_eq!(r, vec![Ok(Door::Locked), Ok(Door::Locked), Ok(Door::Alarm)]);
}
#[test]
fn s5_lockout_beats_correct_code() {
let mut c = Ctx::new(Some("1234"), 3, false);
assert_eq!(fire(Door::Locked, Ev::Unlock, &mut c), Ok(Door::Alarm));
}
#[test]
fn s6_unlock_never_rejected() {
for f in 0..6 {
let mut c = Ctx::new(Some("0000"), f, false);
assert!(fire(Door::Locked, Ev::Unlock, &mut c).is_ok(), "failures={f}");
}
}
#[test]
fn s7_only_admin_resets() {
let mut c = Ctx::new(None, 3, false);
assert!(fire(Door::Alarm, Ev::Reset, &mut c).is_err());
let mut a = Ctx::new(None, 3, true);
assert_eq!(fire(Door::Alarm, Ev::Reset, &mut a), Ok(Door::Locked));
assert_eq!(a.failures, 0);
}
// ---- 今日の追加: S8〜S10 ----
const STATES: [Door; 4] = [Door::Locked, Door::Closed, Door::Open, Door::Alarm];
const EVENTS: [Ev; 5] = [Ev::Unlock, Ev::Lock, Ev::Push, Ev::Pull, Ev::Reset];
fn contexts() -> Vec<Ctx> {
let mut v = Vec::new();
for e in [None, Some("1234"), Some("0000")] {
for f in 0..6 {
for adm in [false, true] {
v.push(Ctx::new(e, f, adm));
}
}
}
v
}
/// S8: on() は純粋(同じ入力で同じ結果、文脈を変えない)を全組み合わせで
#[test]
fn s8_on_is_pure() {
let mut n = 0;
for c in contexts() {
for s in STATES {
for e in EVENTS {
let before = c.clone();
let r1 = s.on(e, &c);
let r2 = s.on(e, &c);
assert_eq!(r1, r2);
assert_eq!(c, before);
n += 1;
}
}
}
assert_eq!(n, 36 * 4 * 5);
}
/// S9: アクションの列が仕様どおり(警報の行は記録→警報の順)
#[test]
fn s9_actions_in_order() {
let c = Ctx::new(Some("0000"), 2, false);
let st = Door::Locked.on(Ev::Unlock, &c).unwrap();
assert_eq!(st.to, Door::Alarm);
assert_eq!(st.actions, &[Act::RecordFailure, Act::SoundAlarm]);
let c = Ctx::new(Some("0000"), 0, false);
assert_eq!(Door::Closed.on(Ev::Push, &c).unwrap().actions, &[] as &[Act]);
}
/// S10: アクション列は静的領域に置かれ、呼ぶたびに同じアドレス(確保が起きていない)
#[test]
fn s10_actions_are_static() {
let c = Ctx::new(Some("0000"), 0, false);
let a = Door::Locked.on(Ev::Unlock, &c).unwrap().actions;
let b = Door::Locked.on(Ev::Unlock, &c).unwrap().actions;
assert!(std::ptr::eq(a, b));
println!("size_of Step = {}", std::mem::size_of::<crate::Step<Door, Act>>());
println!("size_of Result = {}", std::mem::size_of::<Result<crate::Step<Door, Act>, crate::Rejected<Door, Ev>>>());
}
}
Part 2 の答え: 10本すべて通過(実測、rustc 1.96.0、edition 2024、Apple M1)
running 10 tests
size_of Step = 24
size_of Result = 24
..........
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
cargo clippy --all-targets も警告 0 件(実測)。
- S8 は 720 通りを回して、
on()が文脈を一度も変えないことを確かめています。Day 065 のCell版では、この性質は型では保証されず、テストで確かめるしかありませんでした。今日の版ではCtxにCellが無いので、このテストが落ちるコードはそもそもコンパイルできません(Part 3-2 で実測)。S8 は「型が保証していることを念のため確かめる」テストに格下げされました size_of::<Step<Door, Act>>()は 24 バイト(&[Act]が 16 バイト=ポインタ+長さ、Doorが 1 バイト、残りは整列の詰め物)。Result<Step, Rejected>も 24 バイトで、Resultの判別用の場所が詰め物の中に収まっています(実測。どの位置に置かれたかは未確認)
Part 3 の答え: 3つとも止まる。ただし 2 の help: は採ってはいけない(実測)
| # | 壊し方 | エラー | 指した場所 | help: の提案 | 採ってよいか |
|---|---|---|---|---|---|
| 1 | / [RecordFailur] | E0599 no variant ... named RecordFailur found for enum Act | 呼び出し側の表の行(src/lib.rs:86:62)と、マクロ定義の pub enum $Action の行(28行)の両方 | there is a variant with a similar name → RecordFailure | 採ってよい |
| 2 | ガードで c.failures += 1 | E0594 cannot assign to c.failures, which is behind a & reference | 呼び出し側の表の行(src/lib.rs:85:31) | **マクロ定義の on() を $c: &mut $Ctx に変えよ**(33行目) | 採ってはいけない |
| 3 | &[Act::Log(n)] を &'static で返す | E0515 cannot return reference to temporary value | 配列式全体 | (提案なし。temporary value created here とだけ) | — |
2 の出力(抜粋):
読み取ること:
- 1 と 2 を同時に入れると、E0599(名前解決)だけが先に出て、E0594(借用検査)は直した後に初めて出ました(実測)。Day 065 の「エラーは段階ごとに1つずつ」と同じです
- 2 の
help:は、今日わざわざ閉じた穴(ガードから文脈を書き換えられる)を、マクロ定義ごと開け直す提案です。コンパイラは「この&は設計上の意図だ」とは知りません。Day 065 の「help:は通すための提案で、仕様を満たすための提案ではない」の、マクロ版です - 3 は、なぜ今日のアクションを値を持たない unit variant に限ったかの答えです。
Act::Log(3)のような定数なら昇格して通りますが、Act::Log(n)は実行時の値なので静的領域に置けません(同じファイルのkonst()は通り、runtime(n)だけが E0515、実測)
3 の検証に使ったファイル(rustc --edition 2024 a.rs):
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Act { RecordFailure, Log(u32) }
pub fn konst() -> &'static [Act] { &[Act::RecordFailure, Act::Log(3)] }
pub fn runtime(n: u32) -> &'static [Act] { &[Act::RecordFailure, Act::Log(n)] }
fn main() { println!("{:?} {:?}", konst(), runtime(1)); }
Part 4 の答え: 静的スライス版が約10倍速い(実測、ただしばらつき大)
examples/bench.rs(cargo run --release --example bench):
use actfsm::door::*;
use std::hint::black_box;
use std::time::Instant;
const N: u32 = 10_000_000;
// 案(c)-静的: on() が &'static [Act] を返す(今日の模範解答)
fn run_static(c: &mut Ctx) -> u64 {
let mut s = Door::Locked;
let mut acc = 0u64;
for i in 0..N {
let ev = if s == Door::Alarm { Ev::Reset } else { Ev::Unlock };
c.is_admin = i % 2 == 0;
let st = black_box(s).on(black_box(ev), c).unwrap_or(actfsm::Step { to: s, actions: &[] });
for &a in st.actions { c.apply(a); acc += 1; }
s = st.to;
}
acc
}
// 案(c)-Vec: 同じ結果を Vec<Act> に詰め替えて返す(毎回ヒープ確保が起きる形)
fn on_vec(s: Door, ev: Ev, c: &Ctx) -> (Door, Vec<Act>) {
match s.on(ev, c) { Ok(st) => (st.to, st.actions.to_vec()), Err(_) => (s, Vec::new()) }
}
fn run_vec(c: &mut Ctx) -> u64 {
let mut s = Door::Locked;
let mut acc = 0u64;
for i in 0..N {
let ev = if s == Door::Alarm { Ev::Reset } else { Ev::Unlock };
c.is_admin = i % 2 == 0;
let (to, acts) = on_vec(black_box(s), black_box(ev), c);
for a in acts { c.apply(a); acc += 1; }
s = to;
}
acc
}
fn main() {
for name in ["static", "vec"] {
let mut ts = Vec::new();
let mut acc = 0;
for _ in 0..5 {
let mut c = Ctx::new(Some("0000"), 0, false);
let t = Instant::now();
acc = if name == "static" { run_static(&mut c) } else { run_vec(&mut c) };
ts.push(t.elapsed().as_secs_f64() * 1e3);
}
ts.sort_by(|a, b| a.partial_cmp(b).unwrap());
println!("{name:6} median {:.1} ms (min {:.1} / max {:.1}) actions={acc}", ts[2], ts[0], ts[4]);
}
}
結果(実測、Apple M1・8GB、他の作業と並行。2回実行):
static median 222.0 ms (min 209.9 / max 348.1) actions=12499999
vec median 2278.7 ms (min 1638.5 / max 2591.3) actions=12499999
static median 190.6 ms (min 157.4 / max 461.9) actions=12499999
vec median 3627.5 ms (min 1714.5 / max 4358.0) actions=12499999
| 版 | 1,000万ステップの中央値 | 1ステップあたり(導出) |
|---|---|---|
&'static [Act] | 191〜222 ms | 約 19〜22 ns |
Vec<Act> | 2,279〜3,628 ms | 約 230〜360 ns |
- 両方とも実行されたアクションは 12,499,999 個で一致(同じ仕事をしている確認)
- 差は約 10〜16 倍。ただし最大値と最小値の開きが大きく(static で 157〜462 ms)、測定機が静かでないことが分かります。「10倍前後」より細かい数字は主張しません
Vec版の遅さの中身(確保・解放のどちらが重いか、空のVec::new()は確保しないのでErrの回は安い)は分解していません
🪜 Step-by-Step 解説
Step 1: 二重の繰り返しで「省略可能なアクション列」を受ける
$( ($s:pat, $e:pat) $(if $g:expr)? => $to:ident $( / [ $($a:ident),* $(,)? ] )? ; )*
- 外側
$( ... )*が行の繰り返し(1段目)、$( / [ ... ] )?が省略可能部分(2段目、?も1段に数える)、その中の$($a:ident),*がアクション名の並び(3段目)。展開側でも$aを同じ3段で包む - 展開側の
&[ $( $( $Action::$a ),* )? ]は、入力と同じ入れ子で$aを包みます。$Actionは外側(深さ0)のメタ変数なので、どの深さの中でも使えます $to:identの直後の/は、identフラグメントに follow-set の制限が無いので許されます。もし$to:exprにしていたら/は「割り算の続き」と区別できず、マクロ定義の時点で弾かれます(実測:error: `$to:expr` may be followed by `/`, which is not allowed for `expr` fragments、note: allowed there are: =>, , or ;)。**行き先をidentにしたことが、この構文を可能にしている**
Step 2: アクション列を &'static に置く
&[Act::RecordFailure, Act::SoundAlarm] は、すべて定数・Drop 無し・内部可変性無しなので定数昇格し、&'static [Act] になります。だから:
on()の戻り値は**引数cを借りていない**(ライフタイムが'static)fire()でlet step = s.on(ev, c)?;の後、stepを持ったままc.apply(a)(&mut)を呼べる- 確保が起きない(S10 で同じアドレスを実測、Part 4 で Vec 版より約10倍速い)
Step 3: Ctx から Cell を外す
Day 065 では、ガードの中で数えるために failures: Cell<u32> が必要でした。今日は数える仕事を RecordFailure アクションに移したので、failures: u32 で足ります。on() は &Ctx しか持たないので、**ガードの中で c.failures += 1 と書けば E0594 で止まる**(Part 3-2)。Day 065 で「最後のガード行に1つだけ」とコメントで頼んでいた約束が、型の制約になりました。
Step 4: 「次に失敗したら何回目か」をガードで読む
3回目の誤りを判定するガードは c.failures + 1 >= 3 です。まだ数えていない(数えるのは後で実行されるアクション)ので、「この誤りを数えたら何回目か」を + 1 で先読みします。これは今日の設計の代償です。判断の時点では、まだ実行されていない副作用の結果を自分で計算する必要があります(下の「迷った他案」の (b) と同じ悩み)。
Step 5: 判断と実行をつなぐのは呼び出し側の数行
let step = s.on(ev, c)?;
for &a in step.actions { c.apply(a); }
Ok(step.to)
ここが副作用の唯一の入口です。ログを取りたい・テストでアクションを記録したい・本番ではブザーを鳴らしたい、といった差し替えはこの数行(と apply)だけを変えれば済み、遷移表には触りません。
🧭 設計思想・なぜこう書くのか
Rust の & と &mut の区別は、「読むだけの関数」と「書き換える関数」を型で分けるためにあります。Day 065 の PR は Cell で & 越しの書き換えを持ち込み、その区別を自分で外していました。今日の実装は区別を戻し、**判断は &、実行は &mut** という形にそのまま写しています。
さらに、アクションを &'static [Act] で返すことで、「判断の結果が文脈を借りない」こともコンパイラに見える形になりました。これはゼロコスト抽象化の典型例です。判断と実行を分けるという設計上の抽象化を入れても、実行時のコスト(確保)は増えていません(S10・Part 4)。
迷った他案と採らなかった理由
| 案 | 中身 | 採らなかった理由 |
|---|---|---|
| (c-1) 今日の模範解答 | &'static [Act]、アクションは unit variant のみ | 採用。 確保ゼロ、on() が文脈を借りない、ガードの副作用は型で禁止。代償は「値を持つアクションを書けない」(E0515、Part 3-3)と「ガードで + 1 の先読みが要る」(Step 4) |
(c-2) Vec<Act> で返す | actions: Vec<Act>、Act::Log(c.failures) のような値も持てる | 毎回確保が起きる(約10倍遅い、実測)。組み込み・no_std(Phase 4)では alloc が要る。値を持つアクションが必要になったら移る |
(c-3) 固定長の配列 [Option<Act>; N] や arrayvec | 確保なしで値も持てる | N(1行あたりの最大アクション数)をマクロが数える必要があり、Day 059 の数え上げの複雑さが戻る。外部クレート依存も増える。未実装 |
(c-4) コールバック on(ev, &Ctx, &mut impl FnMut(Act)) | 返さずに、その場で呼び出し側の関数に渡す | 確保なしで値も持てるが、on() の中から外の可変状態に触れる道が復活し、S8(2回呼んで同じ)の意味が薄れる。テストでアクション列を assert_eq! で比べにくい |
| (b) アクション無し、呼び出し側が結果の状態を見て数える | on() は状態だけ返し、fire() が「Locked→Locked なら失敗」と推測する | 「失敗とは何か」が表と fire() の2か所に分かれる。Locked→Alarm が「3回目の誤り」なのか「締め出し済み」なのか(記録すべきか否か)を状態だけでは区別できない。今日の S5 と S9 の違いがまさにそれで、(b) では書けない |
前提が変わったら: 監査ログのように実行時の値を持つアクション(誰が・何番を入力したか)が必要になったら (c-2) に移ります。1行のアクション数が小さく固定で、確保を避けたい組み込み向けなら (c-3)。逆に、アクションが不要な表(今日の Closed・Open の行のように行き先だけ)しか無いなら、Day 064 の形のままで十分です。
🚨 コンパイルエラーが出た場合
| エラー | 出た原因 | 読み方 | 正しい直し方 |
|---|---|---|---|
E0599 no variant ... named RecordFailur found for enum Act | 行のアクション名の打ち間違い | 2か所を指す: 表の行(打ち間違えた場所)とマクロ定義の pub enum $Action(enum が作られた場所)。直すのは表の行 | help: どおり RecordFailure |
E0594 cannot assign to c.failures, which is behind a & reference | ガードで文脈を書き換えようとした | 「& しか持っていないので書けない」。今日の設計どおりに止まっている | 書き換えをアクションに移す(/ [RecordFailure])。**help: の &mut 化は採らない** |
E0515 cannot return reference to temporary value | 実行時の値を含む配列を &'static で返した | temporary value created here が配列式全体を指す。定数昇格の条件を満たしていない | アクションを unit variant に限るか、(c-2) の Vec に移る |
`$to:expr` may be followed by `/`, which is not allowed for `expr` fragments | マクロ定義で行き先を $to:expr にした | 呼び出す前、定義の時点で出る。note: が後ろに置けるトークン(=> , ;)を列挙する | 行き先は $to:ident |
no rules expected `/` / note: while trying to match `=>` | 表の行で (Closed, Push) / [SoundAlarm] => Open; と / を前に置いた | note: がマクロ定義側の「ここでは => を待っていた」位置を指す | / [..] は行き先の後ろ、; の直前 |
最後の2行も今日実行して出力を確かめました(rustc 1.96.0)。
🔀 他言語との比較
| 観点 | Rust(今日の fsm!) | TypeScript(XState v5) | Go | Java |
|---|---|---|---|---|
| 判断と実行の分離 | on() が Step { to, actions } を返し、fire() が実行 | transition() が [nextState, actions] を返し、実行しない(一次資料で確認) | 慣習で func (s State) On(ev, ctx) (State, []Action) と書く | 同様に record で返す |
| 判断が文脈を書き換えないことの保証 | &Ctx で型が保証(E0594、実測) | assign は action object を返す宣言的 API だが、ガード関数の中で context を書き換えるのは言語では止まらない | ポインタで渡せば書き換え可能。値渡しなら呼び出し側には影響しない(コピーのコスト) | final はフィールドの再代入だけを止める。中身の可変性は止まらない |
| アクション列の確保 | 定数昇格で 0 回(S10 実測) | 配列オブジェクトを毎回生成(GC) | スライスリテラルはエスケープ解析次第で確保されうる | List.of(...) は毎回生成(定数にすれば共有可) |
TypeScript の「ガードの中の書き換えは止まらない」、Go・Java の列は今日は実行していません(言語仕様の記憶に基づく要旨)。XState の行は、上の一次資料で確認した範囲です。
🏭 実務での使いどころ
- 決済・認証の状態機械: 「ロックアウトの判定」と「失敗回数の永続化(DB 書き込み)」を分けると、判定ロジックを DB 無しでテストできます(S8 のように全組み合わせを回せる)。今日の
applyを「DB に書く」に差し替えるだけです - Elm アーキテクチャ/Redux の reducer と同じ形:
update(msg, model) -> (model, Cmd)のCmdが今日のアクション列です。UI 以外でも、イベントソーシングやワークフローエンジン(Temporal のワークフロー関数は決定的でなければならない、という制約)と同じ考え方です - リプレイとテスト: アクションが値なので、本番のイベント列をテストで再生し、「どのアクションが出たか」を
assert_eq!で比べられます(S9) - 組み込み:
&'static [Act]は確保が無いので、no_stdの環境(Phase 4 予定)でもそのまま使えます
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
「&[...] を返すと、ローカル変数への参照になってダングリングする」 | C++ の感覚で、関数内の配列は関数を抜けたら消えると思う | 定数だけの式なら定数昇格で 'static になる(S10 で同じアドレスを実測)。実行時の値を含むと E0515 |
「コンパイラの help: に従えば設計どおりになる」 | 提案を正解だと思う | E0594 の提案はマクロを &mut にせよというもので、設計の目的そのものを壊す(実測) |
| 「アクションに分ければ、ガードは何も気にしなくてよい」 | 副作用が消えたので安心する | まだ実行されていない副作用の結果を + 1 で先読みする必要がある。先読みを間違えると S4 が落ちる |
「Vec でも大差ない」 | 確保は速いと思う | 今日の条件で約10倍(実測、ばらつき大)。1ステップの仕事が小さいほど確保の比率が上がる |
| 「状態を見れば、何が起きたか分かる」(案 (b)) | 状態だけ返せば十分だと思う | Locked→Alarm には「3回目の誤り(数える)」と「締め出し済み(数えない)」の2種類がある。アクションが無いと区別できない |
📎 一次資料(参照日: 2026-10-06。今回取得して、引用部分を照合済み)
- The Rust Reference, Destructors — Constant promotion: https://doc.rust-lang.org/reference/destructors.html
- "the promoted expression can be evaluated at compile-time and the resulting value does not contain interior mutability or destructors"
- Part 3-3 の
konst()(通る)とruntime(n)(E0515)の対比は、この条件を手元で確かめたもの
- XState v5, Pure transition functions: https://stately.ai/docs/pure-transitions
- "Pure transition functions allow you to compute the next state and actions of a state machine without creating a live actor or executing any side effects."
transition()は[nextState, actions]を返し、アクションは実行しない(同ページ)
- XState v5, Actions: https://stately.ai/docs/actions
- "Actions are fire-and-forget effects."
🚧 今日扱わなかった範囲(次回以降)
| 扱わなかったこと | 理由・次の予定 |
|---|---|
値を持つアクション(Log(u32))を Vec 版・arrayvec 版で実装して比べる | 今日は unit variant に絞った(E0515 で理由を実測)。必要になる題材が出たら |
Part 4 のばらつきの原因・Vec 版の確保と解放の内訳 | M1 8GB で他の作業と並行して測った。静かな機械(Mac mini)で取り直す余地あり |
Result<Step, Rejected> が 24 バイトに収まる位置(判別値の置き場所) | 大きさだけ実測。レイアウトは未確認 |
| エントリー/エグジットアクション(状態に入る・出るときのアクション) | XState・UML にはあるが、今日は遷移アクションのみ |
実在クレート(rust-fsm・statig)がアクション/出力をどう返すかの照合 | 次の概念理解の日に一次資料を取得して読む |
Day 065 からの持ち越し(as_mut() 提案、clippy がマクロ内で黙る理由、並行アクセス) | 今日は案 (c) の実装に集中 |
3 問の自己点検
- 同じ指示を AI に出せば数時間で出るか — 「アクションを返す状態機械マクロ」自体は出る。ただし、E0594 の
help:がマクロ定義を&mutに変えよと提案すること(設計を壊す提案)、E0599 が表の行とマクロ定義の両方を指すこと、2つ同時に入れると名前解決のエラーしか出ないこと、静的スライス版がVec版の約10倍速いこと(ばらつき込み)は、実際にビルド・計測して得た結果 - AI に指示するだけでは出ない部分 — Day 065 の不具合 B2 から出発し、「約束(コメント)→ 型の制約(
&Ctx+アクション)」へ移した判断の筋道と、5案(c-1〜c-4・b)の採否、特に (b) が S5 と S9 を区別できないという具体的な却下理由 - 世界の実物と比べて何割か — XState のアクションの概念のうち、扱ったのは「遷移アクション」と「純粋な遷移関数が actions を返す」の2点。エントリー/エグジットアクション、
assign(文脈の更新をアクションとして書く)、遅延アクションなどは扱っていない。UML ステートマシンの遷移アクション・entry/exit/do のうち扱ったのは遷移アクションの1つ
➡️ 次のステップ
- 発展: アクションに値を持たせたい場合(監査ログに入力番号を残す)を、(c-2)
Vecと (c-4) コールバックの両方で実装し、S8〜S10 のどれが成り立たなくなるかを確かめる - 次回予告: マクロ① の残り期間で、実在クレート(
rust-fsm・statig・bitflags)のマクロの一次資料を取得して読み、Day 063 の ADR の4基準と、今日の「アクションを返すか」の観点で照合する(概念理解)