フェーズ: 上級者 (Phase 3) | 問題タイプ: デバッグ・コードレビュー | テーマ: マクロ①(宣言的マクロ macro_rules!)
📚 背景知識(読んでから問題へ)
Day 064 で、fsm! にガード付きの行((状態, 事象) if 条件 => 行き先;)を書けるようにしました。マクロを作る側の問題はそこで片付きました。今日は使う側です。
マクロは「書き方」を決めますが、その書き方で書かれた中身が正しいかは守りません。ガード付きの遷移表には、マクロでもコンパイラでも止まらない誤りが入り込みます。今日のレビューで見る観点は3つです。
| 観点 | 中身 | 止まるか |
|---|---|---|
| 1. 行の順番 | 同じ (状態, 事象) の行が複数あると、上の行が勝つ(Day 064 T3 で実測) | 止まらない(ガード付きの行は到達可能性の検査に数えられない) |
| 2. ガードの副作用 | ガードは「パターンが一致した行の数だけ」評価される。副作用があると回数がずれる | 止まらない |
| 3. ガードの中の借用 | 生成される on() は文脈を &Ctx(共有参照)で受ける。ガードから値をムーブできない | 止まる(借用チェッカー) |
観点2 には、The Rust Reference に明記された落とし穴がもう1つあります(参照日 2026-10-05、https://doc.rust-lang.org/reference/expressions/match-expr.html 、今回取得して照合)。
"Multiple matches using the | operator can cause the pattern guard and the side effects it has to execute multiple times."
| でつないだ選択肢のうち複数が同じ値に一致すると、ガードはその回数だけ走ります。
観点3 についても、同じページにこうあります。
"Only when the guard evaluates successfully is the value moved, or copied, from the scrutinee into the variable."
つまり match 自身は、ガードの間は共有参照しか使わないように設計されています。ガードの中で自分からムーブを書けば、それは借用チェッカーに止められます。
📝 問題
環境は rustc 1.96.0、cargo new --lib reviewfsm(edition 2024)。依存クレートはありません。fsm! マクロ本体と Rejected は Day 064 の模範解答をそのまま使います(マクロ側は今日は変更しない)。
前提: 仕様(S1〜S7)
扉の暗証番号ロックです。チームで合意済みの仕様は次の7つです。
| # | 仕様 |
|---|---|
| S1 | Locked で Unlock、番号が正しければ Closed |
| S2 | 番号が未入力(None)なら「誤り」として扱い、Locked に留まる。panic しない |
| S3 | 誤り1回につき、失敗回数はちょうど +1 |
| S4 | 誤りの3回目で Alarm(1・2回目は Locked) |
| S5 | 失敗回数が既に3以上なら、正しい番号でも Alarm(総当たり対策) |
| S6 | Locked での Unlock は拒否(Err)されない。必ず Ok を返す |
| S7 | Alarm は管理者だけが Reset で Locked に戻せる |
レビュー対象の PR
同僚が次のコードを出してきました。このコードには不具合が5つあります。
use std::cell::Cell;
pub struct Ctx {
pub entered: Option<String>, // キーパッドで入力された番号(未入力なら None)
pub expected: String,
pub failures: Cell<u32>, // 失敗回数(ガードから数えるので Cell)
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: Cell::new(failures), is_admin }
}
/// 失敗を1回記録し、記録後の失敗回数を返す
pub fn record_failure(&self) -> u32 {
self.failures.set(self.failures.get() + 1);
self.failures.get()
}
}
fsm! {
context c: Ctx;
state Door { Locked, Closed, Open, Alarm }
event Ev { Unlock, Lock, Push, Pull, Reset }
(Locked, Unlock) if c.entered.unwrap() == c.expected => Closed;
(Locked, Unlock) if c.record_failure() >= 3 => Alarm;
(Locked, Unlock) if c.record_failure() < 3 => Locked;
(Closed, Lock) => Locked;
(Closed, Push) => Open;
(Open, Pull) => Closed;
(Alarm, Reset) if c.is_admin = true => Locked;
}
Part 1: コンパイルを通す(予想してから実行)
cargo buildを実行する前に、何個のエラーが出るか、どのエラー番号(E0xxx)が出るかを予想して書いてください- 実行して、予想との差を記録してください
- コンパイラの提案(
help:)をそのまま適用する方針で、通るまで直してください。何回ビルドしたか、各回どのエラーが出たかを記録してください
Part 2: 仕様テストで、コンパイラが止めない不具合を見つける
S1〜S7 をそれぞれ1本の #[test] にして、Part 1 の「コンパイラの提案どおりに直した版」に対して実行してください。実行前に、どのテストが落ちるかと、S4 の3回の結果の列を予想してください。
Part 3: 5つの不具合を表にまとめ、正しく直す
| # | どこ | 不具合の中身 | コンパイラが止めたか | どのテストが落としたか |
|---|
を埋め、S1〜S7 がすべて通る版を書いてください。行の追加・並べ替え・Ctx へのメソッド追加は自由。マクロ本体は変更しない。
Part 4: | パターンとガード(確かめるだけ)
次の2つの行で、audit()(呼ばれた回数を数え、false を返す)が何回呼ばれるかを予想し、テストで確かめてください。あわせて cargo clippy が警告を出すかどうかも記録してください。
(A | B, X | Y) if c.audit() => C; // 入力 (A, X)
(A | _, X) if c.audit() => C; // 入力 (A, X) と (B, X)
💡 ヒント
ヒント1(方向性)
- コンパイラのエラーは段階的に出ます。型の検査で止まると、借用の検査まで進みません。「エラーが1つ」は「不具合が1つ」を意味しません
- コンパイラの
help:は「コンパイルが通る直し方」であって、「仕様どおりの直し方」ではありません。S2 と照らし合わせてください - 失敗回数が「1回の誤りで何回増えるか」を、行を上から1つずつ追って数えてください
ヒント2(アプローチ)
c.is_admin = trueは Rust では代入式で、型は()です。ガードにはboolが要りますOption<String>::unwrap()はselfを取ります(ムーブ)。&Ctx越しにはムーブできません。as_ref()でもムーブは避けられますが、Noneで何が起きるかは別問題です。借用だけで比べるならas_deref()でOption<&str>にして、Some(c.expected.as_str())と比べますrecord_failure()が2つの行のガードにあると、誤り1回で何回呼ばれますか。3行目のガードが偽になったとき、その下にガード無しの受け皿はありますか- S5 は「締め出し判定を、番号の判定より上に置く」問題です
ヒント3(コード骨格)
impl Ctx {
pub fn code_ok(&self) -> bool {
self.entered.as_deref() == Some(self.expected.as_str())
}
}
fsm! {
// ...
(Locked, Unlock) if /* 締め出し済みか(副作用なし) */ => Alarm;
(Locked, Unlock) if c.code_ok() => Closed;
(Locked, Unlock) if /* 失敗を記録して3回目か */ => Alarm;
(Locked, Unlock) => Locked; // 受け皿
// ...
(Alarm, Reset) if c.is_admin => Locked;
}
✅ 模範解答
Part 1 の答え: ビルドは3回、エラーは毎回1個ずつ(実測、rustc 1.96.0)
予想で多いのは「E0308 と E0507 の2個が同時に出る」ですが、実際は1個ずつしか出ません。
| 回 | 出たエラー | 指した場所 | 適用した help: |
|---|---|---|---|
| 1回目 | E0308 mismatched types — expected bool, found () | 呼び出し側の表の c.is_admin = true(src/lib.rs:66:23) | you might have meant to compare for equality → == true(模範解答では c.is_admin だけにする) |
| 2回目 | E0507 cannot move out of c.entered which is behind a shared reference | 呼び出し側の c.entered(src/lib.rs:60:25) | 4つの提案のうち先頭の consider calling .as_ref() を採用 |
| 3回目 | E0277 can't compare &String with String | 呼び出し側の ==(src/lib.rs:60:53) | consider dereferencing here → *c.entered.as_ref().unwrap() |
| 4回目 | (成功) | — | — |
1回目のエラー出力(抜粋):
2回目(抜粋):
読み取ること:
- E0507 は1回目に出なかった。 rustc は型検査(E0308)で失敗すると、借用検査(E0507)まで進みません。「エラーが1つ=直す所が1つ」ではない
- 3つとも、マクロの中ではなく呼び出し側の行を指した。
$g:exprで受けた式は、呼び出し側のスパン(位置情報)を持ったままmatchに置かれるため。Day 064 のunreachable pattern(マクロ定義側を指した)とは対照的 - 2回目の
help:は4つ出たが、どれも「Noneのとき panic する」ことには触れていない。as_mut()の提案は&Ctx越しでは通らないはず(未実測)
Part 2 の答え: 7本中5本が落ちる(実測)
「コンパイラの提案どおりに直した版」に S1〜S7 を流した結果:
test tests::s1_correct_code_opens ... ok
test tests::s2_no_input_stays_locked ... FAILED
test tests::s3_one_failure_counts_once ... FAILED
test tests::s4_third_failure_alarms ... FAILED
test tests::s5_lockout_beats_correct_code ... FAILED
test tests::s6_unlock_never_rejected ... FAILED
test tests::s7_only_admin_resets ... ok
test result: FAILED. 2 passed; 5 failed
| テスト | 実際の結果 | 期待 |
|---|---|---|
| S2 | panic: `called Option::unwrap() on a None value(src/lib.rs:60:45`) | Ok(Locked) |
| S3 | 失敗回数 2 | 1 |
| S4 | [Ok(Locked), Ok(Alarm), Ok(Alarm)] | [Ok(Locked), Ok(Locked), Ok(Alarm)] |
| S5 | Ok(Closed) | Ok(Alarm) |
| S6 | 失敗回数 1 から始めると Err(Rejected)(failures=1 で落ちた) | 常に Ok |
S4・S6 を行ごとに追うと、原因は1つ(record_failure() が2行にある)から2つの症状が出ています。
| 開始時の失敗回数 | 2行目のガード | 3行目のガード | 結果 |
|---|---|---|---|
| 0 | 呼んで 1 → 1 >= 3 偽 | 呼んで 2 → 2 < 3 真 | Ok(Locked)、回数は 2 |
| 1 | 呼んで 2 → 偽 | 呼んで 3 → 3 < 3 偽 | 受け皿が無い → Err |
| 2 | 呼んで 3 → 真 | 評価されない | Ok(Alarm) |
つまり「3回で警報」のつもりが、2回目の誤りで警報になり、条件によっては Unlock が拒否されます。どちらもコンパイルは通り、警告も出ません。
Part 3 の答え: 5つの不具合と修正版(7本すべて通過を実測)
| # | どこ | 不具合の中身 | コンパイラが止めたか | 落としたテスト |
|---|---|---|---|---|
| B1 | 1行目と2・3行目の順番 | 締め出し判定が番号判定より下にある。失敗3回以上でも正しい番号なら開く | 止めない | S5 |
| B2 | 2行目と3行目の record_failure() | 副作用のあるガードが2行にあり、誤り1回で2回数える | 止めない | S3・S4 |
| B3 | 1行目の c.entered.unwrap() | &Ctx 越しのムーブ(E0507)。提案どおり as_ref().unwrap() にすると今度は None で panic | 半分だけ止めた(ムーブは止めたが、panic は止めない) | S2 |
| B4 | 最後の行の c.is_admin = true | == の打ち間違いで代入式になった | 止めた(E0308) | —(コンパイル時) |
| B5 | (Locked, Unlock) に受け皿が無い | ガードが全部偽だと Err(Rejected) になる | 止めない | S6 |
修正版(cargo new --lib reviewfsm の src/lib.rs に貼れば検証できます。fsm! 本体は Day 064 と同一):
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 entered: Option<String>,
pub expected: String,
pub failures: Cell<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: Cell::new(failures), is_admin }
}
/// 入力が期待値と一致するか(借用だけで比べる。None なら false)
pub fn code_ok(&self) -> bool {
self.entered.as_deref() == Some(self.expected.as_str())
}
/// 失敗を1回記録し、記録後の失敗回数を返す
pub fn record_failure(&self) -> u32 {
self.failures.set(self.failures.get() + 1);
self.failures.get()
}
}
fsm! {
context c: Ctx;
state Door { Locked, Closed, Open, Alarm }
event Ev { Unlock, Lock, Push, Pull, Reset }
(Locked, Unlock) if c.failures.get() >= 3 => Alarm; // B1: 締め出しを最初に見る(副作用なし)
(Locked, Unlock) if c.code_ok() => Closed; // B3: 借用だけで比べる(None でも panic しない)
(Locked, Unlock) if c.record_failure() >= 3 => Alarm; // B2: 副作用のあるガードは最後のガード行に1つだけ
(Locked, Unlock) => Locked; // B5: 受け皿(S6: Unlock は拒否しない)
(Closed, Lock) => Locked;
(Closed, Push) => Open;
(Open, Pull) => Closed;
(Alarm, Reset) if c.is_admin => Locked; // B4: 代入ではなく bool をそのまま
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn s1_correct_code_opens() {
let c = Ctx::new(Some("1234"), 0, false);
assert_eq!(Door::Locked.on(Ev::Unlock, &c), Ok(Door::Closed));
}
#[test]
fn s2_no_input_stays_locked() {
let c = Ctx::new(None, 0, false);
assert_eq!(Door::Locked.on(Ev::Unlock, &c), Ok(Door::Locked));
}
#[test]
fn s3_one_failure_counts_once() {
let c = Ctx::new(Some("0000"), 0, false);
assert_eq!(Door::Locked.on(Ev::Unlock, &c), Ok(Door::Locked));
assert_eq!(c.failures.get(), 1);
}
#[test]
fn s4_third_failure_alarms() {
let c = Ctx::new(Some("0000"), 0, false);
let r: Vec<_> = (0..3).map(|_| Door::Locked.on(Ev::Unlock, &c)).collect();
assert_eq!(r, vec![Ok(Door::Locked), Ok(Door::Locked), Ok(Door::Alarm)]);
}
#[test]
fn s5_lockout_beats_correct_code() {
let c = Ctx::new(Some("1234"), 3, false);
assert_eq!(Door::Locked.on(Ev::Unlock, &c), Ok(Door::Alarm));
}
#[test]
fn s6_unlock_never_rejected() {
for f in 0..6 {
let c = Ctx::new(Some("0000"), f, false);
assert!(Door::Locked.on(Ev::Unlock, &c).is_ok(), "failures={f}");
}
}
#[test]
fn s7_only_admin_resets() {
let c = Ctx::new(None, 3, false);
assert!(Door::Alarm.on(Ev::Reset, &c).is_err());
let a = Ctx::new(None, 3, true);
assert_eq!(Door::Alarm.on(Ev::Reset, &a), Ok(Door::Locked));
}
}
実行結果(実測、rustc 1.96.0、edition 2024):
running 7 tests
test tests::s1_correct_code_opens ... ok
test tests::s3_one_failure_counts_once ... ok
test tests::s5_lockout_beats_correct_code ... ok
test tests::s2_no_input_stays_locked ... ok
test tests::s4_third_failure_alarms ... ok
test tests::s7_only_admin_resets ... ok
test tests::s6_unlock_never_rejected ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
cargo clippy --lib も警告 0 件(実測)。
Part 4 の答え: 重なる | ではガードが2回走る。clippy はマクロ越しだと黙る(実測)
tests/orpat.rs に置いた検証コード:
use reviewfsm::fsm;
use std::cell::Cell;
pub struct Log { pub n: Cell<u32> }
impl Log {
/// 監査ログを1件書いた体で数え、false を返す(行は成立させない)
pub fn audit(&self) -> bool { self.n.set(self.n.get() + 1); false }
}
mod disjoint {
use super::*;
fsm! {
context c: Log;
state S { A, B, C }
event E { X, Y }
(A | B, X | Y) if c.audit() => C; // 選択肢が重ならない
(_, _) => A;
}
}
mod overlap {
use super::*;
fsm! {
context c: Log;
state S { A, B, C }
event E { X, Y }
(A | _, X) if c.audit() => C; // A は `A` と `_` の両方に一致する
(_, _) => A;
}
}
#[test]
fn reference_example() {
// The Rust Reference の例をそのまま
let i: Cell<i32> = Cell::new(0);
match 1 {
1 | _ if { i.set(i.get() + 1); false } => {}
_ => {}
}
assert_eq!(i.get(), 2);
}
#[test]
fn disjoint_or_pattern_evaluates_guard_once() {
let c = Log { n: Cell::new(0) };
disjoint::S::A.on(disjoint::E::X, &c).unwrap();
assert_eq!(c.n.get(), 1);
}
#[test]
fn overlapping_or_pattern_evaluates_guard_twice() {
let c = Log { n: Cell::new(0) };
overlap::S::A.on(overlap::E::X, &c).unwrap();
assert_eq!(c.n.get(), 2);
let c = Log { n: Cell::new(0) };
overlap::S::B.on(overlap::E::X, &c).unwrap();
assert_eq!(c.n.get(), 1);
}
| 行 | 入力 | audit() の回数(実測) |
|---|---|---|
(A | B, X | Y) | (A, X) | 1 |
(A | _, X) | (A, X) | 2 |
(A | _, X) | (B, X) | 1 |
Reference の例 1 | _ | 1 | 2 |
cargo test --test orpat は 3 本通過、コンパイラの警告 0 件。cargo clippy --all-targets は wildcard_in_or_patterns(_ を含む | は意味が無い)を Reference の例の行(tests/orpat.rs:35)にだけ出し、fsm! の中の (A | _, X) には出しませんでした。マクロ展開で作られたコードは、clippy の多くの検査が対象外にするためと考えられます(clippy の実装は未確認)。
読み取ること: enum の状態名を | で並べるだけ(重ならない)なら1回で済みます。_ やバインディングを混ぜて選択肢が重なると、副作用のあるガードは回数がずれ、マクロ越しでは clippy も教えてくれません。 B2 と同じ「ガードに副作用を書くと回数がずれる」問題の、もう1つの入口です。
🪜 Step-by-Step 解説
Step 1: エラーは段階ごとに1つずつ出る
rustc は「構文解析 → マクロ展開 → 名前解決 → 型検査 → 借用検査」の順に進み、ある段階で失敗すると多くの場合そこで止まります。今日の PR は E0308(型検査)で止まり、E0507(借用検査)は直してから初めて見えました。レビューでは「コンパイラが通らない」の1個目のエラーを直して安心しない。直したら必ずもう一度ビルドします。
Step 2: help: は「通す」ための提案で、「仕様を満たす」ための提案ではない
E0507 の4つの提案は、すべて「ムーブをやめる」方向でした。as_ref().unwrap() を採ると E0277 → * の追加で通りますが、None で panic します。コンパイラは「entered が None のとき何をすべきか」(S2)を知りません。仕様を知っているのはレビューする人だけです。
正しい直し方は、「借用したまま、Option のまま比べる」ことです。
self.entered.as_deref() == Some(self.expected.as_str()) // Option<&str> 同士の比較
as_deref() は Option<String> を Option<&str> に変えます(ムーブもコピーも起きない)。None は自然に「一致しない」になります。
Step 3: 副作用のあるガードは「行を通過した回数」だけ走る
Day 064 の T5 で、ガードはパターンが一致した行の数だけ評価されると実測しました。今日の B2 はその裏返しで、同じ (Locked, Unlock) の3行に record_failure() が2つあると、1回の誤りで2回数えます。
直し方は2通りあります。
- 今日の模範解答(最小の修正): 副作用のあるガードを、同じパターンの最後のガード行に1つだけ置く。上の行は副作用なし(
failures.get()を読むだけ) - より堅い設計(採らなかった案。下の「迷った他案」を参照): ガードは読むだけにして、失敗回数の更新は
on()の結果を見て呼び出し側で行う
Step 4: 行の順番が仕様を表す
S5(締め出し済みなら正しい番号でも警報)は「締め出し判定を番号判定より上に置く」ことでしか表せません。Day 064 T3 の「上の行が勝つ」がここで仕様になります。ガード付きの行は到達可能性の検査に数えられないので、順番の誤りはコンパイラにもマクロにも止められません。テスト(S5)だけが止めます。
Step 5: 受け皿の有無は「拒否するか、留まるか」の判断
ガード付きの行だけで終わると、全部偽のときは最後の (state, event) => Err(...) に落ちます。S6 は「Unlock は拒否しない」なので、ガード無しの (Locked, Unlock) => Locked; が必要です。B5 は B2 と組み合わさって初めて表に出た(失敗回数 1 から始めたときだけ Err)ので、for f in 0..6 で開始状態を振るテストでないと見つかりません。
Step 6: どの誤りを、誰が止めるか
| 止める仕組み | 今日止めたもの |
|---|---|
| コンパイラ(型検査・借用検査) | B4(代入)、B3 の前半(ムーブ) |
fsm! の #[deny(unused_variables, unreachable_patterns)] | 今日は該当なし(Day 064 の Opn・ガード無し行の後ろの行) |
| 仕様テスト | B1・B2・B3 の後半(panic)・B5 |
| clippy | 今日の行は何も(マクロ越しの A | _ も黙った) |
🧭 設計思想・なぜこう書くのか
Rust の match は、ガードの間は共有参照しか使わず、ガードが真になってから初めて値をムーブします(Reference の引用、背景知識)。これは「ガードは判断であって変更ではない」という設計です。fsm! が文脈を &Ctx で渡しているのも同じ考え方で、ガードから文脈を書き換えられないようにしています。
今日の PR が B2 を作れたのは、Cell で & 越しの書き換え(内部可変性)を持ち込んだからです。Cell 自体は正しい道具ですが、ガードに持ち込むと「判断の回数=変更の回数」になり、行の並べ方で数がずれます。型システムが止めてくれる範囲(& なので書き換え不可)を、自分で外したことになります。
迷った他案と採らなかった理由
| 案 | 中身 | 採らなかった理由 | ||
|---|---|---|---|---|
| (a) 今日の模範解答 | 副作用ガードを最後のガード行に1つだけ置く | 採用。 PR の形を保ったまま最小の差分で S1〜S7 を満たす。ただし「最後に1つだけ」という約束は表のコメントにしか残らず、次に行を足す人が破れる | ||
| (b) ガードを純粋にし、回数の更新は呼び出し側 | on() は failures.get() + 1 >= 3 を読むだけ、呼び出し側が結果を見て failures += 1 | 副作用がガードから消え、Cell も要らなくなる(&mut Ctx を呼び出し側で持つ)。ただし「失敗とは何か」の判定が on() の外にも必要になり、表と呼び出し側の2か所に分かれる。未実装・未測定 | ||
| (c) 遷移と一緒に「アクション」を返す | on() が (次の状態, Vec<Action>) を返し、Action::RecordFailure を呼び出し側が実行 | 状態機械の定番の形(XState の actions、UML の遷移アクション)だが、fsm! の生成するシグネチャが変わる。マクロを変更しないという今日の条件から外れる | ||
| (d) マクロで ` | ` を禁止する | $s:pat を $s:ident に変える | 重なる ` | は防げるが、(Locked | Closed, Lock)` のような安全な書き方も書けなくなる。Day 058 の表の短さを失う |
前提が変わったら: 失敗回数のような「数える副作用」が2つ以上の状態にまたがる(例: Closed での誤操作も数える)なら、(a) の「最後の行に1つ」は守れなくなるので (c) に移ります。逆に、文脈が読むだけで済む表(今日の is_admin のような条件だけ)なら、Cell を Ctx から外して、ガードの副作用そのものを型で禁じるのが一番安全です。
🚨 コンパイルエラーが出た場合(デバッグ問題では必須)
今日実際に出たエラー(すべて rustc 1.96.0 の実出力)の読み方です。
| エラー | 出た原因 | 読み方 | 正しい直し方 |
|---|---|---|---|
E0308 mismatched types: expected bool, found () | ガードに x = true(代入) | Rust の代入式は () を返す。ガードには bool が要る | if c.is_admin(== true も通るが冗長) |
E0507 cannot move out of c.entered which is behind a shared reference | &Ctx 越しに Option<String>::unwrap()(self を取る) | note: の takes ownership of the receiver self が原因の行。behind a shared reference は「& しか持っていない」の意味 | as_deref() で Option<&str> にして比べる |
E0277 can't compare &String with String | as_ref().unwrap() で &String になり、右辺は String のまま | 左右の型を help: が示す。* で揃えると通る | そもそも unwrap をやめる(上の行) |
実行時 panic: `called Option::unwrap() on a None value` | E0507 の提案どおりに直した結果 | 位置(src/lib.rs:60:45)は呼び出し側の表の行 | 同上 |
エラーが呼び出し側を指す理由: $g:expr で受けた式は、呼び出し側で書かれたトークン(とその位置情報)のまま match のガードに置かれます。だから、ガードの中の型・借用のエラーは表の行を正確に指します。これは Day 064 で見た unreachable pattern(マクロ定義側を指し、表の何行目かを言わない)と対照的で、マクロに渡した部品の中の誤りは位置が分かり、マクロが組み立てた構造の誤りは位置が分かりにくい、という違いです。
🌐 他言語との比較(該当する場合)
| 観点 | Rust(match ガード) | TypeScript | Go | Java 21(switch パターン+when) |
|---|---|---|---|---|
ガードで == を = と打ち間違える | () と bool の型エラーで止まる(実測) | if (x = true) は通る(strict でも止まらない。ESLint の no-cond-assign で検出) | if x = true { は構文エラー | when x = true は boolean 型の代入式なので通る(未実測) |
| ガードの中で値をムーブ/破棄 | 共有参照越しのムーブは E0507 | 該当概念なし(参照のみ) | 該当概念なし | 該当概念なし |
None/null の扱い | unwrap() は実行時 panic、as_deref() で型のまま比べられる | entered! は通って実行時 undefined 比較(panic しない) | *entered は nil で panic | entered.equals(...) は NullPointerException |
| 重なる選択肢でガードが複数回 | 走る(Reference に明記、実測 2 回) | switch に該当機能なし | 該当機能なし | 未確認 |
TypeScript・Go・Java の列は、今日は実行して確かめていません(言語仕様の記憶に基づく要旨)。
🏭 実務での使いどころ
- ガード付き遷移表のレビュー項目として、今日の5つはそのまま使えます: ①同じ
(状態, 事象)の行の順番 ②ガードの副作用 ③&越しのムーブ ④代入の打ち間違い ⑤受け皿の有無 - 仕様テストは開始状態を振る: B5 は失敗回数 0 から始めるテストでは見つからず、1 から始めたときだけ出ました。状態機械のテストでは、
forで文脈(回数・フラグ)を振るのが安く効きます(S6) - コンパイラの提案を機械的に当てない: 特に
unwrapを含む提案は、None/Errの場合の仕様と照らし合わせます。AI agent に「コンパイルを通して」と頼んだときに一番入りやすい不具合でもあります(今日の Part 1 はその再現) - 認証・課金まわりの回数制限(ログイン試行・API レート・OTP 再送)は、今日の B2 と同じ「判断の中で数える」形になりがちです。数える場所を1か所にする(案 (b)(c))のが定石です
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| 「エラーが1つなら、直す所は1つ」 | 全部の検査が同時に走ると思う | 型検査で止まると借用検査まで進まない。今日は 1 個ずつ 3 回出た(実測) |
「help: の提案どおり直せば正しい」 | コンパイラが仕様を知っていると思う | 提案は「通す」ため。as_ref().unwrap() は None で panic(実測) |
| 「ガードは1行に1回だけ評価される」 | 1行=1判定だと思う | パターン一致した行の数だけ。さらに重なる | では1行で2回(実測) |
「&Ctx で渡しているから、ガードは文脈を変えられない」 | & は不変だと思う | Cell/RefCell で内部可変性を持ち込むと変えられる。B2 はこれ |
「clippy が A | _ を教えてくれる」 | 普通の match では教えてくれる | マクロの中の行には出なかった(実測) |
| 「受け皿が無くても、ガードのどれかは当たる」 | 条件を網羅したつもりになる | ガード付きの腕は網羅性に数えられない。副作用で条件がずれると全部偽になる(S6、実測) |
🔗 一次資料(参照日: 2026-10-05。今回取得して、引用部分を照合済み)
- The Rust Reference, Match expressions: https://doc.rust-lang.org/reference/expressions/match-expr.html
- "Multiple matches using the
|operator can cause the pattern guard and the side effects it has to execute multiple times."
- "Only when the guard evaluates successfully is the value moved, or copied, from the scrutinee into the variable."
- Part 4 の
reference_exampleは、このページの例(1 | _ if { i.set(i.get() + 1); false }→i == 2)を手元で実行して一致を確認したもの
- "Multiple matches using the
🚧 今日扱わなかった範囲(次回以降)
| 扱わなかったこと | 理由・次の予定 |
|---|---|
| 案 (b)(純粋なガード+呼び出し側で更新)・案 (c)(アクションを返す)の実装と比較 | 今日は「マクロを変えない」レビューに絞った。(c) は fsm! の拡張なので、マクロ① の残り期間で実装候補 |
E0507 の as_mut() 提案が &Ctx で実際に通らないこと | 推定のみ。未実測 |
clippy がマクロ展開のコードを黙る理由(in_external_macro 等の判定) | clippy のソースを今日は読んでいない。「黙った」という観測だけ |
| TypeScript・Go・Java の比較列 | 実行していない(比較表に明記) |
| Day 064 からの持ち越し(案C・D の上限、4案のコンパイル時間、実在の状態機械クレートとの照合 0 件) | 今日は使う側のレビューに集中。実在クレート(rust-fsm・statig)の一次資料の取得は次の概念理解の日に |
失敗回数の永続化・並行アクセス(2台のキーパッドから同時に Unlock) | Cell は Sync でないので複数スレッドからは使えない。並行の扱いは Phase 3 前半(Arc/Mutex/atomic)の復習として別の日に |
3 問の自己点検(/ai-smell-audit 相当)
- 同じ指示を AI に出せば数時間で出るか — 「不具合入りのコードと直し方」自体は出る。ただし「エラーが1個ずつ3回出る」「E0507 の提案どおりに直すと E0277 を経て
Noneで panic する」「S4 が[Locked, Alarm, Alarm]」「失敗回数1から始めたときだけErr」「clippy はマクロ内のA | _を黙る」は、実際にビルドとテストを回して得た結果。 - AI に指示するだけでは出ない部分 — コンパイラの
help:を機械的に当てた場合の経路(3回のビルド)を実出力で残したことと、5つの不具合を「誰が止めるか」(コンパイラ/マクロの deny/仕様テスト/clippy)で仕分けた表、副作用の置き場所の4案の判断記録。 - 世界の実物と比べて何割か — Reference の Match expressions のうち、今日扱ったのは「
|での複数回評価」「ガード中は共有参照でムーブはガード成功後」の2点(Day 064 の「パターン一致時のみ評価」「if letガード」と合わせて4点)。状態機械の実物(XState の actions、UML の遷移アクション)とは、案 (c) として名前を挙げただけで、一次資料の照合は 0 件。
➡️ 次のステップ
- 発展: 案 (c) を実装する —
fsm!の行に=> Locked / RecordFailure;のようにアクションを書けるようにし、on()が(次の状態, アクション)を返す形にする。ガードからCellを取り除けるかを S1〜S7 で確かめる - 次回予告: マクロ① の残り期間で、実在クレート(
rust-fsm・statig・bitflags)のマクロの一次資料を取得して読み、Day 063 の ADR の4基準のどれに当たるかを照合する(概念理解)