Day 067 — 2026-10-07 | マクロ① macro_rules! ⑫ 実在クレートのマクロを読む:bitflags・rust-fsm・statig を ADR-063 と「アクションをどこに置くか」で照合する(扉を rust-fsm の形に移すと状態 4→6・入力 5→7・RecordFailure が消える/bitflags_match! の腕の return は関数から抜けない)

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

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

Day 059〜066 で自作した fsm! は、世界で使われている実物と比べてどの位置にいるのか。今日はそれを確かめる日です。照合の物差しは2本です。

  1. Day 063 の ADR-063(マクロを追加してよい基準) — 関数・型で書けない次の4つの場合だけマクロにする。

①呼び出し元の制御フローを変え、かつ ? で書けない ②引数の評価を遅らせ、かつクロージャで書けない ③stringify!/line! などソース情報が要る ④struct・impl・enum などの item を生成する

  1. Day 066 の「アクション(副作用)をどこに置くか」 — (c-1) &'static [Act] で返す(採用)/(c-2) Vec で返す/(c-4) コールバックで渡す/(b) 返さない。加えて Day 065 の「ガードの中で文脈を書き換える」(Cell)

読む相手は3つです。選んだ理由は、bitflags は macro_rules! だけで item を大量に生成する定番クレート(このマシンのローカルのレジストリにも 1.3.2・2.13.1・2.13.2 の3版が入っており、他のクレートの依存として引き込まれている)で、rust-fsm と statig は fsm! と同じ「状態機械の表を書く」問題を別の作り方で解いているからです。

