フェーズ: 上級者 (Phase 3) | 問題タイプ: 実装 | テーマ: マクロ①(宣言的マクロ macro_rules!)
📚 背景知識(読んでから問題へ)
Day 058〜059 で作った fsm! は「状態 × 事象 → 次の状態」の表を書くだけで enum と on() を生成しました。ただし実務の状態機械には、同じ状態・同じ事象でも、条件によって行き先が変わる行がほぼ必ずあります。
- 扉: 施錠中に「解錠」→ 暗証番号が正しければ「閉」、3回失敗していれば「警報」、どちらでもなければ「施錠のまま」
- 注文: 「支払い完了」→ 在庫があれば「出荷待ち」、無ければ「取り寄せ」
match には、これを書く仕組みが最初からあります。マッチガード(パターン if 条件 =>)です。The Rust Reference は動作をこう書いています(参照日 2026-10-04、https://doc.rust-lang.org/reference/expressions/match-expr.html )。
"When the pattern matches successfully, the pattern guard is executed."
つまり、パターンが一致した行だけガードが評価され、偽なら次の行へ進みます。今日の実装は、この match の仕組みをマクロの表の書き方に持ち込むことです。
ここで、マクロ特有の壁が3つ出てきます。今日はこの3つに実際にぶつかります。
| 壁 | 中身 | 一次資料 |
|---|---|---|
| 1. 衛生性(hygiene) | マクロの中で作ったローカル変数(例: ctx)は、呼び出し側が書いたガード式からは見えない | Reference: "local variables are looked up at the macro definition site" |
| 2. 後続トークンの制限(follow-set) | $g:expr の後ろに置けるのは => , ; だけ。$s:pat の後ろには if が置ける | Reference: "expr and stmt may only be followed by one of: =>, ,, or ;." |
| 3. 再帰の上限 | トークンを1つずつ食べる書き方(tt muncher)は、再帰が深くなると止まる | Reference: "The default in rustc is 128." |
(3つとも参照日 2026-10-04 に取得して照合。URL は末尾の「一次資料」)
今日の位置づけ: Day 063 の判断記録(ADR-063)では、マクロを追加してよい理由を4つに絞りました。fsm! は **④ item(enum・impl)を生成する** に当たるので、マクロのまま育ててよい部品です。
📝 問題
環境は rustc 1.96.0、cargo new --lib guardfsm(Cargo.toml の edition は cargo new の既定で 2024。2021 に変えても同じ結果になることを確認済み)。依存クレートはありません。
Part 1: ガード付きの fsm! を実装する(本題)
次の呼び出しが通る fsm! を src/lib.rs に書いてください。
fsm! {
context c: Ctx; // ガードから読む文脈。名前 c は呼び出し側が決める
state Door { Locked, Closed, Open, Alarm }
event Ev { Unlock, Lock, Push, Pull, Reset }
(Locked, Unlock) if c.tick() && c.attempts >= 3 => Alarm; // 先に「試行回数超過」を見る
(Locked, Unlock) if c.tick() && c.code_ok => Closed;
(Locked, Unlock) => Locked; // ガード無しの受け皿
(Closed, Lock) => Locked;
(Closed, Push) => Open;
(Open, Pull) => Closed;
(Alarm, Reset) if c.is_admin => Locked;
}
要件:
Door::on(self, ev: Ev, c: &Ctx) -> Result<Door, Rejected<Door, Ev>>を生成する。どの行にも当たらなければErr(Rejected { state, event })- 行は上から順に照合し、最初に「パターン一致 かつ ガード真」の行が勝つ
- ガードは任意(
if ...が無い行も書ける) - 状態名の打ち間違い(
Opn)や、絶対に届かない行はコンパイルエラーにする(Day 059 と同じ#[deny(unused_variables, unreachable_patterns)]の工夫)
Ctx は次のものを使ってください(tick() はガードが評価された回数を数えるためのもの)。
use std::cell::Cell;
pub struct Ctx { pub code_ok: bool, pub attempts: u32, pub is_admin: bool, pub guard_evals: Cell<u32> }
impl Ctx {
pub fn new(code_ok: bool, attempts: u32, is_admin: bool) -> Self {
Ctx { code_ok, attempts, is_admin, guard_evals: Cell::new(0) }
}
pub fn tick(&self) -> bool { self.guard_evals.set(self.guard_evals.get() + 1); true }
}
Part 2: テストを書いて、予想と比べる
次の5本を cargo test で通してください。書く前に、括弧の中の値を予想して紙に書くこと。
| # | 入力 | 確かめること(予想を書く) |
|---|---|---|
| T1 | Locked + Unlock、暗証番号 正、試行 0 | 行き先は? |
| T2 | Locked + Unlock、暗証番号 誤、試行 0 | 行き先は?(拒否か、留まるか) |
| T3 | Locked + Unlock、暗証番号 正、試行 3 | 行き先は?(2つのガードが両方真) |
| T4 | Alarm + Reset、管理者でない/管理者 | Err になるか/行き先は? |
| T5 | ガードの評価回数: ①Closed+Push ②Locked+Unlock(暗証番号 誤・試行 0)③同(試行 5) | ①②③ それぞれ何回? |
Part 3: 壊して、エラーの出方を記録する
次の3つを実際に試し、エラーの種類と指している場所(呼び出し側か、マクロ定義側か)を記録してください。
- (a) マクロの中で引数名を
ctxに固定し(context c:を受け取らない)、呼び出し側のガードにctx.okと書く - (b) Part 1 の表で、ガード無しの
(Locked, Unlock) => Locked;を一番上に移す - (c)
(Open, Pull)を(Opn, Pull)と打ち間違える
Part 4: if let ガードを表に書けるようにする(4案を測る)
rustc 1.96.0 では、match のガードに if let が書けます(Reference の文法 MatchGuardCondition → ... | let Pattern = MatchGuardScrutinee。手元でも edition 2021・2024 の両方でコンパイルと実行を確認)。次の行を表に書きたいとします。
(Locked, Unlock) if let Some(p) = c.entered && p == c.expected => Closed;
ところが Part 1 の $(if $g:expr)? では通りません。次の4案を実際に書いて、通るか/何行まで書けるかを表にしてください。
| 案 | マッチャー(行の受け方) |
|---|---|
| A | $(if $g:expr)?(Part 1 のまま) |
| A' | $(if $g:expr)? $(if let $gp:pat = $ge:expr)?(2つの任意グループを並べる) |
| B | $(if $($g:tt)+)?(=> までのトークンを丸ごと受ける) |
| C | $(if { $($g:tt)+ })?(呼び出し側にガードを { } で囲ませる) |
| D | tt muncher(ガードを1トークンずつ食べ、=> が来たら行を確定する) |
最後に、どの案を採るかの判断記録を 10 行以内で書いてください。迷った他案と採らなかった理由、前提が変わったら判断がどう変わるか、を含めること。
💡 ヒント
ヒント1(方向性)
生成する match の腕は、($s, $e) if $g => Ok($to), の形です。ガードが無い行は if $g の部分が消えるだけ。つまり「ガードを任意にする」は、**マクロの任意グループ $( ... )? をそのまま展開側にも書く**だけで済みます。
ガードから c が見えるかどうかは、c という名前を誰が書いたかで決まります。
ヒント2(アプローチ)
- パターンは
$s:pat、ガードは$g:expr、行き先は$to:ident。$s:patの後ろに,やifを置くのは許されますが、+などは許されません - 引数名は
context $c:ident : $Ctx:ty ;で呼び出し側から受け取り、fn on(self, ev: $Event, $c: &$Ctx)に使います。こうするとcは呼び出し側の名前になり、衛生性の壁を越えられます #[deny(unreachable_patterns)]は、ガード無しの行の後ろにある同じパターンの行を止めます(ガード付きの行の後ろは止めません。ガードが偽なら次に進むからです)- Part 4 の D:
=>は1つのトークンです。「先頭が=>なら確定、それ以外なら1つ食べて溜める」の2本の規則を、この順に書きます
ヒント3(コード骨格)
#[macro_export]
macro_rules! fsm {
(
context $c:ident : $Ctx:ty ;
state $State:ident { $($S:ident),+ $(,)? }
event $Event:ident { $($E:ident),+ $(,)? }
$( ($s:pat, $e:pat) $(if $g:expr)? => $to:ident ; )*
) => {
/* enum を2つ */
impl $State {
#[deny(unused_variables, unreachable_patterns)]
pub fn on(self, ev: $Event, $c: &$Ctx) -> Result<Self, $crate::Rejected<Self, $Event>> {
#[allow(unused_imports)]
use {$State::*, $Event::*};
match (self, ev) {
$( ($s, $e) $(if $g)? => Ok($to), )*
(state, event) => Err($crate::Rejected { state, event }),
}
}
}
};
}
✅ 模範解答
Part 1・2 の答え(cargo test で 5 本通過を実測)
cargo new --lib guardfsm の src/lib.rs にそのまま貼れば検証できます。
use std::cell::Cell;
/// 遷移できなかったときの値(マクロの外=ライブラリ側に置く)
#[derive(Debug, PartialEq, Eq)]
pub struct Rejected<S, E> {
pub state: S,
pub event: E,
}
#[macro_export]
macro_rules! fsm {
(
context $c:ident : $Ctx:ty ;
state $State:ident { $($S:ident),+ $(,)? }
event $Event:ident { $($E:ident),+ $(,)? }
$( ($s:pat, $e:pat) $(if $g:expr)? => $to:ident ; )*
) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum $State { $($S),+ }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum $Event { $($E),+ }
impl $State {
/// 遷移表を上から順に照合する。最初に「パターン一致 かつ ガード真」の行が勝つ
#[deny(unused_variables, unreachable_patterns)]
pub fn on(self, ev: $Event, $c: &$Ctx)
-> ::core::result::Result<Self, $crate::Rejected<Self, $Event>>
{
#[allow(unused_imports)]
use {$State::*, $Event::*};
match (self, ev) {
$( ($s, $e) $(if $g)? => ::core::result::Result::Ok($to), )*
(state, event) => ::core::result::Result::Err($crate::Rejected { state, event }),
}
}
}
};
}
/// ガードの中で読む文脈。ガードが何回評価されたかを数える
pub struct Ctx {
pub code_ok: bool,
pub attempts: u32,
pub is_admin: bool,
pub guard_evals: Cell<u32>,
}
impl Ctx {
pub fn new(code_ok: bool, attempts: u32, is_admin: bool) -> Self {
Ctx { code_ok, attempts, is_admin, guard_evals: Cell::new(0) }
}
/// ガードから呼ぶと評価回数が 1 増える
pub fn tick(&self) -> bool { self.guard_evals.set(self.guard_evals.get() + 1); true }
}
fsm! {
context c: Ctx;
state Door { Locked, Closed, Open, Alarm }
event Ev { Unlock, Lock, Push, Pull, Reset }
(Locked, Unlock) if c.tick() && c.attempts >= 3 => Alarm; // 先に「試行回数超過」を見る
(Locked, Unlock) if c.tick() && c.code_ok => Closed;
(Locked, Unlock) => Locked; // ガード無しの受け皿(拒否ではなく留まる)
(Closed, Lock) => Locked;
(Closed, Push) => Open;
(Open, Pull) => Closed;
(Alarm, Reset) if c.is_admin => Locked;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn guard_true_moves() {
let c = Ctx::new(true, 0, false);
assert_eq!(Door::Locked.on(Ev::Unlock, &c), Ok(Door::Closed));
}
#[test]
fn guard_false_falls_through_to_unguarded_row() {
let c = Ctx::new(false, 0, false);
assert_eq!(Door::Locked.on(Ev::Unlock, &c), Ok(Door::Locked));
}
#[test]
fn first_matching_row_wins() {
// 暗証番号が正しくても、試行回数超過の行が先に書いてあるので Alarm
let c = Ctx::new(true, 3, false);
assert_eq!(Door::Locked.on(Ev::Unlock, &c), Ok(Door::Alarm));
}
#[test]
fn guard_false_without_fallback_is_rejected() {
let c = Ctx::new(false, 0, false);
assert_eq!(
Door::Alarm.on(Ev::Reset, &c),
Err(Rejected { state: Door::Alarm, event: Ev::Reset })
);
let admin = Ctx::new(false, 0, true);
assert_eq!(Door::Alarm.on(Ev::Reset, &admin), Ok(Door::Locked));
}
#[test]
fn guards_are_evaluated_only_when_pattern_matches() {
let c = Ctx::new(true, 0, false);
assert_eq!(Door::Closed.on(Ev::Push, &c), Ok(Door::Open));
assert_eq!(c.guard_evals.get(), 0); // Locked 行のガードは呼ばれない
let c = Ctx::new(false, 0, false);
Door::Locked.on(Ev::Unlock, &c).unwrap();
assert_eq!(c.guard_evals.get(), 2); // 1行目・2行目のガードが順に評価され、3行目へ
let c = Ctx::new(false, 5, false);
Door::Locked.on(Ev::Unlock, &c).unwrap();
assert_eq!(c.guard_evals.get(), 1); // 1行目で決まり、2行目のガードは評価されない
}
}
実行結果(実測、rustc 1.96.0、edition 2024。edition 2021 に変えても 5 本通過):
running 5 tests
.....
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Part 2 の答え(すべて実測):
| # | 結果 | 読み取ること |
|---|---|---|
| T1 | Ok(Closed) | 2行目のガードが真 |
| T2 | Ok(Locked) | ガード2本が偽 → 3行目(ガード無し)が受け皿になる。**受け皿が無ければ Err** |
| T3 | Ok(Alarm) | 両方真でも上の行が勝つ。表の行の順番は仕様の一部 |
| T4 | 管理者でない → Err(Rejected{Alarm, Reset})、管理者 → Ok(Locked) | ガード付きの行しかない組み合わせは、偽のとき拒否になる |
| T5 | ① 0 回 ② 2 回 ③ 1 回 | パターンが一致しない行のガードは呼ばれない(①)。上から順に評価し、真で止まる(③) |
Part 3 の答え(実出力の要点、rustc 1.96.0)
(a) 引数名をマクロ内で ctx に固定した場合:
指す場所: 呼び出し側(そしてマクロ定義側の ctx を「衛生性のせいで見えない」と名指しする)。rustc 1.96.0 はこの help: を出してくれるので、原因はすぐ分かります。直し方は、名前を呼び出し側から $c:ident で受け取ること(模範解答の context c: Ctx;)。
(b) ガード無しの行を一番上に移した場合:
指す場所: マクロ定義側。止めること自体はできますが、表の何行目が悪いかを言いません(fsm! 全体を指すだけ)。7行の表なら目で探せますが、50行の表では探すのに時間がかかります。これが Day 063 で記録した「マクロのエラーは定義側を指す」コストの、今日の実例です。
(c) Opn の打ち間違い:
指す場所: 呼び出し側の該当行、しかも Open の候補まで出ます。$s:pat に渡したトークンは呼び出し側の位置情報(span)を持ったままなので、行が特定できます。(b) との違いは、(b) のエラーが「生成した match の腕」というマクロが作った構造に対して出ている点です。
Part 4 の答え(4案の実測)
| 案 | 結果(実測) | if let ガード | 書ける行数(3トークンのガード c.is_admin の行を並べた場合) |
|---|---|---|---|
A $(if $g:expr)? | 呼び出し時に error: no rules expected keyword let(while trying to match meta-variable $g:expr) | × | 1,000 行で成功(再帰しない) |
| A' 任意グループ2つ | マクロ定義の時点でエラー: $g:expr may be followed by if, which is not allowed for expr fragments ` | × | ―(定義できない) |
B $(if $($g:tt)+)? | 呼び出し時に local ambiguity when calling macro: multiple parsing options(=> が「ガードの続き」とも「区切り」とも読める) | × | ― |
C $(if { $($g:tt)+ })? | 成功。Some("7")→7、Some("x")→-1、None→0 | ○(if { let Ok(n) = .. } と書かせる) | 再帰しない(行数の上限は今日は未測定。A と同じく再帰が無いので上限が出ないと推定) |
| D tt muncher | 成功(if let Some(p) = c.entered && p == c.expected が通り、テスト 1 本通過) | ○(普通の書き方のまま) | **25 行まで成功、26 行で recursion limit reached**。#![recursion_limit = "256"] で 50 行成功・51 行失敗 |
D の上限は計算と合います(導出): 1行あたり「行の開始 1 + ガードのトークン 3 + 行の確定 1 = 5 回」再帰するので、128 ÷ 5 ≒ 25 行、256 ÷ 5 ≒ 51 行(実測は 50 行。入口の 1 回ぶんずれる)。ガードが長いほど書ける行数が減るので、c.tick() && c.attempts >= 3(10 トークン)のような行ばかりなら、既定の上限で 10 行ほどしか書けない計算です(この組み合わせは未実測)。
D の版(src/lib.rs の差し替え。cargo test 1 本通過を実測):
#[derive(Debug, PartialEq, Eq)]
pub struct Rejected<S, E> { pub state: S, pub event: E }
/// 版D: ガードを tt で1つずつ食べる(tt muncher)。`if let` ガードも通る
#[macro_export]
macro_rules! fsm {
(
context $c:ident : $Ctx:ty ;
state $State:ident { $($S:ident),+ $(,)? }
event $Event:ident { $($E:ident),+ $(,)? }
$($table:tt)*
) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum $State { $($S),+ }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum $Event { $($E),+ }
$crate::fsm!(@row [$c $Ctx ; $State $Event] [] $($table)*);
};
// 終端: 溜めた腕で impl を出す
(@row [$c:ident $Ctx:ty ; $State:ident $Event:ident] [$($arms:tt)*]) => {
impl $State {
#[deny(unused_variables, unreachable_patterns)]
pub fn on(self, ev: $Event, $c: &$Ctx)
-> ::core::result::Result<Self, $crate::Rejected<Self, $Event>>
{
#[allow(unused_imports)]
use {$State::*, $Event::*};
match (self, ev) {
$($arms)*
(state, event) => ::core::result::Result::Err($crate::Rejected { state, event }),
}
}
}
};
// ガード無しの行
(@row $hdr:tt [$($arms:tt)*] ($s:pat, $e:pat) => $to:ident ; $($rest:tt)*) => {
$crate::fsm!(@row $hdr [$($arms)* ($s, $e) => ::core::result::Result::Ok($to),] $($rest)*);
};
// ガード付きの行: `if` の後ろを `=>` まで1トークンずつ集める
(@row $hdr:tt [$($arms:tt)*] ($s:pat, $e:pat) if $($rest:tt)*) => {
$crate::fsm!(@guard $hdr [$($arms)*] [($s, $e)] [] $($rest)*);
};
// 先頭が `=>` なら行を確定(この規則を先に書くのが要点)
(@guard $hdr:tt [$($arms:tt)*] [$($p:tt)*] [$($g:tt)*] => $to:ident ; $($rest:tt)*) => {
$crate::fsm!(@row $hdr [$($arms)* $($p)* if $($g)* => ::core::result::Result::Ok($to),] $($rest)*);
};
// それ以外は1トークン食べてガードに溜める
(@guard $hdr:tt [$($arms:tt)*] [$($p:tt)*] [$($g:tt)*] $t:tt $($rest:tt)*) => {
$crate::fsm!(@guard $hdr [$($arms)*] [$($p)*] [$($g)* $t] $($rest)*);
};
}
pub struct Ctx { pub entered: Option<u32>, pub expected: u32, pub is_admin: bool }
fsm! {
context c: Ctx;
state Door { Locked, Closed, Open, Alarm }
event Ev { Unlock, Lock, Push, Pull, Reset }
(Locked, Unlock) if let Some(p) = c.entered && p == c.expected => Closed;
(Closed, Lock) => Locked;
(Closed, Push) => Open;
(Open, Pull) => Closed;
(Alarm, Reset) if c.is_admin => Locked;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn if_let_guard_passes_through() {
let c = Ctx { entered: Some(1234), expected: 1234, is_admin: false };
assert_eq!(Door::Locked.on(Ev::Unlock, &c), Ok(Door::Closed));
let c = Ctx { entered: Some(1111), expected: 1234, is_admin: false };
assert!(Door::Locked.on(Ev::Unlock, &c).is_err());
let c = Ctx { entered: None, expected: 1234, is_admin: false };
assert!(Door::Locked.on(Ev::Unlock, &c).is_err());
}
}
途中で踏んだ罠(実測): 最初は内部規則の見出しを [$c:ident $Ctx:ty $State:ident $Event:ident] と書き、定義の時点で $Ctx:ty is followed by $State:ident, which is not allowed for ty fragments ` で止まりました(ty の後ろに置けるのは { [ => , > = : ; | as where)。$Ctx:ty ; と ; を挟んで直しています。
判断記録(10 行以内):
ADR-064 fsm! のガードの受け方
決定: 既定は案A `$(if $g:expr)?`。`if let` ガードが必要な表だけ案D(tt muncher)を別マクロで用意する。
理由: A は再帰しないので 1,000 行でも通り(実測)、エラーも `$g:expr` の位置を指す。
採らなかった案: A'(定義時にエラー、expr の後に if を置けない)/B(`=>` で曖昧エラー)/
C(通るが `if { let .. }` という Rust に無い書き方を覚えさせる。読む人が驚く)/
D を既定にする(3トークンのガードで既定 25 行、256 に上げても 50 行が上限。ガードが長いと更に減る)。
前提が変わったら: ①表の大半が `if let` を使うなら D を既定にし recursion_limit を README に明記する
②`if let` を使う行が数行なら、ガード側を `matches!(c.entered, Some(p) if p == c.expected)` の式に書き直して A で済ませる
③表が 100 行を超えるなら、マクロをやめて build.rs や proc-macro で生成する案を測る。
測れていないこと: C の行数上限、D の 10 トークンガードでの行数、両案のコンパイル時間。
👣 Step-by-Step 解説
Step 1: 任意グループは、展開側にもそのまま書く
$(if $g:expr)? で受けたら、展開側も $(if $g)? と書きます。外側の $( ... )*(行の繰り返し)の中に、内側の $( ... )?(ガードの有無)が入れ子になっているだけで、tt muncher(再帰)は要りません。$g が何回繰り返されたかを rustc が覚えているので、**ガードの無い行では if ごと消えます**。
なぜ $s:pat の後ろに if が置けるのか: match のガードがまさに「パターンの後ろの if」なので、Rust は pat の後ろに if を許しています(Reference の follow-set: =>, ,, =, if, in。rustc 1.96.0 のエラーメッセージは、さらに if let も挙げます)。
Step 2: ガードの中の名前は、呼び出し側に決めさせる
Part 3 (a) の E0425 が示すとおり、マクロの中で ctx という引数を作っても、呼び出し側が書いた ctx.ok の ctx とは別物として扱われます。これが衛生性(Reference の言葉では mixed-site hygiene: ローカル変数はマクロ定義側で解決される)です。C のマクロのように「たまたま同じ名前だったので繋がった」が起きないための安全装置です。
繋げたいときは、名前そのものを呼び出し側から渡す(context $c:ident)。こうすると引数の $c もガードの c も、同じ「呼び出し側が書いた c」になります。これは |c| ... とクロージャの引数名を呼び出し側が決めるのと同じ考え方です。
Step 3: 行の順番は仕様である
T3 で、暗証番号が正しくても Alarm になりました。match は上から最初に当たった腕を採るので、表の行の順番がそのまま優先順位です。状態遷移表を Excel などの表で管理している現場では、行の並べ替えが仕様変更になる点に注意が要ります(ソートしてはいけない表)。
Step 4: ガードの評価回数は、パターンで絞られる
T5 の ①0回・②2回・③1回は、Reference の「パターンが一致したらガードを実行する」「&& と同じく左から評価し、偽で止まる」の2つの記述どおりでした。ガードに重い処理(DB 照会など)を書くと、同じ (状態, 事象) の行の数だけ呼ばれうる、ということです。
補足(実測): 同じ腕の中で | を使ったパターンは、ガードが複数回走ることがあります。match (1, 1) { (1, 1) | (1, _) if g() => .., _ => .. } では g() が 2 回、(1, 1 | _) と入れ子にしても 2 回でした(g() が偽を返す場合)。Reference にも "Multiple matches using the | operator can cause the pattern guard ... to execute multiple times." とあります。今日の表は Locked | Closed のように重ならない選択肢しか使わないので 1 回で済みますが、ガードに副作用を書くなら気にすべき点です。
Step 5: 「止められる」と「どこが悪いか言える」は別
Part 3 の (b) と (c) は、どちらも deny で止まります。違いは位置情報です。
| 誤り | 止まるか | 指す場所 | 理由 |
|---|---|---|---|
(c) Opn | 止まる | 呼び出し側の該当行+候補 Open | $s のトークンが呼び出し側の span を持つ |
| (b) 行の順番 | 止まる | マクロ定義の $( ($s, $e) ... )* | 「到達不能」は生成した腕どうしの関係で、どの行かを言わない |
(b) を行単位で指させたいなら、proc-macro で自前の検査を書き、compile_error! を該当行の span で出す必要があります(マクロ② の範囲)。
Step 6: if let ガードを通すには、expr から降りる
let Some(p) = c.entered は式ではない(if や match のガードの中でだけ書ける構文)ので、$g:expr は受け取れません(案A のエラー)。ではトークンのまま受けようとすると:
- 案B:
$($g:tt)+は=>もトークンとして食べられるので、「まだガードの続き」か「ここで終わり」かを決められず、曖昧エラー - 案A':
$g:exprの直後に別のifが来うる形は、マクロの定義そのものが拒否される(exprの後ろには=>,;しか置けない)
逃げ道は2つです。区切りを括弧で作る(案C: { } の中は1つのトークン木なので曖昧にならない)か、1トークンずつ食べて「=> が来たら止まる」を規則の順番で表す(案D)。D は自然な書き方のまま通りますが、1トークンごとに1段再帰するので、既定の上限 128 にすぐ当たります(3トークンのガードで 25 行)。
🧠 設計思想・なぜこう書くのか
match のガードは、「パターン(形)で絞り込み、ガード(値の条件)で最終判断する」という2段構えです。形の検査はコンパイラが網羅性・到達可能性を調べられますが、ガードの中身はコンパイラには分かりません(だから ガード付きの腕は網羅性の計算に数えられません。T4 で Alarm+Reset が拒否になりえたのはこのため)。今日の fsm! は、この区別を表の書き方にそのまま残しています。
マクロ側の工夫は、Rust の構文をそのまま通すことに尽きます。$s:pat と $g:expr で受けて、match にそのまま置く。独自の DSL(例: when code_ok)を作らないので、ガードの意味は Rust の match と同じで、利用者が新しく覚えることがありません。案C を既定にしなかったのも同じ理由です(if { let .. } は Rust に無い書き方)。
迷った他案と採らなかった理由
| 案 | 採らなかった理由 |
|---|---|
ガードをクロージャで渡す(if |c| c.code_ok) | 呼び出し側で名前を決められるので衛生性は解決するが、match のガードに置けないので (|c| ..)(ctx) と呼ぶ形になり、if let も使えない。context c: で名前を1回渡す方が短い |
Ctx を self に持たせる(struct Machine { state, ctx }) | 状態が Copy な enum で済まなくなり、Day 059 までの on(self, ev) の形が変わる。文脈を変更したい(試行回数を増やす)なら次の候補だが、今日は読み取りだけに絞った |
ガードをマクロの外の関数にして、表には関数名だけ書く(if code_ok) | 表の見通しは良くなるが、関数が増えて行と条件が離れる。行が 20 を超えるなら検討する |
| 案D(muncher)だけにする | if let も通るが、行数の上限が既定で 25 行(3トークンのガード)。表が大きい用途で突然止まる |
前提が変わったら: 文脈を書き換える必要(失敗回数を数える等)が出たら $c: &$Ctx を &mut に変えるか、遷移の結果と一緒に「やること(アクション)」を返す設計に変えます(Day 059 のメモ付き遷移の発展)。その場合、ガードの中で &mut を使えるかは別途測る必要があります(未実施)。
🛠️ コンパイルエラーが出た場合(デバッグ問題では必須)
今日は実装問題ですが、Part 3・4 で実際に出たエラーを、読み方つきでまとめます(いずれも rustc 1.96.0 の実出力)。
| エラー | いつ出るか | 読み方・直し方 |
|---|---|---|
E0425 cannot find value ctx + not accessible due to macro hygiene | マクロ内で作った名前を、呼び出し側のガードから使った | 名前を $c:ident で呼び出し側から受け取る |
unreachable pattern + in this macro invocation | ガード無しの行の後ろに同じパターンの行がある | 表の順番を見直す。エラーは行を名指ししないので、ガード無しの行を探す |
unused variable: Opn + similarly named variant Open | 状態名の打ち間違い(use で解決できない名前は「変数への束縛」になる) | 候補の名前に直す。deny(unused_variables) が無いと全部に当たる腕になり黙って誤動作する |
no rules expected keyword let | $g:expr に if let を渡した | 案C か案D に替える |
$g:expr may be followed by if ` | マクロの定義(呼ぶ前)の検査 | expr の後ろには => , ; しか置けない |
local ambiguity when calling macro | $($g:tt)+ => で => の扱いが2通りに読めた | 区切りを括弧で作るか、muncher にする |
$Ctx:ty is followed by $State:ident ` | ty の直後に別の断片を並べた | 間に ; などの許された区切りを挟む |
recursion limit reached while expanding | muncher の行・ガードが多すぎる | #か、再帰しない案に替える |
🔀 他言語との比較(該当する場合)
| 観点 | Rust(今日の fsm!) | TypeScript(XState) | Go | Java |
|---|---|---|---|---|
| ガードの書き方 | match のガードをそのまま使う(if 条件、if let も可) | 設定オブジェクトに guard: ({ context }) => ... を書く | switch の中で if を書く | switch のパターン+when 条件(Java 21 のパターンマッチ) |
| 状態名の打ち間違い | コンパイルエラー(deny(unused_variables)) | 文字列なら実行時まで気付かない(型付けすればコンパイル時) | 定数なら未定義エラー | enum ならコンパイルエラー |
| 届かない行 | コンパイルエラー(deny(unreachable_patterns))。ただし位置はマクロ定義側 | 検出しない | 検出しない | 支配されたラベルはコンパイルエラー |
| 評価の順番 | 上から、パターン一致の行だけガードを評価 | 遷移配列の上から、最初に真のガード | 書いた順 | 上から |
XState の guard の書き方は、今日は一次資料を取得して照合していません(記憶に基づく要旨)。
🏭 実務での使いどころ
- 決済・注文・ワークフローの状態遷移: 「支払い完了 → 在庫ありなら出荷待ち/無しなら取り寄せ」のように、同じ事象で行き先が分かれる行は必ず出ます。行き先の分岐を
on()の外のifに散らすより、表の1行に置いた方が「何が起こりうるか」を表だけで読めます。 - 受け皿の行(T2)を明示する設計判断: ガードが全部偽のとき「拒否(
Err)」か「留まる(Ok(同じ状態))」かは、業務上の意味が違います(拒否はログ・警告の対象、留まるは正常)。表にガード無しの受け皿を書くかどうかで、この判断をコードに残せます。 - ガードに副作用を書かない: T5 のとおり、ガードは行の数だけ呼ばれえます。今日の
tick()は数えるための例外で、実務のガードは読み取りだけにします。
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
「マクロの中の ctx は呼び出し側からも見える」 | C のマクロ(ただの文字列置換)の感覚 | 衛生性で別物になる。E0425 と not accessible due to macro hygiene(実測) |
| 「ガードは全部の行で評価される」 | if の連鎖と同じだと思う | パターンが一致した行だけ(T5: 0・2・1 回、実測) |
| 「ガードが両方真なら、どちらでもよい」 | 表を集合だと思う | 上の行が勝つ(T3: Alarm、実測)。表の順番は仕様 |
「if let も $g:expr で受けられる」 | if let を式だと思う | let .. は式ではない。no rules expected keyword let(実測) |
「$($g:tt)+ なら何でも受けられる」 | tt は万能だと思う | 区切りの => も tt なので曖昧エラー(実測) |
| 「recursion_limit を上げれば muncher は解決」 | 上限の問題だと思う | 上げても倍になるだけ(25 → 50 行、実測)。ガードの長さに比例して減る |
「unreachable_patterns を deny すれば、悪い行を教えてくれる」 | 止まることと、位置を言うことを混同 | マクロ定義側を指し、表の何行目かは言わない(実測) |
📖 一次資料(参照日: 2026-10-04。今回取得して、引用部分を照合済み)
- The Rust Reference, Macros By Example(follow-set の制限と mixed-site hygiene): https://doc.rust-lang.org/reference/macros-by-example.html
- "
exprandstmtmay only be followed by one of:=>,,, or;." - "local variables are looked up at the macro definition site"
- 注: 取得したページの
patの follow-set は=>,,,=,if,inで、rustc 1.96.0 のエラーメッセージが挙げるif letは載っていなかった(版のずれと推定。未確認)
- "
- The Rust Reference, Match expressions(ガードの評価と
if letガードの文法): https://doc.rust-lang.org/reference/expressions/match-expr.html- "When the pattern matches successfully, the pattern guard is executed."
- The Rust Reference, Limits(recursion_limit): https://doc.rust-lang.org/reference/attributes/limits.html
- "The default in
rustcis 128."
- "The default in
🚧 今日扱わなかった範囲(次回以降)
| 扱わなかったこと | 理由・次の予定 |
|---|---|
| 案C の行数上限、案D の 10 トークンガードでの行数 | 今日は 3 トークンのガードだけを測った。D の式(上限 ÷ (トークン数 + 2))は導出のみ |
| 4案のコンパイル時間 | 今日は「通るか・何行まで」だけ。Day 062 の方法(5 回測って最小)で測る候補 |
ガードで文脈を書き換える(&mut Ctx)・遷移時のアクション | 設計が変わる(上の「前提が変わったら」)。別の日に |
| 表の何行目が届かないかを行単位で報告する検査 | macro_rules! では難しい。proc-macro(マクロ②)で扱う |
実在クレート(rust-fsm・statig・sm など)のガードの書き方との照合 | 一次資料を今日は取得していない。断言しない |
| XState のガードとの比較の照合 | 記憶に基づく要旨のみ(上の比較表に明記) |
| edition 2021 と 2024 での if-let ガード・let チェーンの扱いの差 | 手元の rustc 1.96.0 では両方で通ったことだけ確認。安定化された版は未確認 |
3 問の自己点検(/ai-smell-audit 相当)
- 同じ指示を AI に出せば数時間で出るか — ガード付きの
fsm!(案A)のコード自体は出る。ただし「案A' は定義時に落ちる」「案B は曖昧エラー」「案D は既定で 25 行・256 で 50 行」「unreachable のエラーは表の行を名指ししない」「tyの後ろに ident を置けず定義で落ちた」は、実際に動かして初めて出た結果。 - AI に指示するだけでは出ない部分 — 4案を同じ表で並べた実測と、そこから出した「既定は A、
if letが要る表だけ D」の判断記録(採らなかった案・前提が変わった場合・測れていないこと)。 - 世界の実物と比べて何割か —
matchのガードの仕様(Reference の Match expressions)のうち、扱ったのは「パターン一致時のみ評価」「左から評価し偽で止まる」「|で複数回評価」「if let・let チェーン」の4点で、属性付きのガード条件(OuterAttribute* let)や網羅性との関係の詳細は扱っていない。実在の状態機械クレートとの照合は 0 件(上の表に明記)。
➡️ 次のステップ
- 発展: 案D の muncher を、ガードのトークン数に依存しない形にできるか考える(例: ガードを
[ ]で囲む案Cの変形と比べて、書き味と上限を測る)。または、遷移と一緒に「アクション」(例: 試行回数を増やす)を返すfsm!に拡張する - 次回予告: マクロ① の残りの日程で、実在クレートのマクロ(一次資料を取得して照合)を読み、Day 063 の ADR の4基準のどれに当たるかを確かめる