声明宏:macro_rules!

11 分钟
本页目录

声明宏适合把重复的语法结构变成一组模式,例如生成若干实现、接受数量可变的表达式,或提供简短的领域语法。若只是对已经确定类型的值执行算法,普通函数与泛型通常更容易理解、调试和检查。

宏在编译时展开,但生成的代码仍可能在运行时分配、循环和产生副作用;const fn 也可能在编译期求值,不能只用“编译期对运行期”区分宏和函数。

调用中的 token trees括号分组与语法片段,不是任意字符串替换1按规则顺序匹配选择首个匹配成功的规则,执行重复和替换2展开到调用位置形成表达式、项、模式等合法语法3名称、类型与借用检查宏生成的代码仍需符合普通 Rust 规则4宏展开不会免除所有权规则,也不自动保证参数只求值一次。声明宏:先匹配语法,再检查展开后的代码

一个小宏先说明求值次数

macro_rules! list {
    () => { ::std::vec::Vec::new() };
    ($($value:expr),+ $(,)?) => {{
        let mut output = ::std::vec::Vec::new();
        $(output.push($value);)*
        output
    }};
}

fn main() {
    let mut n = 0;
    let values = list![{ n += 1; n }, { n += 1; n },];
    assert_eq!(values, [1, 2]);
    assert_eq!(n, 2);
    let empty: Vec<u8> = list![];
    assert!(empty.is_empty());
}

重复部分让每个表达式在生成的 push 中出现一次,所以每项求值一次,并按书写顺序执行。若把 $value 写到两处,可能重复副作用或触发移动后使用错误;宏系统不会替你缓存结果。

双层花括号中的内部块让整个展开是一个表达式,并把临时变量限制在块内。这里使用 ::std 路径,明确示例依赖 std;no_std 库导出的宏需要适当处理 alloc 路径,不能直接照搬。

展开一次,表达式却可能执行两次

把有副作用的表达式作为宏参数,最容易看到展开与执行的区别。这里 next() 是普通运行时操作,宏只决定它在输出语法里出现几次。先绑定可以控制求值次数,但局部值后续能否被重复使用,还取决于它的 Copy、借用和移动规则。

正在呈现知识画面
展开一次,表达式却可能执行两次

重复插入 $e 会重复运行表达式;先 let value=$e 再使用 value 才会只求值一次。

fragment 是语法类别,不是 Rust 类型

fragment匹配内容容易混淆的边界
expr表达式不知道其推导类型是 u32 还是 String
ty类型语法不会自动展开别名或判断 trait 实现
ident标识符形状不是任意多段路径
path路径可用于限定名等相应语法
pat / pat_param模式顶层或模式规则与 edition 有关
tt一个 token tree一个 token 或一对配平分隔符包裹的树,不是任意长输入
item项函数、结构体、impl 等
meta / vis属性内容 / 可见性vis 可匹配空可见性

需要任意数量的 token tree 通常写 tokens:tt)*。将 expr 等已解析片段转发给另一宏时,它通常是相应类别的不透明片段,不能再按任意字面 token 拆开匹配;ident、lifetime、tt 有特定例外。宏匹配规则

expr 与 tt 转发后,下一层看见的东西不同

macro_rules! inspect {
    (3) => { "literal token" };
    ($value:expr) => { "expression fragment" };
}
macro_rules! pass_expr { ($value:expr) => { inspect!($value) }; }
macro_rules! pass_tt { ($value:tt) => { inspect!($value) }; }
fn main() {
    assert_eq!(inspect!(3), "literal token");
    assert_eq!(pass_tt!(3), "literal token");
    assert_eq!(pass_expr!(3), "expression fragment");
}

pass_expr 已经将 3 解析为表达式片段,后续 inspect 不能把这个不透明片段重新按字面 3 匹配,因此走 expr 规则。tt 保留可匹配的 token tree 形状。若接下来需要做 token 级语法分析,就在接口中保留相应 token;若只是生成使用表达式的代码,expr 的语法约束更清楚。