クレート今日読んだもの(参照日 2026-10-07)マクロの種類
bitflags 2.13.2ローカルの ~/.cargo/registry/src/*/bitflags-2.13.2/src/lib.rs(ソースを直接読んだ)macro_rules!(src/ 全体で 26 個、実測 grep -rn "macro_rules!" src | wc -l)
rust-fsm 0.8 系GitHub eugene-babichenko/rust-fsm の README.md・rust-fsm/src/lib.rs・rust-fsm-dsl/src/lib.rs(master)#[proc_macro](syn・quote を使用)
statigGitHub mdeloof/statig の README.md(main)属性 proc-macro #[state_machine]

今日の問題は「読む」ことが中心ですが、読んだ主張のうち手元で確かめられるものは実行して確かめました(Part 3・Part 4)。

📝 問題

Part 1: ADR-063 で仕分ける

次の4つのマクロは、ADR-063 の基準 ①〜④ のどれでマクロであることを正当化できますか。複数当てはまるなら全部挙げてください。また、macro_rules! か proc-macro かを答え、proc-macro なら「なぜ macro_rules! で書かなかったのか」を一次資料の文から推測してください。

#マクロ使い方(一次資料の例を短縮)
M1bitflags!bitflags! { struct Flags: u8 { const A = 1; const B = 1 << 1; const _ = !0; } }
M2bitflags_match!bitflags_match!(flags, { Flags::A | Flags::B => .., _ => .. })
M3rust_fsm::state_machine!Closed(Unsuccessful) => Open [SetupTimer],
M4#[statig::state_machine]impl Blinky { #[state] fn led_on(event: &Event) -> Outcome<State> { .. } }

Part 2: アクションはどこにあるか

rust-fsm の中核の型はこうです(rust-fsm/src/lib.rs から引用、今回取得)。

pub trait StateMachineImpl {
    type Input;
    type State;
    type Output;
    const INITIAL_STATE: Self::State;
    fn transition(state: &Self::State, input: &Self::Input) -> Option<Self::State>;
    fn output(state: &Self::State, input: &Self::Input) -> Option<Self::Output>;
}

statig の状態ハンドラはこうです(README.md から引用)。

#[state]
fn led_on(context: &mut Context, event: &Event) -> Outcome<State> {
    context.do_something();
    Handled
}

問い:

  1. rust-fsm・statig・XState v5(Day 066 で読んだ transition() が [nextState, actions] を返す)・Day 066 の fsm! の4つを、「判断と実行が分かれているか」「1回の遷移で出せるアクションの数」「ガードが文脈を読めるか」の3列で表にしてください
  2. statig の形で Day 065 の不具合 B2(失敗回数を2回数える)は起こりえますか。型で止まりますか

Part 3: 扉を rust-fsm の形に移す(予想してから確かめる)

Day 066 の扉(状態4・事象5・文脈 failures/code_ok/is_admin)を、上の StateMachineImpl の形(transition と output は状態と入力しか見ない)に移すとします。実行する前に予想してください。

  1. 状態はいくつ、入力はいくつになるか。failures と code_ok() と is_admin はそれぞれどこへ行くか
  2. Day 066 のアクション RecordFailure・ResetFailures・SoundAlarm のうち、残るものはどれか
  3. Day 066 の S5(「失敗3回の状態で正しい番号を入れても警報」)は、移した後どう書けるか
  4. 締め出しの回数を 3 から 10 に変えたら、状態の数と行数はどう変わるか

Part 4: 実物のマクロの書き方を予想する

  1. bitflags! は旗の名前を $Flag:ident ではなく const $Flag:tt = $value:expr; で受けています(src/lib.rs 484 行目)。なぜ ident ではだめなのか。ident にしたら何が出るか予想してください
  2. bitflags_match! は展開を (|| { ... })() というクロージャで包んでいます(ソースのコメント: "Expand to a closure so we can use return")。次の関数の early(Perm::R) は何を返すか予想してください
pub fn early(p: Perm) -> i32 {
    let v: i32 = bitflags_match!(p, {
        Perm::R => { return 1; },
        _ => 2,
    });
    v * 100
}

Part 5: 判断を記録する

「自作の fsm! を使い続けるか、rust-fsm か statig に乗り換えるか」の ADR を 10 行以内で書いてください。採らなかった案の却下理由と、前提が変わったら判断がどう変わるかを必ず含めること。

💡 ヒント

ヒント1(方向性)
  • ADR-063 の ④(item を生成する)は、「関数の中からは struct や enum を宣言して外へ出せない」ことが理由でした。生成しているものを見れば分類できます
  • 「判断と実行が分かれているか」は、遷移を決める関数のシグネチャが & か &mut か、そして戻り値にアクションが含まれるかで読めます
  • Part 3 は、「文脈が無い」なら文脈の情報をどこかに移すしかない、という問題です。行き先は状態か入力の2つしかありません
ヒント2(アプローチ)
  • rust-fsm の README に "Using the DSL will require to connect an additional crate rust-fsm-dsl (this is due to limitation of the procedural macros system)." とあります。proc-macro は専用のクレートにしか置けない、という Rust の制約の話です
  • Option<Self::Output> は「0個か1個」。複数のアクションを出したいなら、Output の側を工夫するしかありません
  • _ は Rust の字句上「予約された識別子」で、ident フラグメントには合いません
  • クロージャの中の return は、クロージャから抜けます。外側の関数からではありません
ヒント3(コード骨格)

Part 3 の確認用(rust-fsm のクレートは入れず、上の trait と同じ形を手で書く):

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum S { Locked0, Locked1, Locked2, Closed, Open, Alarm }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum I { UnlockOk, UnlockNg, Lock, Push, Pull, ResetAdmin, ResetUser }
// transition(&S, &I) -> Option<S> と output(&S, &I) -> Option<O> を match で書く

✅ 模範解答

Part 1 の答え

#マクロ種類ADR-063根拠(一次資料・実測)
M1bitflags!macro_rules!④pub struct $BitFlags・impl・定数を生成する。さらに内部用の型 InternalBitFlags を const _: () = { ... }; の中に生成し、利用者の API から隠している(src/lib.rs 536 行目付近、コメント "Declared in a "hidden" scope that can't be reached directly")
M2bitflags_match!macro_rules!①(と、match では書けない比較の連鎖)ドキュメントの "The macro expands to a series of if statements, checking equality between the input expression and each pattern." 。match はパターンに定数の ` を書くと「どれかに一致」になり、「A と B の両方が立っている値と等しい」を表せない。腕の中の return` を受けるためにクロージャで包む(制御フローを変える)
M3rust_fsm::state_machine!#[proc_macro]④enum 3つ(状態・入力・出力)と impl StateMachineImpl for Impl を生成する(rust-fsm-dsl/src/lib.rs)。proc-macro なので別クレート rust-fsm-dsl に置かれ、README はそれを "limitation of the procedural macros system" と書いている
M4#[statig::state_machine]属性 proc-macro④README: the macro "simply parses the underlying impl block and derives some code based on its content." 。impl ブロックの関数(#[state])から状態の enum を作る

読み取ること:

  • 4つとも ADR-063 のどれかで説明でき、「短く書けるから」だけのものは無い。 自作の fsm! も ④(enum 3つと impl を生成)で、同じ位置にいます
  • M3 が macro_rules! でない理由は README の文からは断定できません。推測(未検証): Closed(Unsuccessful) => Open [SetupTimer] と HalfOpen => { Successful => Closed, ... } の2つの書き方を混ぜられる構文や、全行から状態名を重複なく集めて enum を作る処理は、macro_rules! では Day 059 の数え上げ・重複除去と同じ苦労になります。自作の fsm! が state Door { .. } と状態を先に宣言させるのは、この重複除去を避けるための選択でした(Day 064)
  • M2 は Day 061 の持ち越し「$s:expr で定数パスを受ける if x == $s 連鎖版」の、実物の実装例です。bitflags は連鎖の順序が意味を持つことをドキュメントに明記しています: "Patterns are evaluated in the order they appear in the macro."

Part 2 の答え

判断と実行1回の遷移で出せるアクションガードが文脈を読めるかDay 066 の案でいうと
Day 066 fsm!分かれている(on(&Ctx) が &'static [Act] を返し、fire が実行)0〜n 個(unit variant のみ)読める(&Ctx)(c-1)
rust-fsm分かれている(output() が値を返し、実行は呼び出し側。consume は Result<Option<Output>, TransitionImpossibleError> を返す)0〜1 個(Option)読めない(transition・output の引数は状態と入力だけ)(c-1) の「1個・文脈なし」版。Output は値で返るので値を持つ variant も型としては置ける(DSL から書けるかは未確認)
statig分かれていない(ハンドラが &mut Context を受け、その場で context.do_something())ハンドラ内で何個でも(返り値は Transition/Handled/Super のみ)読める・書ける(&mut)Day 065 の Cell 版に近い。ただし &mut が型に見えている
XState v5分かれている(transition() が [nextState, actions])0〜n 個読める(c-1)〜(c-2)

2 の答え: 起こりえて、型では止まりません。 statig のハンドラは &mut Context を受けるので、「判定の前に数える」と「判定の後に数える」を同じハンドラの中で2回書けば2回数えます。ただし Day 065 と違う点が2つあります。

  • statig のハンドラは普通の Rust の関数で、ガードは match と if で書きます。Day 065 の B2 は「| で並べた腕でガードが2回評価される」というマクロが作った match の性質から来ていましたが、statig では1つの事象につきハンドラが1回呼ばれるだけなので、その経路は無い(README の構造からの推論。statig を動かしての確認はしていない)
  • &mut がシグネチャに見えているので、レビューで「この関数は文脈を書き換える」と分かる。Day 065 の &Ctx + Cell は、シグネチャが「読むだけ」と嘘をついていた

statig が判断と実行を分けないのは欠陥ではなく、選択です。 statig は README で "Compatible with #![no_std], state machines are defined in ROM and no heap memory allocations." と書くとおり組み込みを主な対象にしています。組み込みでは「その場でピンを叩く」方が自然で、アクションを値にして返す層は余分になりやすい。逆に、Day 066 の S8(720 通りで on() が純粋)のような全数検査は、statig の形では文脈のスナップショットを比べる必要があり、手間が増えます。

Part 3 の答え(実測、rustc 1.96.0、edition 2024、Apple M1)

問い答え
状態・入力の数状態 4 → 6(Locked0/Locked1/Locked2/Closed/Open/Alarm)、入力 5 → 7(UnlockOk/UnlockNg/Lock/Push/Pull/ResetAdmin/ResetUser)
文脈の行き先failures → 状態(名前に回数を埋める)。code_ok() と is_admin → 入力(照合と権限の判定は呼び出し側が先にやり、結果を入力の種類にする)
残るアクションSoundAlarm だけ。 RecordFailure と ResetFailures は消える。回数は状態そのものなので、数える仕事が無くなる
S5書けない(書く必要が無い)。 「失敗3回なのに Locked」という状態が型に存在しない。Alarm 中の UnlockOk は TransitionImpossibleError になる
回数 3 → 10状態は L+3 = 13、UnlockNg の行は L = 10 行(導出。L は締め出しの回数)

確認に使ったコード(rustc --edition 2024 --test mealy.rs && ./mealy --nocapture。rust-fsm 本体は入れず、StateMachineImpl と consume を一次資料と同じ形で手書きした):

// rust-fsm 0.8 の StateMachineImpl と consume を写した最小版(クレートは入れず、同じ形で手書き)
pub trait StateMachineImpl {
    type Input; type State; type Output;
    const INITIAL_STATE: Self::State;
    fn transition(state: &Self::State, input: &Self::Input) -> Option<Self::State>;
    fn output(state: &Self::State, input: &Self::Input) -> Option<Self::Output>;
}
#[derive(Debug, PartialEq, Eq)] pub struct TransitionImpossibleError;
pub struct StateMachine<T: StateMachineImpl> { state: T::State }
impl<T: StateMachineImpl> StateMachine<T> {
    pub fn new() -> Self { Self { state: T::INITIAL_STATE } }
    pub fn from_state(state: T::State) -> Self { Self { state } }
    pub fn state(&self) -> &T::State { &self.state }
    pub fn consume(&mut self, input: &T::Input) -> Result<Option<T::Output>, TransitionImpossibleError> {
        if let Some(state) = T::transition(&self.state, input) {
            let output = T::output(&self.state, input);
            self.state = state;
            Ok(output)
        } else { Err(TransitionImpossibleError) }
    }
}

// Day 066 の扉を、文脈なし(transition/output は state と input しか見ない)で書き直す
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum S { Locked0, Locked1, Locked2, Closed, Open, Alarm }       // 失敗回数を状態名に埋める
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum I { UnlockOk, UnlockNg, Lock, Push, Pull, ResetAdmin, ResetUser } // 照合・権限は呼び出し側で判定して入力に埋める
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum O { SoundAlarm }                                             // 数えるアクションは不要になる
pub struct Door;
impl StateMachineImpl for Door {
    type Input = I; type State = S; type Output = O;
    const INITIAL_STATE: S = S::Locked0;
    fn transition(s: &S, i: &I) -> Option<S> {
        use {I::*, S::*};
        Some(match (s, i) {
            (Locked0 | Locked1 | Locked2, UnlockOk) => Closed,
            (Locked0, UnlockNg) => Locked1,
            (Locked1, UnlockNg) => Locked2,
            (Locked2, UnlockNg) => Alarm,
            (Closed, Lock) => Locked0,
            (Closed, Push) => Open,
            (Open, Pull) => Closed,
            (Alarm, ResetAdmin) => Locked0,
            _ => return None,
        })
    }
    fn output(s: &S, i: &I) -> Option<O> {
        match (s, i) { (S::Locked2, I::UnlockNg) => Some(O::SoundAlarm), _ => None }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test] fn s4_third_failure_alarms() {
        let mut m = StateMachine::<Door>::new();
        let r: Vec<_> = (0..3).map(|_| { let o = m.consume(&I::UnlockNg); (*m.state(), o) }).collect();
        assert_eq!(r, vec![(S::Locked1, Ok(None)), (S::Locked2, Ok(None)), (S::Alarm, Ok(Some(O::SoundAlarm)))]);
    }
    #[test] fn s5_alarm_state_rejects_unlock() {
        // 「失敗3回で Locked のまま」は表せない。Alarm 中の UnlockOk は遷移不能
        let mut m = StateMachine::<Door>::from_state(S::Alarm);
        assert_eq!(m.consume(&I::UnlockOk), Err(TransitionImpossibleError));
    }
    #[test] fn s7_only_admin_resets() {
        let mut m = StateMachine::<Door>::from_state(S::Alarm);
        assert!(m.consume(&I::ResetUser).is_err());
        assert_eq!(m.consume(&I::ResetAdmin), Ok(None));
        assert_eq!(*m.state(), S::Locked0);
    }
    #[test] fn sizes() {
        println!("size_of State={} Output={} consume戻り値={}", std::mem::size_of::<S>(), std::mem::size_of::<O>(),
            std::mem::size_of::<Result<Option<O>, TransitionImpossibleError>>());
    }
    #[test] fn table_shape() {
        let ss = [S::Locked0,S::Locked1,S::Locked2,S::Closed,S::Open,S::Alarm];
        let is = [I::UnlockOk,I::UnlockNg,I::Lock,I::Push,I::Pull,I::ResetAdmin,I::ResetUser];
        let mut ok = 0; for s in ss { for i in is { if Door::transition(&s,&i).is_some() { ok += 1; } } }
        println!("組み合わせ {} 通り中 遷移あり {}", ss.len()*is.len(), ok);
    }
}
fn main() {}

出力(実測):

running 5 tests
size_of State=1 Output=0 consume戻り値=1
組み合わせ 42 通り中 遷移あり 10
test tests::s4_third_failure_alarms ... ok
test tests::s5_alarm_state_rejects_unlock ... ok
test tests::s7_only_admin_resets ... ok
test tests::sizes ... ok
test tests::table_shape ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

読み取ること:

  • 文脈を持たない状態機械では、文脈は状態か入力へ押し出される。 表は 4×5=20 通り(Day 066)から 6×7=42 通りに広がり、そのうち遷移があるのは 10(実測)
  • その代わり、「起きてはいけない組み合わせ」が型から消えます。 Day 066 では Ctx::new(Some("1234"), 3, false) と Door::Locked の組(失敗3回なのに締め出されていない)を作れたので、S5 という仕様テストが必要でした。rust-fsm の形ではその組が書けません。「不正な状態を表せないようにする」(make illegal states unrepresentable)の典型です
  • Output が1種類だけの enum は大きさ 0 バイト、consume の戻り値 Result<Option<O>, _> は 1 バイト(実測)。Day 066 の Result<Step, Rejected> は 24 バイトでした。アクションが固定の1個なら、列(ポインタ+長さ 16 バイト)を持つ必要が無いからです
  • 代償は回数の上限が型に焼き込まれること。上限を設定ファイルで変えたい・ユーザーごとに変えたいなら、この形は使えません。また、回数を DB に保存したい(監査)なら、消えたはずの RecordFailure 相当の出力が結局また要ります

Part 4 の答え(実測)

1 — _ は ident に合わず、呼び出し側でエラーになります。

error: no rules expected reserved identifier `_` --> tt.rs:5:23 | 1 | macro_rules! by_ident { ($($f:ident = $v:expr;)*) => { $( let _ = $v; )* }; } | --------------------- when calling this macro ... 5 | by_ident!{ A = 1; _ = 2; } | ^ no rules expected this token in macro call | = note: while trying to match sequence end

同じファイルの by_tt!{ A = 1; _ = 2; }($f:tt)は通りました。bitflags が tt を使うのは、ドキュメントの "If the identifier is _, then the flag is unnamed. Unnamed flags don't appear in the generated API, but affect how bits are truncated." を実現するためです。const _ = !0; は「名前は要らないが、すべてのビットを既知として扱う」宣言です。

代償: tt は何でも1トークン受けるので、const 1 = 2; のような誤りもマクロの入口では弾けず、展開後の const 宣言で初めてエラーになります(Day 062 の「受け口が広いほどエラーが遠くなる」と同じ。bitflags でこの誤りを入れたときのエラー文は未確認)。

2 — early(Perm::R) は 100 を返します。1 ではありません。

early(R)=100 early(W)=200

return 1; は bitflags_match! が作ったクロージャから抜けて、クロージャの値が 1 になり、v * 100 が実行されます。コンパイラは警告を1つ出しますが、内容は「到達しない式」で、「その return は関数から抜けない」とは言いません。

warning: unreachable expression --> src/lib.rs:15:18 | 15 | let v: i32 = bitflags_match!(p, { | __________________^ 16 | | Perm::R => { return 1; }, 17 | | _ => 2, 18 | | }); | | ^ | | | | |______unreachable expression | any code following this expression is unreachable

(cargo new --lib bf に bitflags = "=2.13.2" を足し、cargo test --offline で実測。size_of::<Perm>() は 1 バイト)

これは ADR-063 の ① 「呼び出し元の制御フローを変える」が両刃であることの実例です。bitflags はクロージャで包むことで「腕の値を return で返せる」ようにしましたが、その代わり腕の中の return の意味が、普通の match と変わりました。マクロの外から見ると match のような見た目なので、match の感覚で return を書くと読み違えます。

Part 5 の答え(例)

ADR-067 扉の状態機械に自作 fsm! を使い続けるか
決定: 自作 fsm! を使い続ける。文脈を読むガード(failures・code_ok・is_admin)と、1遷移で複数のアクション(RecordFailure→SoundAlarm)が仕様にあるため。
採らなかった案:
  rust-fsm: transition/output が文脈を見ない。状態 4→6・入力 5→7・表 20→42 通り(実測)、上限を状態名に焼き込む。出力は 0〜1 個。
  statig: ハンドラが &mut Context で判断と実行を同じ場所で行う。Day 066 の S8(720 通りで on() が純粋)を型で保てない。
  手書きの match(マクロなし): Day 064 の表と同じ行数で書けるが、enum 3つ・Rejected の定型を毎回書く(ADR-063 ④ の理由が残る)。
前提が変わったら: 回数の上限が固定で、不正な組み合わせを型で消したいなら rust-fsm の形へ。
  入れ子の状態(Alarm の中に「鳴動中/消音中」)や entry/exit アクションが要るなら statig へ(自作で入れ子を作らない)。
  値を持つアクション(監査ログ)が要るなら、どれを選んでも Vec かコールバックが要る(Day 066 (c-2)(c-4))。
測れていないこと: rust-fsm・statig 本体のビルドと実行(今日は trait の形を写した手書き版のみ)、コンパイル時間。

🪜 Step-by-Step 解説

Step 1: 「何を生成しているか」で ADR-063 に当てる

マクロを読むときは、まず展開結果が item か式かを見ます。bitflags!・state_machine!・#[state_machine] は struct・enum・impl を生成するので ④。関数では struct を外へ出せないので、マクロ(か build.rs のコード生成)しか手段がありません。bitflags_match! は式を生成するので ④ ではなく、match で書けない「等値比較の連鎖」と、return を受け止めるための制御フローの変更(①)が理由です。

Step 2: macro_rules! か proc-macro か

macro_rules!proc-macro
置き場所同じクレートに書けるproc-macro = true の専用クレートが必要(rust-fsm が rust-fsm-dsl を分けている理由)
依存無し普通は syn・quote・proc-macro2(ビルド時間が増える)
できることトークンの並びのパターン照合と置換任意の Rust コードで入力を解析できる(重複除去・並べ替え・自由な構文)
古い Rust への対応bitflags は rust-version = "1.56.0"(Cargo.toml、実測)syn の対応版に左右される

bitflags がこれだけ多くの機能(serde・arbitrary・bytemuck 連携を含む、src/external.rs に __impl_external_bitflags_* マクロ群)を macro_rules! で書き切っているのは、依存を増やさないこと(bitflags は多数のクレートの下に入る土台)を優先しているからだと読めます(推測。一次資料の明言は未確認)。

Step 3: シグネチャから「判断と実行」を読む

シグネチャ読めること
fn transition(&State, &Input) -> Option<State>文脈を受け取らない。純粋
fn output(&State, &Input) -> Option<Output>アクションは値で、最大1個
fn led_on(&mut Context, &Event) -> Outcome<State>文脈を書き換えられる。アクションはその場で実行
fn on(self, Event, &Ctx) -> Result<Step, Rejected>(Day 066)文脈は読むだけ。アクションは &'static の列で返す

ドキュメントを全部読まなくても、中核の関数の型を4行読めば設計の立場が分かる。Rust の &/&mut の区別が、ドキュメントの代わりをしています。

Step 4: 文脈を状態に押し込むとどうなるか

rust-fsm は rust-fsm/src/lib.rs のドキュメントで、作れるものを "A Mealy machine by providing all entities listed above." と説明しています。ミーリ機械(出力が状態と入力で決まる有限状態機械)は、有限であることが前提です。失敗回数のような数え上げは、有限の範囲(0〜2)に限れば状態に展開できます。展開すると不正な組み合わせが消える一方、上限が型に焼き込まれます。Day 066 の Ctx は、UML ステートマシンでいう「拡張状態」(extended state、状態の外に持つ変数)にあたります。

Step 5: マクロの中のクロージャは制御フローを変える

bitflags_match! の (|| { ... })() は、腕の値を return で返す実装のための道具です。利用者が腕の中に書いた return・?・break は、すべてクロージャの境界で止まります(? と break は今日は試していない。return のみ実測)。自作マクロでクロージャを使うときは、利用者のコードをクロージャの中に入れていないかを確認する、が今日の教訓です。

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

3つのクレートは、同じ「状態機械を Rust で書く」問題に対して、Rust の型システムのどこに重みを置くかが違います。

  • rust-fsm は「有限であること」に重みを置き、文脈を持たないことで、不正な状態を型から消す
  • statig は「組み込みで速く、確保なしで」に重みを置き、&mut Context で直接書き換える。純粋さより、その場で実行する素直さを選ぶ
  • 自作の fsm!(Day 066) は「判断の純粋さ」に重みを置き、&Ctx と &'static [Act] でそれを型に写す

どれもゼロコスト(確保なし)で、どれも &/&mut で立場をシグネチャに表しています。違うのは何を型で保証し、何をテストに残すかです。rust-fsm は S5 を型で保証し、Day 066 は S8 を型で保証しました。両方を型で保証する形は、今日読んだ範囲には無く、これがトレードオフの本体です。

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

案中身採らなかった理由
(A) 今日の形: 一次資料を読み、主張のうち手元で確かめられるものを実行rust-fsm の trait を写して扉を移す、bitflags はローカルにある本物で実行採用。 ネットワークからクレートを取らずに(--offline)確かめられる範囲に限った
(B) rust-fsm・statig を依存に入れて実際にビルドcargo add rust-fsm statig今日の環境ではローカルのレジストリに無く、取得が必要。trait の形は一次資料から写せるので、設計の比較には足りると判断した。代わりに「本体は動かしていない」を明記
(C) 読むだけ(実行なし)一次資料の引用と分類だけDay 059〜063 の持ち越しで「一次資料3件は取得し直して照合していない」が続いていた。読んだ主張を実行で裏付けないと、記憶の要約と区別がつかない
(D) 自作 fsm! を rust-fsm の DSL 構文に寄せる実装問題Locked(UnlockNg) => Locked1 [..] を受けるように改造概念理解の日に実装を詰め込むと読みが浅くなる。Part 5 の ADR で「寄せない」と判断したので不要になった

前提が変わったら: クレートを取得してよい環境(Mac mini、ネットワーク可)で、rust-fsm と statig を実際に入れて、cargo expand で展開結果を自作 fsm! と並べるなら (B) に移ります。

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

今日はデバッグ問題ではありませんが、Part 4 で出た2つを残します。

出力原因読み方直し方
error: no rules expected reserved identifier `_` / note: while trying to match sequence end$f:ident に _ を渡した_ は「予約された識別子」で ident フラグメントに合わない。note: は「並びの終わりを待っていたが合わなかった」位置_ を受けたいなら $f:tt(bitflags と同じ)。代わりに入口での検査は弱くなる
warning: unreachable expression(bitflags_match! の呼び出し全体を指す)腕の中の return がクロージャから抜ける警告の文は「後のコードに届かない」だが、本当の問題は「return が関数から抜けない」こと。警告の文面だけでは気づけない腕で値を返し、関数からの早期 return はマクロの外で if を使う

🔀 他言語との比較

観点Rust(rust-fsm)Rust(statig)TypeScript(XState v5)GoJava
表の書き方proc-macro の DSL(別クレート)属性 proc-macro で impl ブロックの関数を状態にするオブジェクトリテラル(設定)を createMachine に渡すswitch を手書き。コード生成なら go generateenum + switch、またはライブラリ(Spring Statemachine)
文脈(拡張状態)持たない(状態に展開)&mut Contextcontext(assign アクションで更新)構造体のフィールドフィールド
アクションOption<Output> を返す(0〜1 個)ハンドラ内で実行、entry/exit は #[action]transition() が配列で返す慣習次第慣習次第
入れ子の状態無し(README の範囲で見当たらない)あり(README: "Hierarchical state machines")あり手書きSpring Statemachine はあり

Rust の2列と XState の列は今日取得した一次資料の範囲です。Go・Java の列と Spring Statemachine は今日は確認しておらず、記憶に基づく要旨です。

🏭 実務での使いどころ

  • ライブラリを選ぶとき、中核の関数のシグネチャを先に読む。 README の宣伝文より、fn transition(&State, &Input) の1行の方が「文脈を持てない」ことを正確に教えます。今日の Part 2 の表は、そのまま選定会議の比較表になります
  • プロトコルの実装(TCP の状態・TLS のハンドシェイク・サーキットブレーカー)は rust-fsm の形が合う。 状態が有限で、文脈は少なく、不正な遷移を型で消したい。rust-fsm の README の例がサーキットブレーカー(Closed(Unsuccessful) => Open [SetupTimer])なのはそのためです
  • 組み込みのファームウェアは statig の形が合う。 その場でピンを叩き、確保しない。入れ子の状態で共通の処理を親に寄せられる
  • 業務ワークフロー(承認・決済)は Day 066 の形が合う。 回数・金額・権限などの文脈が多く、判断を DB なしで全数テストしたい
  • マクロをレビューするとき: 利用者のコードがクロージャの中に入っていないか(Part 4-2)、tt で受けていてエラーが遠くなっていないか(Part 4-1)を見る

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「人気のクレートはマクロを macro_rules! で書けないから proc-macro にしている」proc-macro の方が高度に見えるbitflags は serde 連携まで含めて macro_rules! 26 個で書いている(実測)。proc-macro を選ぶのは構文の自由度や重複除去が要るとき。代わりに専用クレートと syn 依存が増える
「bitflags_match! は match と同じ」名前と見た目が match に似ている等値比較の if 連鎖で、上から順に評価。腕の中の return はクロージャから抜ける(early(R) が 100、実測)
「状態機械ライブラリならどれも同じことができる」状態と遷移という語が共通文脈を持てるか・アクションを何個返せるか・判断と実行を分けるかが違う。扉を rust-fsm に移すと状態 4→6・入力 5→7(実測)
「文脈を持たない方が劣っている」機能が少なく見える不正な状態(失敗3回なのに締め出されていない)を型から消せる。Day 066 で S5 としてテストで守っていたことが、型で守られる
「$x:tt で受ければ何でも通るので便利」受け口が広いほど使いやすいと思う誤りがマクロの入口で弾けず、展開後の遠い場所でエラーになる。bitflags は _ を受けるために必要な分だけ広げている

🔗 一次資料(参照日: 2026-10-07。今回取得して、引用部分を照合済み)

  • bitflags 2.13.2 のソース(ローカル ~/.cargo/registry/src/index.crates.io-*/bitflags-2.13.2/src/lib.rs、直接読んだ)
    • 484 行目: const $Flag:tt = $value:expr;
    • "If the identifier is _, then the flag is unnamed. Unnamed flags don't appear in the generated API, but affect how bits are truncated."
    • "The macro expands to a series of if statements, checking equality between the input expression and each pattern." / "Patterns are evaluated in the order they appear in the macro."
    • // Expand to a closure so we can use return``
  • rust-fsm README: https://github.com/eugene-babichenko/rust-fsm/blob/master/README.md
    • "Using the DSL will require to connect an additional crate rust-fsm-dsl (this is due to limitation of the procedural macros system)."
    • DSL の例 Closed(Unsuccessful) => Open [SetupTimer],
  • rust-fsm lib.rs: https://github.com/eugene-babichenko/rust-fsm/blob/master/rust-fsm/src/lib.rs
    • StateMachineImpl の定義、consume の定義(Part 2・Part 3 に引用)、"A Mealy machine by providing all entities listed above."
  • rust-fsm-dsl lib.rs: https://github.com/eugene-babichenko/rust-fsm/blob/master/rust-fsm-dsl/src/lib.rs
    • #[proc_macro] pub fn state_machine(tokens: TokenStream) -> TokenStream、syn・quote を使用、impl StateMachineImpl for Impl を生成
  • statig README: https://github.com/mdeloof/statig/blob/main/README.md
    • the #[state_machine] macro "simply parses the underlying impl block and derives some code based on its content."
    • "Compatible with #![no_std], state machines are defined in ROM and no heap memory allocations."
    • fn led_on(context: &mut Context, event: &Event) -> Outcome<State>

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

