フェーズ: 上級者 (Phase 3) | 問題タイプ: デバッグ・コードレビュー | テーマ: マクロ①(宣言的マクロ macro_rules!)
📚 背景知識(読んでから問題へ)
Day 068 で、表の行に Audit(c.failures + 1)(bool を忘れた)と書くと、E0061 がマクロ定義の行を指し、help: がマクロ定義そのものを書き換える提案を出すことを確かめました。Day 069 でも「E0080 の下線は panic! の行を指す」と書いて止めています。
表を書く人(扉の仕様を決める人)は、マクロの中身を読みません。エラーが src/lib.rs:56 の emit($Action::$a $( ( $($arg),* ) )?) を指しても、どの行のどのアクションが悪いのか分かりません。エラーの位置は、マクロの利用者体験そのものです。
macro_rules! の生成したコードの各トークンは、元の位置(span)を覚えています。表から来た c.failures + 1 は表の行の位置、マクロ定義に書いた emit( や :: はマクロ定義の位置です。コンパイラはエラーを出すとき、その式の位置を使います。問題は、式が両方の位置のトークンから組み立てられたとき、どちらが選ばれるかです。
今日は、同僚が出した PR をレビューします。
PR #70「fsm!: アクションの引数の誤りを表の行で報告する」
表を書く人から「エラーがマクロの中を指して分からない」と苦情が来たので直します。
📝 問題
環境は rustc 1.96.0、cargo new --lib spanfsm(edition 2024)。出発点は Day 069 の fsm!(max 4、ROW_ACTIONS、MAX_ACTIONS)と扉の表です。警報の行(3行目)のアクションの1つを、次の 7 通りに壊して試します。
| # | 3行目に書くアクション | 誤りの種類 |
|---|---|---|
| E1 | Audit(c.failures + 1) | 引数が足りない |
| E2 | Audit(c.failures + 1, 0) | 引数の型が違う |
| E3 | NotifyGuard(1) | 引数を取らない variant に引数 |
| E4 | Audit | 引数が要る variant に引数なし |
| E5 | Audti(c.failures + 1, false) | variant 名の綴り誤り |
| E6 | Audit(c.failures + 1, false, 9) | 引数が多い |
| E7 | if c.is_admin { SoundAlarm } else { NotifyGuard } | variant 以外の式 |
Part 1: 現状を予想する
Day 069 の fsm! のまま E1〜E6 を入れたとき、それぞれのエラー番号と、エラーが表の行を指すかマクロ定義を指すかを予想してください。E7 は通るか、止まるか。
Part 2: PR の版1をレビューする
版1の差分(行のアクション部分だけ):
- $( / [ $( $a:ident $( ( $($arg:expr),* ) )? ),* $(,)? ] )? ; )*
+ $( / [ $( $a:ident $($args:tt)? ),* $(,)? ] )? ; )*
...
- $( $( emit($Action::$a $( ( $($arg),* ) )?); )* )?
+ $( $( emit($Action::$a $($args)?); )* )?
意図は「丸括弧ごと tt で受ければ、括弧の位置が表の行になる」。正しい表(壊していない表)でこれを cargo build すると何が起きるか、理由とともに答えてください。
Part 3: PR の版2をレビューする
版1が通らなかったので、同僚は版2に変えました。
- $( / [ $( $a:ident $( ( $($arg:expr),* ) )? ),* $(,)? ] )? ; )*
+ $( / [ $( $a:expr ),* $(,)? ] )? ; )*
...
- use {$State::*, $Event::*};
+ use {$State::*, $Event::*, $Action::*};
...
- $( $( emit($Action::$a $( ( $($arg),* ) )?); )* )?
+ $( $( emit($a); )* )?
同僚の報告は「E1〜E6 がすべて表の行を指すようになった」。これは実測でも正しい。それでもこの PR を承認してはいけない理由を2つ挙げてください。1つは次の要件で表面化します。
要件 M-1: 扉を閉めたまま施錠(Ev::Lock)するとき、錠のモーターを動かすアクションLockを出すこと。action Act { .., Lock }、(Closed, Lock) => Locked / [Lock];
Part 4: レビュアーの対案を疑う
あなたは最初こう提案しかけました。「グロブが問題なら、variant を1つだけ import して、裸の名前で呼べばいい」:
$em({ use $Act::$a; $a $args }) // $args は利用者の丸括弧をそのまま tt で
呼び出しの式は $a(表の行)と $args(表の行)だけでできています。E1 と E5 がどこを指すかを予想してください。
Part 5: 直す
次をすべて満たす fsm! を書いてください。
- E1〜E6 のエラーがすべて表の行を指す
- E7(variant 以外の式)や
Act::SoundAlarm(完全修飾)は表の行で止まる(Day 066 の「アクションはVariantかVariant(..)だけ」を守る) - 要件 M-1 の表(アクション名
Lockと イベント名Lockが同じ)が通り、Act::Lockが出る - Day 069 の
ROW_ACTIONS・MAX_ACTIONS・max 4の検査はそのまま動く(行ごとの数[2, 2, 4, 2, 0, 0, 0, 1]) cargo clippy --all-targetsで警告 0 件
Part 6: レビューコメントを書く
PR #70 に付けるコメントを 10 行以内で書いてください。版1・版2・Part 4 の対案を採らない理由と、Part 5 の形の代償(コンパイル時間など)、前提が変わったら判断がどう変わるかを含めること。
💡 ヒント
ヒント1(方向性)
- 版1:
macro_rules!の照合器は先読みをしません。RecordFailureの直後の,を見た時点で、それが$args:ttなのか区切りの,なのかを決められるでしょうか - 版2: グロブ import が同じスコープに2つあり、同じ名前を持っていたら、その名前を使った時点でどうなるか。もう1つは「
$a:exprは何でも受ける」こと - Part 4: E1〜E6 のうち、Day 069 で表の行を指していたのは E2(型)と E5(綴り)でした。その違いは「エラーの位置が引数の式か呼び出しの式か」です
ヒント2(アプローチ)
- 実測で分かった規則は1つです: 呼び出しの式そのものが、利用者のトークンから
exprとして構文解析されたときだけ、E0061・E0618 は表の行を指す。マクロの本体で$Act::$a $argsのように組み立てた式は、部品がすべて利用者のトークンでもマクロ定義を指した - ということは、「形の検査」と「式としての取り込み」を別の場所でやればよい。形は
identとident(..)だけを受ける補助マクロで検査し、生成は同じトークン列を$a:exprで受ける補助マクロで行う - 行のアクション列は
[ $($acts:tt)* ]で丸ごと受け、補助マクロに渡します(tt muncher)。腕は上から順に試されるので、$a:ident ,の腕を先に書けば$a:ident (..)と曖昧になりません - グロブは
fnのスコープではなく、emit({ use $Act::*; $a })のブロックの中に置く
ヒント3(コード骨格)
#[macro_export]
macro_rules! __count { // 形の検査 + 数え上げ
() => { 0usize };
($a:ident $(, $($rest:tt)*)?) => { 1usize + $crate::__count!($($($rest)*)?) };
($a:ident ( $($arg:tt)* ) $(, $($rest:tt)*)?) => { 1usize + $crate::__count!($($($rest)*)?) };
}
#[macro_export]
macro_rules! __emit { // 同じ列を expr として取り込み直す
($em:ident, $Act:ident; ) => {};
($em:ident, $Act:ident; $a:expr $(, $($rest:tt)*)?) => {
$em({ #[allow(unused_imports)] use $Act::*; $a });
$crate::__emit!($em, $Act; $($($rest)*)?);
};
}
// fsm! の行: $( / [ $($acts:tt)* ] )?
// ROW_ACTIONS: 0usize $( + $crate::__count!($($acts)*) )?
// on_emit: $( $crate::__emit!(emit, $Action; $($acts)*); )?
✅ 模範解答
Cargo.toml:
[package]
name = "spanfsm"
version = "0.1.0"
edition = "2024"
[dependencies]
smallvec = "=1.16.1" # Day 069 から引き継ぐ。今日のコードでは使わない
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,
}
/// 行のアクション列の「形」を検査しながら数える。許す形は `Variant` と `Variant(..)` の2つだけ。
/// 形が違うと `no rules expected` が利用者の行のトークンを指す
#[doc(hidden)]
#[macro_export]
macro_rules! __count {
() => { 0usize };
($a:ident $(, $($rest:tt)*)?) => { 1usize + $crate::__count!($($($rest)*)?) };
($a:ident ( $($arg:tt)* ) $(, $($rest:tt)*)?) => { 1usize + $crate::__count!($($($rest)*)?) };
}
/// 同じ列を、1個ずつ `expr` として取り込み直して emit を生成する。
/// 呼び出し式全体が利用者のトークンから構文解析されるので、型・引数の数の誤りは表の行を指す。
/// グロブ `use $Act::*` はこのブロックの中だけに置き、State/Event の名前とぶつけない
#[doc(hidden)]
#[macro_export]
macro_rules! __emit {
($em:ident, $Act:ident; ) => {};
($em:ident, $Act:ident; $a:expr $(, $($rest:tt)*)?) => {
$em({
#[allow(unused_imports)]
use $Act::*;
$a
});
$crate::__emit!($em, $Act; $($($rest)*)?);
};
}
/// Day 069 の fsm! の行のアクション部分だけを `[ $($acts:tt)* ]` で受け、
/// 形の検査(__count)と生成(__emit)を補助マクロに分けたもの
#[macro_export]
macro_rules! fsm {
(
context $c:ident : $Ctx:ty ;
state $State:ident { $($S:ident),+ $(,)? }
event $Event:ident { $($E:ident),+ $(,)? }
action $Action:ident $( max $cap:literal )? { $( $A:ident $( ( $($T:ty),* ) )? ),* $(,)? }
$( ($s:pat, $e:pat) $(if $g:expr)? => $to:ident
$( / [ $($acts:tt)* ] )? ; )*
) => {
#[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 {
/// 各行の (行の文字列, アクション数)。数えるついでに形を検査する
pub const ROW_ACTIONS: &'static [(&'static str, usize)] = &[
$( (
stringify!(($s, $e) $(if $g)?),
0usize $( + $crate::__count!($($acts)*) )?
) ),*
];
pub const MAX_ACTIONS: usize = {
let rows = Self::ROW_ACTIONS;
let (mut i, mut m) = (0, 0);
while i < rows.len() {
if rows[i].1 > m { m = rows[i].1; }
i += 1;
}
m
};
#[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)? => {
$( $crate::__emit!(emit, $Action; $($acts)*); )?
::core::result::Result::Ok($to)
} )*
(state, event) => ::core::result::Result::Err($crate::Rejected { state, event }),
}
}
}
$(
const _: () = {
let rows = $State::ROW_ACTIONS;
let mut i = 0;
while i < rows.len() {
if rows[i].1 > $cap {
panic!("{}", rows[i].0);
}
i += 1;
}
};
)?
};
}
pub mod door {
#[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())
}
}
crate::fsm! {
context c: Ctx;
state Door { Locked, Closed, Open, Alarm }
event Ev { Unlock, Lock, Push, Pull, Reset }
action Act max 4 { RecordFailure, ResetFailures, SoundAlarm, NotifyGuard, Audit(u32, bool) }
(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, NotifyGuard];
(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>;
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))
}
}
/// 名前の衝突の検査用(要件 M-1): アクションに Ev と同じ名前 `Lock`(錠のモーターを動かす)がある表
pub mod motor {
pub struct Ctx;
crate::fsm! {
context _c: Ctx;
state St { Closed, Locked }
event Ev { Lock, Unlock }
action Act { Lock, Release }
(Closed, Lock) => Locked / [Lock];
(Locked, Unlock) => Closed / [Release];
}
}
#[cfg(test)]
mod tests {
use super::door::*;
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];
/// S9: 3回目の誤りの列(順序込み)は Day 069 と同じ
#[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, Act::NotifyGuard]);
}
/// S16': 数え方を __count に変えても行ごとの数と MAX_ACTIONS は Day 069 と同じ
#[test]
fn s16_row_counts_unchanged() {
let counts: Vec<usize> = Door::ROW_ACTIONS.iter().map(|r| r.1).collect();
println!("row counts = {counts:?}, MAX_ACTIONS = {}", Door::MAX_ACTIONS);
assert_eq!(counts, vec![2, 2, 4, 2, 0, 0, 0, 1]);
assert_eq!(Door::MAX_ACTIONS, 4);
}
/// S17: 720 通りで、emit された数が ROW_ACTIONS のどれかと一致し MAX_ACTIONS を超えない
#[test]
fn s17_emitted_len_matches_row_count_720() {
let (mut n, mut ok_cases) = (0, 0);
for e in [None, Some("1234"), Some("0000")] {
for f in 0..6 {
for adm in [false, true] {
let c = Ctx::new(e, f, adm);
for s in STATES {
for ev in EVENTS {
n += 1;
if let Ok((_, v)) = on_vec(s, ev, &c) {
ok_cases += 1;
assert!(Door::ROW_ACTIONS.iter().any(|r| r.1 == v.len()));
assert!(v.len() <= Door::MAX_ACTIONS);
}
}
}
}
}
}
println!("cases={n} accepted={ok_cases}");
assert_eq!(n, 720);
}
/// S18: アクション名が Ev の名前とぶつかっても、ブロック内のグロブが Act を選ぶ(要件 M-1)
#[test]
fn s18_action_named_like_event() {
use super::motor;
let mut got = vec![];
let to = motor::St::Closed.on_emit(motor::Ev::Lock, &motor::Ctx, &mut |a| got.push(a)).unwrap();
assert_eq!(to, motor::St::Locked);
assert_eq!(got, vec![motor::Act::Lock]);
}
}
テスト結果(実測、rustc 1.96.0、edition 2024、Apple M1・8GB、2026-10-10)
running 4 tests
row counts = [2, 2, 4, 2, 0, 0, 0, 1], MAX_ACTIONS = 4
.cases=720 accepted=162
...
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
cargo clippy --offline --all-targets の警告は 0 件(grep -c "^warning" で 0、実測)。
Part 1 の答え(実測、Day 069 の fsm! のまま)
| # | エラー | 指した場所 |
|---|---|---|
| E1 引数が足りない | E0061 | マクロ定義(emit($Action::$a ...) の行)。help: はマクロ定義を書き換える提案 |
| E2 型が違う | E0308 | 表の行(0 の位置) |
| E3 unit に引数 | E0618 expected function, found Act | マクロ定義 |
| E4 引数なし | E0308 expected Act, found enum constructor | マクロ定義 |
| E5 綴り誤り | E0599 | 表の行(Audti の位置) |
| E6 引数が多い | E0061 | マクロ定義 |
| E7 if 式 | 照合で止まる($a:ident に if が合い、次の c で no rules expected) | 表の行 |
E1〜E6 の 6 種のうち 4 種がマクロ定義を指します(E7 を含めた 7 種でも、型検査まで進む 6 種のうち 4 種)。表の行を指したのは、エラーの位置が引数の式(E2)か利用者が書いた識別子そのもの(E5)のときだけでした。「呼び出しの数が合わない」「呼び出せない」という、呼び出しの式全体についてのエラーは、マクロ定義を指しています。
E1 の出力:
help: の提案は、マクロ定義の $( ( $($arg),* ) )? を壊して書き換えるものです。従えばマクロが壊れます。
Part 2 の答え(実測)
正しい表のままでもコンパイルできません。
指しているのは SoundAlarm の直後の , です。照合器は SoundAlarm を $a:ident に入れた後、次の , を「$args:tt(, も1個のトークン木)」と「繰り返しの区切り」のどちらにも読めます。Reference は "When matching, no lookahead is performed" と定めていて、先読みして決めることはしません(一次資料)。tt は何にでも合うので、? や * の直後に区切りや別の規則を置くと曖昧になります。
Part 3 の答え(実測)
版2は、E1〜E6 をすべて表の行に向けます(実測: 6 種すべて 104 行目=表の行)。承認してはいけない理由は2つです。
理由1: 名前の衝突(要件 M-1 で E0659)。 fn のスコープに State・Event・Action の3つのグロブが並ぶので、Ev::Lock と Act::Lock がぶつかります。
2件出ます。パターン (Closed, Lock) が曖昧になる E0659 と、[Lock] が Ev::Lock と解決されて型が合わない E0308 です。Day 066 で $Action::$a と完全修飾にしたのは、まさにこの衝突を避けるためでした。版2はその方針を黙って捨てています。扉では「施錠する」がイベントにもアクションにもなるので、この衝突は仮定の話ではありません。Day 069 の形と Part 5 の形は、同じ表で通ります(実測)。
理由2: アクションの位置に何でも書ける(E7 が黙って通る)。 $a:expr は式なら何でも受けます。if c.is_admin { SoundAlarm } else { NotifyGuard } はコンパイルが通りました(実測)。これはガードを経ずに行の中で分岐できるということで、Day 064 から積み上げた「分岐はガードに、表は宣言だけ」という表の読みやすさを壊します。ROW_ACTIONS もこの行を 1 個と数えるので、件数の検査も黙ります。
Part 4 の答え(実測)
効きません。かえって悪化します。
| # | 単独 import + $a $args | Day 069 |
|---|---|---|
| E1 | E0061、マクロ定義($a の2文字に下線) | マクロ定義 |
| E5 | E0432 unresolved import Act::Audti、マクロ定義の use の行 | E0599、表の行 |
呼び出しの式を作るトークンがすべて表の行から来ていても、マクロの本体で並べて作った式はマクロ定義を指しました。しかも、Day 069 で表の行を指していた E5 まで、use の行(マクロ定義)を指すようになります。丸括弧を tt で受けて $em($Act::$a $args) とした版(tt muncher だけの版)も同じ結果でした(E1・E3・E6 がマクロ定義、実測)。
ここから分かる規則(今日の実測で確かめた範囲): 呼び出しの式全体を、利用者のトークンから $a:expr として構文解析させたときだけ、式の位置が表の行になる。 Reference の「別のマクロに渡した断片は、その種類の不透明な構文木として見える」(一次資料)のとおり、expr 断片は捕まえた時点で式として出来上がっていて、その位置は利用者の行のものです。マクロの本体で組み立てた式は、マクロの展開の中で作られた式です。
Part 5 の答え(実測)
模範解答の形で 7 種を試した結果:
| # | エラー | 指した場所 |
|---|---|---|
| E1 | E0061 | 表の行(Audit に下線、help: が表の行に /* bool */ を足す提案) |
| E2 | E0308 | 表の行 |
| E3 | E0618 | 表の行 |
| E4 | E0308 expected Act, found enum constructor | 表の行 |
| E5 | E0425 cannot find function, tuple struct or tuple variant Audti | 表の行 |
| E6 | E0061 | 表の行(3つ目の引数に下線) |
| E7 | no rules expected c | 表の行(形の検査で止まる) |
Act::SoundAlarm | no rules expected :: | 表の行(完全修飾も形の検査で止まる) |
E1 の出力(help: が、今度は表の行を正しく直す提案になっています):
要件 M-1 の表(motor)も通り、S18 で Act::Lock が出ることを確かめました。
代償の測定(導出と実測): 300 行 × 4 アクション(1,200 個)の合成表で cargo check を 5 回ずつ交互に測ると、Day 069 の形が中央値 207 ms、今日の形が 265 ms(差 58 ms、実測)。ただし測定時の load average は 16.96〜17.39 で、Day 069 の形の 1 回は 866 ms に跳ねています。差は「補助マクロの再帰が 1 行あたり(アクション数 + 1)回 × 2 種」増える分と考えられます(導出、-Z time-passes での分解はしていない)。扉の表(8 行・最大 4 個)では体感できる差ではありません。
Part 6 の答えの例: PR #70 へのレビューコメント
Request changes。目的(エラーを表の行に向ける)には賛成、実装を変えてほしい。
版1 ($args:tt): 正しい表でも local ambiguity。tt が区切りの , にも合い、照合器は先読みしない。
版2 ($a:expr + fn スコープのグロブ): E1〜E6 は表の行を指すようになったが、
(1) Act と Ev に同名(M-1 の Lock)があると E0659。Day 066 の完全修飾の方針を捨てている。
(2) if 式などが行に書けてしまい、分岐がガードの外に漏れる。
単独 import で裸の名前を呼ぶ案: 効かない。E1 はマクロ定義のまま、E5 は E0432 でマクロ定義を指すよう悪化(実測)。
提案: 行は [ $($acts:tt)* ] で受け、__count(ident か ident(..) だけを受ける)で形を検査・計数し、
__emit で同じ列を $a:expr として取り込み直し、グロブはブロックの中だけに置く。7 種すべて表の行を指す(実測)。
代償: 300 行×4 個で cargo check +58 ms(負荷の高い機械での実測)。補助マクロが 2 つ増える。
前提が変わったら: 表が数千行になり check 時間が効くなら、proc-macro(マクロ②)で span を直接付ける形へ移す。
🪜 Step-by-Step 解説
Step 1: 現状を表で押さえる(Part 1)
まず 7 種を全部入れて、どれがどこを指すかを表にしました。「エラーが分かりにくい」という苦情を、何種のうち何種という数に置き換えるためです。6 種中 4 種がマクロ定義を指していて、それは全部「呼び出しの式全体」についてのエラーでした。
Step 2: tt の曖昧さを読む(Part 2)
$( $a:ident $($args:tt)? ),* の $($args:tt)? は「0 個か 1 個のトークン木」です。, もトークン木の1つなので、照合器は「, を $args に入れる」と「, で繰り返しを区切る」の両方に進めます。macro_rules! は先読みしないので、どちらかを選ぶ規則がなく、エラーになります。Day 066 で $to:expr の後ろに / を置けない(follow-set)ことを見ましたが、今日のはfollow-set ではなく照合時の曖昧さで、定義は通り、呼んだときに止まります。
Step 3: 「直った」報告を、別の要件で壊してみる(Part 3)
版2は同僚の報告どおり E1〜E6 を直しました。レビューで見るのは「直ったか」だけでなく「何を代わりに失ったか」です。グロブを fn のスコープに足すと、3 つの enum の名前空間が1つに混ざります。扉の語彙では「施錠」がイベントにもアクションにもなるので、M-1 の 1 行で E0659 が出ました。
Step 4: もっともらしい対案を実測で捨てる(Part 4)
「部品が全部利用者のトークンなら、式も利用者の位置になるはず」は、直感的ですが外れました。マクロの本体で並べた時点で、式はマクロの展開の中で作られたものになります。位置を利用者側に保つには、式の構文解析そのものを expr 断片に任せる必要がありました。
Step 5: 形の検査と式の取り込みを分ける(Part 5)
$a:expr は位置を守るが、形を守りません。$a:ident $( (..) )? は形を守るが、位置を守りません。1 つの照合で両方は取れないので、同じトークン列を2回照合します。
__count!はidentとident(..)だけを受ける。合わなければno rules expectedが表のトークンを指す。ついでに数を返し、ROW_ACTIONSが Day 069 と同じ値になる(S16')__emit!は同じ列を$a:exprで 1 個ずつ受け、emit({ use $Act::*; $a })を生成する。グロブはブロックの中にあるので、外のState/Eventのグロブより内側が優先され、LockはAct::Lockに解決される(S18)
2 つの補助マクロは同じ $($acts)* を受けるので、形の検査が通った列だけが式として取り込まれる、とは限りません——両方が独立に展開されます。ただし __count! は ROW_ACTIONS という定数の中で必ず展開されるので、形が違えばそこでコンパイルが止まり、__emit! の結果は使われません。
🧭 設計思想・なぜこう書くのか
Rust のコンパイラは、エラーの位置(span)をとても大切にしています。借用チェッカーのエラーが「どこで借りて、どこで使ったか」を下線で示すのは、そのためです。マクロも同じで、利用者が直すべき場所を指すエラーを出すことが、マクロの品質の大きな部分です。
macro_rules! には位置を直接操作する手段がありません。使えるのは「どのトークンを、どの断片の種類(ident・expr・tt)で受けるか」だけです。今日の結論は、断片の種類の選び方が、エラーの位置を決めるということです。ident で受けて組み立てると形は守れるが位置はマクロ側、expr で受けると位置は利用者側だが形は守れない。両方欲しければ、2 回照合する。
これは Day 063 の ADR-063 の基準で言えば、マクロを使う理由(③ 字面を使う・④ item を生成する)とは別の、マクロを使うことのコストの話です。fn で書いたものなら、エラーは常に呼び出し側を指します。マクロにした以上、エラーの位置は作る側が設計しないと悪くなります。
迷った他案と採らなかった理由
| 案 | 中身 | 採らなかった理由 |
|---|---|---|
(A) 今日の形: __count! で形を検査、__emit! で expr として取り込む | 2 回照合 | 採用。 7 種すべて表の行(実測)、M-1 も通る |
(B) 版1: $($args:tt)? | 丸括弧を tt で | 正しい表で local ambiguity(実測) |
(C) 版2: $a:expr + fn スコープのグロブ | 1 回照合 | E0659(M-1、実測)と、if 式を受ける(実測) |
| (D) 版2 のグロブだけブロックへ移す | (C) の衝突だけ直す | 衝突は消えるが(実測で M-1 が通る)、if 式は通ったまま(実測)。形の検査が無い |
| (E) 単独 import で裸の名前を呼ぶ | { use $Act::$a; $a $args } | E1 はマクロ定義のまま、E5 は E0432 で悪化(実測) |
| (F) 何もしない(Day 069 のまま) | — | 6 種中 4 種がマクロ定義を指し、help: がマクロを壊す提案をする(実測) |
| (G) proc-macro に移す | syn で表を構文解析し、quote_spanned! で位置を付ける | 位置は自由に付けられるが、別クレートが要り、テーマはマクロ②。表が育ってから判断する |
🚨 コンパイルエラーが出た場合
| エラー | 出た原因 | 読み方 | 正しい直し方 |
|---|---|---|---|
local ambiguity when calling macro ... built-in NTs tt ('args') or 2 other options | ?/* の直後の tt が区切りにも合う | 下線は曖昧になった最初のトークン(,) | tt の繰り返しの後ろに区切りを置かない。列を [ $($acts:tt)* ] で丸ごと受け、補助マクロの腕の順序で切り分ける |
E0659 ... is ambiguous / multiple glob imports of a name in the same module | 同じスコープに同名を持つグロブが2つ | note の2つの import 位置を見る | グロブを内側のブロックに移す(内側が優先される)か、完全修飾にする |
E0061 がマクロ定義を指す | 呼び出しの式がマクロ本体で組み立てられている | help: に従うとマクロが壊れる | 呼び出し全体を $a:expr で受けて生成する |
E0432 unresolved import がマクロ定義の use を指す | 単独 import で variant 名を受けた | 綴り誤りがマクロ側の誤りに見える | 単独 import をやめ、グロブ(ブロック内)+ expr で受ける |
no rules expected c(if の次) | ident はキーワードにも合うので if が $a:ident に入り、次で止まる | 「c が悪い」ではなく「if から始まる式は書けない」と読む | アクションは Variant か Variant(..) で書く |
🌐 他言語との比較
| 観点 | Rust macro_rules!(今日) | Rust proc-macro | C プリプロセッサ | C++ テンプレート |
|---|---|---|---|---|
| エラーの位置 | 断片の種類で決まる(expr で受けた式は利用者の行、組み立てた式はマクロ定義、実測) | Span を明示して付けられる(quote_spanned!) | 展開後の行を指すことが多く、元の行は #line 頼み | インスタンス化の連鎖が長く出る。C++20 の concepts で制約違反として短くなる |
| 形の検査 | 照合の腕で行う(no rules expected が利用者のトークンを指す、実測) | syn の構文解析で任意のエラー文を出せる | 無い | concepts / static_assert |
| 名前の衝突 | ブロック内のグロブは外側より優先(実測) | 生成側が完全修飾を選べる | マクロ名はグローバルで衝突しやすい | 名前空間と ADL |
Rust macro_rules! の列は今日実行した結果です。proc-macro・C・C++ の列は仕様の記憶に基づく要旨で、今日は実行していません。
🏭 実務での使いどころ
- DSL マクロの利用者体験: 状態機械・ルーティング表・設定表などをマクロで書かせるとき、「エラーが表の行を指すか」は採用されるかどうかを分けます。今日の 7 種のような壊し方の一覧を作り、どこを指すかを表にするのは、マクロのレビューでそのまま使える手順です
- コンパイル失敗のテスト: 今日は手で 7 種を入れ替えました。実務では
trybuildで「このファイルはこのエラー文で失敗する」を固定し、エラーの位置の退行をテストで止めます(今日は未導入。Day 056 からの持ち越し) - 名前空間の設計: イベントとアクションで同じ動詞(Lock・Open・Reset)が出るのは、扉・エレベーター・産業機械の制御表ではよくあることです(一般的な観察で、特定の規格は引いていません)。生成コードのグロブの置き場所は、こうした語彙の重なりを前提に決めます
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| 「部品が全部利用者のトークンなら、エラーも利用者の行を指す」 | トークンが位置を覚えていることを知っている | マクロ本体で組み立てた式はマクロ定義を指した(Part 4、実測)。式ごと expr で受ける |
「tt で受ければ何でも安全」 | tt は何にでも合う | 何にでも合うから区切りとぶつかり、local ambiguity になる |
| 「グロブ import は使った名前だけが問題」 | 普段の use の感覚 | 同じスコープの2つのグロブに同名があると、その名前を使った時点で E0659。生成コードでは利用者の語彙次第で起きる |
「ident はキーワードには合わない」 | 識別子とキーワードを区別する | Reference の定義は IDENTIFIER_OR_KEYWORD(_ を除く)。if も $a:ident に入る |
「help: の提案に従えば直る」 | 普段は正しい | マクロ定義を指すときの提案はマクロを壊す(Part 1、実測) |
🔗 一次資料(参照日: 2026-10-10)
- The Rust Reference, Macros By Example(https://doc.rust-lang.org/reference/macros-by-example.html、今日取得)
r-macro.decl.transcription.lookahead: "When matching, no lookahead is performed" — Part 2 の local ambiguity の根拠r-macro.decl.transcription.fragment: 別のマクロに渡した断片は "an opaque AST of the fragment type" として見え、ident・lifetime・ttだけはリテラルのトークンで照合できる — Part 4・5 で$actsをttのまま渡し、__emit!の中で初めてexprとして構文解析させた理由r-macro.decl.meta.specifier:identはIDENTIFIER_OR_KEYWORDから_を除いたもの — E7 でifが$a:identに合った理由
- エラー文はすべて rustc 1.96.0 で今日実行した出力。
rustc --explain E0061・E0659の説明文は読んでいない - 「
expr断片の位置が利用者の行になり、組み立てた式はマクロ定義になる」ことのコンパイラ内部での理由(span の選び方)は、rustc のソースを読んでいないので実測の規則としてだけ書いている
🚧 今日扱わなかった範囲(次回以降)
| 扱わなかったこと | 理由・次の予定 |
|---|---|
E0080(max 超え)の下線を表の行に向けること | const の panic! に位置を渡す手段が macro_rules! に無い。行ごとの const _ を生成する形で試せるかは未実施。proc-macro(マクロ②)の題材 |
trybuild で 7 種のエラーの位置を固定するテスト | 依存がローカルのレジストリに無い。Day 056 からの持ち越し |
ガード(if $g:expr)・遷移先($to:ident)の誤りの位置 | 今日はアクションの列に限った。$to に存在しない状態を書いたときの位置は未確認 |
コンパイル時間の静かな機械での取り直し・-Z time-passes での分解 | M1 8GB で load average 17 前後のときの 5 回だけ。Mac mini での取り直しは Day 066・068 の宿題と一緒に |
| rustc が式の位置を選ぶ仕組み(ソース) | 規則は実測のみ |
| proc-macro・C・C++ の比較列の実行 | 記憶ベース |
Day 069 の宿題(concat! で E0080 に説明を足す、静かな機械での計測、実在クレートの照合) | 今日はエラーの位置に集中 |
3 問の自己点検
- 同じ指示を AI に出せば数時間で出るか — 「マクロのエラーを分かりやすくする」一般論は出る。ただし、Day 069 の形で 6 種中 4 種がマクロ定義を指すこと、
$args:ttが正しい表で local ambiguity になること、$a:expr+ グロブが M-1 で E0659 と E0308 の 2 件を出すこと、単独 import 案が E5 を E0432 に悪化させること、2 回照合の形で 7 種+完全修飾がすべて表の行を指すこと、300 行で check +58 ms は、ビルドして得た結果 - AI に指示するだけでは出ない部分 — 「直った」という版2の報告を、扉の語彙(施錠がイベントにもアクションにもなる)で壊して見せたこと。もっともらしい対案(単独 import)を実測で捨て、「式の構文解析を
expr断片に任せたときだけ位置が利用者側になる」という規則を 4 つの実装の比較から取り出したこと - 世界の実物と比べて何割か — Reference の macros-by-example のうち、使ったのは照合の先読み・断片の転送・
identの定義の 3 節。衛生性(hygiene)の節と、rustc の span の扱いは読んでいない。実在の DSL マクロ(rust-fsm のstate_machine!、bitflags)がエラーの位置をどう扱っているかは照合していない
➡️ 次のステップ
- 発展: 今日の 7 種を
trybuildのtests/ui/*.rsと*.stderrにして、エラーの位置の退行をテストで止める。$to:identに存在しない状態(=> Alrm)を書いたときにどこを指すかも同じ表に足す - 次回予告: マクロ① の残り期間(〜10/24)。今日のデバッグの次は概念理解か設計。候補は (1) 入れ子の状態(
Alarmの中に鳴動中/消音中)を自作fsm!と statig の superstate で比べる設計、(2) const 評価でできること・できないこと(E0015、for不可、generic_const_exprs)をROW_ACTIONSを題材に整理する概念理解