注意 pass_tt!(1 + 2) 不是“表达式也算一个 token tree”,它包含多个未分组 token,无法匹配单个 $value:tt;可以显式分组,或按需求接收重复 token。选择 fragment 会影响后续能做什么,并非随便选一个能吞掉输入的占位符。

重复、规则顺序与歧义

()+ 是至少一次,$()? 是可选一次;逗号分隔与可选尾逗号通常组合为 x:expr),* $(,)?。重复输出的嵌套结构必须与捕获相匹配,多个变量同时重复时数量也必须对应。

规则按定义顺序尝试,首个匹配后若展开代码失败,不会自动回头试另一个规则。应把更具体的规则放在能吞掉它们的宽泛规则前面。

匹配不是一个任意回溯的通用解析器。例如重复 ident 后紧跟另一个 ident,可能在当前 token 无法判断它属于哪一部分,从而出现局部歧义。使用分隔符、显式关键字或分组让边界清楚,比不断增加递归更稳妥。

让成对数据在同一重复层里被捕获

macro_rules! pairs {
    ($($key:expr => $value:expr),* $(,)?) => {{
        let mut output = ::std::vec::Vec::new();
        $(output.push(($key, $value));)*
        output
    }};
}
fn main() {
    let mut calls = 0;
    let entries = pairs!(
        "first" => { calls += 1; calls },
        "second" => { calls += 1; calls },
    );
    assert_eq!(entries, [("first", 1), ("second", 2)]);
    assert_eq!(calls, 2);
}

每个重复单元同时捕获 key 和 value,因此一对数据天然对应。若设计成两组独立重复,再在输出中把它们放进同一重复层,就必须保证两组捕获数量一致;宏展开不会替调用者补齐缺失项或自动做笛卡尔积。

这个 pairs 只生成 Vec,不检查键是否重复,也不创建 Map。语法糖应准确命名它提供的能力;如果需求包含重复键策略、类型转换或资源上限,这些仍需要生成对应代码或交给普通函数完成。

分隔符也是语法长期兼容性的边界

宏 fragment 后面允许出现的 token 受到 follow-set 规则限制。例如 expr 后接 =>、, 或 ; 是常见合法边界,不能任意发明一个紧跟表达式的符号序列,再期待解析器永远区分清楚。显式分组和关键字还能让错误位置更接近用户输入,而不是在多层递归后才报无匹配。

一个边界明确的递归 token 处理例子

下面把 text 表达式按次序写入 String,用分号界定一条指令。它是小型 TT muncher,不冒充完整 HTML/XML 解析器。

macro_rules! text_script {
    (@step $out:ident;) => {};
    (@step $out:ident; text $value:expr; $($rest:tt)*) => {
        {
            use ::std::fmt::Write as _;
            ::std::write!(&mut $out, "{}", $value).expect("write String");
        }
        text_script!(@step $out; $($rest)*);
    };
    (@step $out:ident; $($invalid:tt)+) => {
        compile_error!("expected: text <expression>;");
    };
    () => { ::std::string::String::new() };
    ($($input:tt)+) => {{
        let mut output = ::std::string::String::new();
        text_script!(@step output; $($input)*);
        output
    }};
}

fn main() {
    assert_eq!(text_script!(text "value="; text 42;), "value=42");
    assert_eq!(text_script!(), "");
}

每一步消费一条 text 指令,把剩余 token 转发;终止规则必须存在。专门的 @step 错误规则放在宽泛入口前,避免非法输入反复进入入口后只报递归深度错误。长输入反复匹配整个尾部还可能导致较高、甚至二次的宏展开成本。

<$tag:ident> ... </$tag:ident> 不是有效的“匹配同名标签”技巧:在同一 matcher 重复绑定同名变量会出错,而无限制 children 重复遇到关闭标签又容易歧义。复杂嵌套语法可改成显式分组,或使用过程宏真正解析。