扱わなかったこと理由・次の予定
rust-fsm・statig 本体のビルドと cargo expand での展開結果の比較ローカルのレジストリに無い。取得してよい環境で(Mac mini)
rust-fsm の DSL で値を持つ出力(Output::Log(u32))が書けるかOption<Output> の型としては置けるが、DSL の構文は未確認
statig の入れ子の状態(superstate)・entry/exit アクション今日は「アクションをどこに置くか」の観点だけ読んだ
bitflags_match! の腕の中の ?・breakreturn のみ実測
tt で受けた bitflags に const 1 = 2; を入れたときのエラー文未実行
bitflags が proc-macro を避ける理由の一次資料(メンテナの発言)推測にとどめた
Day 066 からの持ち越し(値を持つアクションの Vec/コールバック版、Part 4 の計測の取り直し、24 バイトの判別値の位置、TS・Go・Java の比較列の実行)今日は概念理解の日として照合に集中

3 問の自己点検

  1. 同じ指示を AI に出せば数時間で出るか — 3つのクレートの紹介文は出る。ただし、扉を rust-fsm の形に移すと状態 4→6・入力 5→7・表 42 通り中 10 遷移・RecordFailure が消えて S5 が型に吸収されること、bitflags_match! の腕の return で early(R) が 100 を返し警告の文面からは気づけないこと、_ が ident に合わない実際のエラー文は、手元でビルドして得た結果
  2. AI に指示するだけでは出ない部分 — Day 063 の ADR-063 と Day 066 の (c-1)〜(b) という、この連載で自分が作った物差しで実物を測ったこと。そして「rust-fsm は S5 を型で守り、Day 066 は S8 を型で守る。両方を守る形は読んだ範囲に無い」というトレードオフの特定
  3. 世界の実物と比べて何割か — statig の README が挙げる機能(入れ子の状態・entry/exit・文脈・no_std・非同期など)のうち扱ったのは文脈とアクションの置き場所の2つ。rust-fsm の中核(StateMachineImpl の4項目+consume)は全部扱った。bitflags のマクロ 26 個のうち読んだのは bitflags!・bitflags_match!・__bitflags_match の3つ

➡️ 次のステップ

  • 発展: 入れ子の状態(Alarm の中に「鳴動中/消音中」)を、自作 fsm! で表すとどうなるかを設計する。statig の superstate と比べて、自作で作るべきでない線を決める(設計・アーキテクチャ)
  • 次回予告: マクロ① の残り期間(〜10/24)で、Day 066 の発展「値を持つアクション(監査ログ)を (c-2) Vec と (c-4) コールバックで比べる」設計問題、または入れ子の状態の設計問題

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

Part 3・Part 4 の予想と実際(状態の数、early(R) の値)

自分の回答

気づき・メモ