卫生性与跨 crate 引用

macro_rules 具有混合位置卫生性:宏内部创建的局部变量等不会简单按文本与调用者变量混淆,但项路径等名称解析仍需按规则理解。不能只说它“完全卫生,所以所有名字问题都消失”。

导出宏内部引用定义 crate 的辅助项时,使用 crate 不绕过可见性规则,外部展开需要访问的辅助项仍须具有合适可见性。

普通 macro_rules 不提供任意稳定的字符串拼接生成标识符能力。接受调用方提供的 ident、引用固定名字,与从两个 token 拼出一个新名字是不同需求;后者可能需要过程宏或专门支持。

宏定义中的 value宏内部局部绑定调用传入的 $input仍在调用者语境中解析生成结果两种同名绑定不互相误捕获卫生性处理名字,不处理重复求值或业务副作用输入表达式写几次、什么时候执行,仍由展开代码决定局部临时名与调用者输入,保留不同的名称来源macro_rules 是混合位置卫生性;项路径和跨 crate 辅助函数仍需明确引用。

同名局部变量不会简单按文本替换

macro_rules! offset {
    ($input:expr) => {{
        let value = 10;
        $input + value
    }};
}
fn main() {
    let value = 7;
    assert_eq!(offset!(value), 17);
    assert_eq!(value, 7);
}

传入的 value 仍指向调用处的 7,宏引入的临时 value 为 10。这不意味着宏可以读取调用方任何同名变量;若希望生成一个调用者后续使用的绑定,应该接收相应 ident 并明确规定展开位置与作用域。

导出宏中的辅助项要从定义 crate 定位

#[doc(hidden)]
pub fn __decorate(text: &str) -> String { format!("[{text}]") }
#[macro_export]
macro_rules! decorated {
    ($text:expr) => { $crate::__decorate($text) };
}
fn main() {
    assert_eq!(decorated!("ready"), "[ready]");
}

$crate 指向定义宏的 crate,即使使用方重命名依赖也能定位;它不会把私有函数变成公共函数。这里辅助函数必须对外可见,doc(hidden) 只隐藏生成文档中的常规展示,不是访问权限。公共宏生成代码所需的辅助接口同样有兼容性责任,不能随意移除。

edition 与检查策略

宏定义所在 edition 会影响 expr、pat 等片段的匹配范围。Rust 2024 的 expr 扩展了可匹配的表达式形状,expr_2021 可保留相应旧规则;升级 edition 时,要检查原先多个分支的选择是否变化。

验证宏除了成功例子,还应覆盖空输入、尾分隔符、有副作用的参数、无效语法以及跨模块/跨 crate 使用。展开输出适合理解结构,但最终仍以调用代码的编译行为为准。

2024 的 expr 会改变规则选择

以下完整例子按 edition 2024 编译:

macro_rules! broad_first {
    ($value:expr) => { "expression" };
    (const $value:expr) => { "special const" };
}
macro_rules! legacy_first {
    ($value:expr_2021) => { "expression" };
    (const $value:expr) => { "special const" };
}
fn main() {
    assert_eq!(broad_first!(const { 1 }), "expression");
    assert_eq!(legacy_first!(const { 1 }), "special const");
}

2024 的 expr 能接收顶层 const 块,因此 broad_first 的第一条已经匹配;expr_2021 保留旧片段范围,使第二条 const 规则仍有机会被选中。迁移时应决定要保留旧行为,还是调整规则顺序并接受新语法,而不是只看最终是否还能编译。决定匹配范围的是宏定义处的 edition,不是宏调用方的 edition。2024 fragment 迁移

宏的失败案例也属于接口说明:记录非法输入期望触发哪个自定义错误,确认重复次数不匹配时能拒绝,并在外部 crate 中测试导出宏。只有同一个源文件中的成功调用,无法覆盖依赖重命名、可见性和路径卫生性。