练习与自测
本章练习共 11 题,答案折叠在每题下方。建议先自己写、编译通过后再展开答案;难度标记:★☆☆ 基础 / ★★☆ 综合 / ★★★ 挑战。
练习 1:实现 my_vec! 的三种形态
难度:★☆☆
要求:实现 my_vec! 宏,支持三种调用形态: my_vec、my_vec、my_vec![a, b, c] 与 my_vec。 必须返回真正的 Vec<T>,不能用 vec! 宏来实现(避免循环依赖)。 写出完整的 fn main 与断言。
提示:列表形态用 FromIterator;重复形态用 std::vec::from_elem。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
macro_rules! my_vec {
// 形态 1:空
() => { ::std::vec::Vec::new() };
// 形态 2:重复填充。用 `;` 与列表形态区分,不会歧义
($elem:expr; $n:expr) => {
::std::vec::from_elem($elem, $n)
};
// 形态 3:列表(+ 可选尾逗号)。用 FromIterator 避免依赖 vec! 宏
($($x:expr),+ $(,)?) => {
<::std::vec::Vec<_> as ::std::iter::FromIterator<_>>::from_iter([$($x),+])
};
}
fn main() {
let empty: Vec<i32> = my_vec![];
let repeated = my_vec![7u8; 3];
let listed = my_vec![1, 2, 3];
let trailing = my_vec![1, 2, 3,];
assert!(empty.is_empty());
assert_eq!(repeated, vec![7, 7, 7]);
assert_eq!(listed, vec![1, 2, 3]);
assert_eq!(trailing, vec![1, 2, 3]);
println!("{empty:?} {repeated:?} {listed:?} {trailing:?}");
// 输出:[] [7, 7, 7] [1, 2, 3] [1, 2, 3]
}要点解析:
::std::vec::Vec::new()/from_elem用全路径,因为调用者可能定义了自己的std模块。- 形态 2 与形态 3 的分隔符不同(
;vs,),所以臂的顺序不影响正确性; 但()空臂建议放最前,读起来最清晰。 $(,)?必须显式写,expr片段不会自己吃掉尾逗号(实测: 不写时报unexpected end of macro invocation+while trying to match meta-variable $x:expr)。- 用
FromIterator而不是vec![$($x),+]:后者是「宏调宏」, 在某些 no_std / 重导出场景下更容易出问题,而且可读性更差。
练习 3:批量实现 trait 的声明宏
难度:★★☆
要求:写 impl_greet_for! 宏,为多个具名结构体批量实现同一个 trait。 给出 trait 定义、宏定义、3 个结构体的调用、以及 main 里的使用。 要求宏能同时接受 1 个、2 个、5 个类型名(即用 $(...)+ 而不是逐个枚举)。
提示:宏内部对每个类型名生成一个独立的 impl 块,用 $($T)+ 重复。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
// 目标 trait
pub trait Greet {
fn greet(&self) -> String;
}
// 宏观:为每个列出的类型名生成一个独立的 impl
macro_rules! impl_greet_for {
($($T:ty),+ $(,)?) => {
$( impl Greet for $T {
fn greet(&self) -> String {
format!("你好,我是 {}", std::any::type_name::<Self>())
}
} )+
};
}
struct User { name: String }
struct Admin { level: u8 }
struct Guest;
impl_greet_for!(User, Admin, Guest);
// 也支持一次只给一个、或者给五个:
// impl_greet_for!(User);
// impl_greet_for!(A1, A2, A3, A4, A5);
fn main() {
let u = User { name: "小明".into() };
let a = Admin { level: 3 };
println!("{}", u.greet()); // 输出:你好,我是 play::User
println!("{}", a.greet()); // 输出:你好,我是 play::Admin
println!("{}", Guest.greet()); // 输出:你好,我是 play::Guest
}要点解析:
$T:ty(类型)而不是$T:ident(标识符):这样连Vec<u8>、(i32, i32)这类 类型也能用。只有需要「当名字用」时才用ident。$( impl ... )+是「为每个类型重复生成一个impl块」。 不能写成一个impl里多个for——Rust 没有这种语法。- 这就是标准库为元组批量实现 trait 的同款手法。 一旦需要「按类型不同生成不同代码」,
macro_rules!就无能为力了(不能内省类型), 必须换过程宏。
练习 4:处理尾逗号与重复片段
难度:★★☆
要求:写一个带尾逗号支持的宏 call_all!,形态为 call_all!(f; a, b, c,), 展开为依次调用 f(a)、f(b)、f(c) 并把结果丢弃。 要求:call_all!(f; a)(单个)与 call_all!(f;)(零个参数)都必须能编译。
提示:注意「0 个」和「带尾逗号的 0 个」是两种输入;分隔符 ; 是固定 token。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
macro_rules! call_all {
// 0 个参数
($f:expr;) => {{
let _ = &$f; // 即使不调用,也确保 f 被使用,避免 unused 警告
}};
// 1 个或多个参数(+ 可选尾逗号)
($f:expr; $($arg:expr),+ $(,)?) => {{
$( let _ = $f($arg); )+
}};
}
fn double(n: i32) -> i32 { n * 2 }
fn main() {
call_all!(double; 1, 2, 3);
call_all!(double; 1);
call_all!(double; 1, 2, 3,);
call_all!(double;);
println!("ok"); // 输出:ok
}要点解析:
- 三条不同的输入(
f;/f; a/f; a, b,)必须被分开的臂处理, 因为$(...)+要求至少一个元素,$(...)*又会在尾逗号上失败。 ($f:expr; $($arg:expr),+ $(,)?)里的$(,)?不能省, 这是「带尾逗号」与「不带尾逗号」都能编译的关键。call_all!(double;)这个臂里写let _ = &$f;, 是因为没有调用$f时编译器会报unused variable; 用&取引用而不是let _ = $f;,避免把函数项当成值移动(对FnOnce闭包更友好)。- 如果想让所有
call_all!调用共用一条臂,就需要用ttmuncher 逐个吃参数, 但那样会让「0 个参数」与「尾逗号」的处理复杂化,得不偿失。
练习 5:用 tt muncher 反转列表
难度:★★☆
要求:写一个 tt muncher 宏 reverse_list!,把 reverse_list!(1, 2, 3, 4) 展开成 [4, 3, 2, 1](一个固定长度数组字面量的表达式)。 必须真的用「逐个吃 token」的方式实现,不能用 [..].reverse()。 说明为什么必须写基础臂。
提示:在递归里「先递归、后放元素」自然就反序了;基础臂是空输入。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
// 可用的(但会把结果退化成 Vec,因为长度算不出来)
fn reverse_via_vec<T: Copy>(xs: &[T]) -> Vec<T> {
xs.iter().rev().copied().collect()
}
fn main() {
assert_eq!(reverse_via_vec(&[1, 2, 3, 4]), vec![4, 3, 2, 1]);
}为什么不能「先递归、再把 $head 追加到末尾」:Rust 数组的长度是类型的一部分, [T; N] 与 [T; N+1] 是两个不同的类型。宏可以拼出新的数组字面量, 但没办法把「长度加一」这件事表达在类型里——macro_rules! 没有类型层的运算。 所以「先递归、再追加」在纯数组上写不出来(退化成 Vec 就不算数组了)。
正确的做法是把累加器作为参数一起递归——把每个新元素放到累加器的前面, 「先来的反而排在后面」,天然实现反序,而且长度由数组字面量自己决定。 待处理部分用 tt 逐个吃(顺便天然兼容尾逗号):
rust
macro_rules! reverse_list {
// 入口 A:空输入(必须单独一条!否则展开侧那个逗号会多出来)
() => { [] };
// 入口 B:非空输入,把元素倒进累加器,并吃掉可选的尾逗号
($($x:expr),+ $(,)?) => {{ reverse_list!(@acc []; $($x),+) }};
// 基础臂:待处理部分为空 → 累加器就是结果(累加器用 * 而非 +,可能为空)
(@acc [$($done:expr),*]) => { [$($done),*] };
// 递归臂(最后一个元素):允许尾逗号
(@acc [$($done:expr),*]; $next:expr $(,)?) => { [$next $(, $done)*] };
// 递归臂(还有剩余):用 tt 逐个吃,剩下的原样交给下一轮
(@acc [$($done:expr),*]; $next:expr, $($rest:tt)*) => {
reverse_list!(@acc [$next $(, $done)*]; $($rest)*)
};
}
fn main() {
let r = reverse_list!(1, 2, 3, 4);
println!("{r:?}"); // 输出:[4, 3, 2, 1]
assert_eq!(r, [4, 3, 2, 1]);
assert_eq!(reverse_list!(9), [9]);
assert_eq!(reverse_list!(1, 2, 3,), [3, 2, 1]); // 尾逗号也支持
// 空输入走入口 A;数组字面量要靠类型标注才能定下元素类型
let empty: [i32; 0] = reverse_list!();
assert!(empty.is_empty());
}要点解析:
- 为什么必须写基础臂:递归臂每轮都在消耗 token;当待处理元素耗尽时, 如果没有
(@acc [$($done),*])这条臂,就会出现unexpected end of macro invocation(note: while trying to match ...)。 宏展开没有「运行期判断」,终止只能靠模式匹配不到任何递归臂。 - 基础臂里的
$($done:expr),*必须用*:最后一轮展开时待处理部分为空, 但累加器里有全部元素,所以基础臂要能匹配「累加器任意长度(含 0)」。 - 入口要分成两条臂(
()与($($x:expr),+ $(,)?)):这是一个非常容易踩的细节。 如果只写($($x:expr),* $(,)?) => {{ reverse_list!(@acc []; $($x),*) }}, 那么reverse_list!()会匹配它(*允许零个元素),但展开器里的,还是会照样生成,得到reverse_list!(@acc []; )——多出一个逗号, 于是报unexpected end of macro invocation。 经验:$(...)*的展开侧如果要写分隔符,就必须为「零个」单独准备一条臂。 - 空数组要写类型标注:
let empty: [i32; 0] = reverse_list!();。 裸写reverse_list!() == []会因为没有任何元素可以推断类型而报error[E0282]: type annotations needed。 - 递归臂必须用
tt而不是expr列表:如果写成(@acc [...]; $next:expr $(, $rest:expr)*),最后一步会展开成reverse_list!(@acc [4, 3, 2, 1]; )——末尾多一个逗号,而$($rest:expr)*后面没有$(,)?去兜它,同样报unexpected end of macro invocation。 用tt逐个吃是最省心的写法。 @acc内部标记让「累加器」和「待处理元素」在模式里清楚分开, 也避免用户不小心写出这种调用(@不是合法表达式的开头)。- 兜底臂(公开入口)放在最前也能工作,因为它要求第一个 token 能开始一个
expr, 而@不能。但如果兜底臂写成($($rest:tt)*)(tt能匹配@),就必须放最后, 否则会像「recursion limit reached」那条那样不收敛。稳妥做法永远是:具体臂在前,兜底臂在最后。 - 局部宏的递归用裸名:这里没写
#[macro_export],所以写$crate::reverse_list!会直接报error[E0433]: cannot find 'reverse_list' in '$crate'。
练习 6:宏参数只求值一次
难度:★★☆
要求:写一个 my_assert_eq! 宏,失败时的 panic 信息必须包含 两个操作数的源码文本(用 stringify!)与它们的 Debug 值。 额外要求:参数表达式只能求值一次(即使它有副作用)。 给出一个失败用例并写出它实际打印的内容。
提示:先 let l = &$left;,再比较 *l == *r。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
macro_rules! my_assert_eq {
($left:expr, $right:expr $(,)?) => {{
// 取引用:既避免移动,也保证每个表达式只求值一次
let l = &$left;
let r = &$right;
if !(*l == *r) {
::std::panic!(
"assertion `left == right` failed\n left: {} = {:?}\n right: {} = {:?}",
::std::stringify!($left), l,
::std::stringify!($right), r
);
}
}};
}
fn double(n: i32) -> i32 { n * 2 }
fn main() {
my_assert_eq!(double(2), 4);
// 下面这行会 panic,输出见注释
my_assert_eq!(double(3), 5);
// thread 'main' panicked at ...:
// assertion `left == right` failed
// left: double(3) = 6
// right: 5 = 5
}要点解析:
let l = &$left;:这是全章最重要的技巧之一。 直接写if $left == $right会让每个表达式各出现一次(这里还好), 但在「失败时再打印$left」的写法里,$left会出现两次—— 如果它是foo()这样带副作用的调用,就会执行两次; 如果是String,第二次使用还会触发use of moved value。stringify!保留的是宏调用处的 token 原文:double(3)打印出来就是"double(3)", 不是"6"。这正是标准库assert_eq!的行为。- 双花括号保证整个宏是一个表达式块,可以直接放在
if条件、match臂等位置。 - 想再加「自定义消息」形态,就再写一条
($l:expr, $r:expr, $($msg:tt)+)的臂; 注意$($msg:tt)+用tt而不是expr,这样才能接受"a {}", x这种 「格式串 + 若干参数」的不定长形式。
练习 7:编写 derive 过程宏
难度:★★★
要求:编写完整的 #[derive(DisplayName)] 过程宏项目,要求:
- 给出完整目录树、两个
Cargo.toml、lib.rs、main.rs。 - 为具名结构体生成
fn display_name(&self) -> String, 输出形如Config(host="localhost", port=8080):字段名用stringify!,值用{:?}。 - 对元组结构体、枚举、union 给出可读的编译期错误(用
syn::Error::new_spanned)。 - 说明为什么 derive 宏不能修改原类型。
提示:syn::DeriveInput → Data::Struct → Fields::Named; 用 quote! 里的 #(#fields)* 重复展开多个字段。
参考答案(先自己写再看)
参考答案(先自己写再看)
目录树:
display-demo/
├── Cargo.toml
├── src/
│ └── main.rs
└── display-derive/
├── Cargo.toml
└── src/
└── lib.rsCargo.toml:
toml
[package]
name = "display-demo"
version = "0.1.0"
edition = "2024"
[dependencies]
display-derive = { path = "display-derive" }display-derive/Cargo.toml:
toml
[package]
name = "display-derive"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full", "extra-traits"] }display-derive/src/lib.rs:
rust
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields};
/// 为具名结构体派生 `DisplayName`。
#[proc_macro_derive(DisplayName)]
pub fn derive_display_name(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
// 只支持具名结构体:其余情况给出指向用户代码的编译错误
let fields = match &input.data {
Data::Struct(data) => match &data.fields {
Fields::Named(named) => named.named.iter().collect::<Vec<_>>(),
Fields::Unnamed(_) => {
return syn::Error::new_spanned(
name,
"DisplayName 不支持元组结构体,请改用带字段名的 struct",
)
.to_compile_error()
.into();
}
Fields::Unit => {
return syn::Error::new_spanned(
name,
"DisplayName 不支持无字段结构体(单元结构体)",
)
.to_compile_error()
.into();
}
},
Data::Enum(_) | Data::Union(_) => {
return syn::Error::new_spanned(
name,
"DisplayName 只能用于 struct,不能用于 enum 或 union",
)
.to_compile_error()
.into();
}
};
// 为每个字段取一次引用,再用 {:?} 打印;字段名用 stringify! 保留原文
let field_idents = fields.iter().map(|f| f.ident.as_ref().expect("具名字段"));
let field_pairs = fields.iter().map(|f| {
let id = f.ident.as_ref().expect("具名字段");
quote! { format!("{}={:?}", stringify!(#id), #id) }
});
let type_name = name.to_string();
let expanded = quote! {
impl DisplayName for #name {
fn display_name(&self) -> String {
#( let #field_idents = &self.#field_idents; )*
let parts: Vec<String> = vec![ #( #field_pairs ),* ];
format!("{}({})", #type_name, parts.join(", "))
}
}
};
expanded.into()
}src/main.rs:
rust
use display_derive::DisplayName;
/// 这个 trait 由派生宏自动实现,必须与宏生成的 `impl` 处在同一作用域可见
pub trait DisplayName {
fn display_name(&self) -> String;
}
#[derive(DisplayName)]
struct Config {
host: String,
port: u16,
}
#[derive(DisplayName)]
struct Empty {}
fn main() {
let c = Config { host: "localhost".into(), port: 8080 };
println!("{}", c.display_name());
// 输出:Config(host="localhost", port=8080)
println!("{}", Empty {}.display_name());
// 输出:Empty()
}触发错误形态时的实际编译错误(struct P(u8); 加 #[derive(DisplayName)]):
error: DisplayName 不支持元组结构体,请改用带字段名的 struct
--> src\main.rs:10:10
|
10 | #[derive(DisplayName)]
| ^^^^^^^^^^^要点解析:
- derive 只能附加代码:宏拿到的是结构体的 AST,输出只能是新的
impl块。 它不能给结构体加字段、改名字、删方法——那需要属性宏(它能替换整个 item)。 这是派生宏与属性宏最本质的分界。 syn::Error::new_spanned(name, msg)把错误「挂」在结构体名这个 token 上, 所以报错准确指向用户写的#[derive(DisplayName)]行,而不是宏内部的某一行。 这是过程宏相对声明宏最大的体验优势。stringify!(#id)在生成代码里对每个字段求值一次,得到字段名文本; 与手写"host"相比,改名时不会漏改。parse_macro_input!已经处理了「解析失败 → 自动生成compile_error!」, 所以不需要自己写 match 处理syn::Error。- 如果字段上还想支持自定义属性(如
#[display_name(skip)]), 就要用syn的Attribute::parse_nested_meta或darling来解析, 代码量会明显上升——这也说明「什么时候该引入darling」。
练习 8:编写属性宏 #[timed]
难度:★★★
要求:编写 #[timed] 属性宏:
- 打印函数名(用
stringify!)与执行耗时到 stderr。 - 保留原函数的可见性、其它属性(如
#[inline])、参数与返回类型。 - 说明为什么它不能正确处理
async fn,以及正确的做法是什么。
提示:syn::ItemFn 的 attrs / vis / sig / block 四个字段都要取出来用。
参考答案(先自己写再看)
参考答案(先自己写再看)
timed-derive/Cargo.toml:
toml
[package]
name = "timed-derive"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }timed-derive/src/lib.rs:
rust
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};
/// 把函数体包进计时器,退出时把「函数名 + 耗时」打到 stderr。
#[proc_macro_attribute]
pub fn timed(_attr: TokenStream, item: TokenStream) -> TokenStream {
let func = parse_macro_input!(item as ItemFn);
let attrs = &func.attrs; // 保留 #[inline] / #[doc] 等其它属性
let vis = &func.vis; // 保留 pub
let sig = &func.sig; // 保留签名(参数、返回类型、泛型)
let block = &func.block; // 原函数体(已含花括号)
let name = &func.sig.ident;
let expanded = quote! {
#(#attrs)*
#vis #sig {
let __dsh_timed_start = ::std::time::Instant::now();
let __dsh_timed_result = { #block };
::std::eprintln!(
"[timed] {} took {:?}",
::std::stringify!(#name),
__dsh_timed_start.elapsed()
);
__dsh_timed_result
}
};
expanded.into()
}使用侧 src/main.rs:
rust
use timed_derive::timed;
#[timed]
#[inline]
pub fn slow(n: u64) -> u64 {
let mut acc = 0u64;
for i in 0..n {
acc = acc.wrapping_add(i);
}
acc
}
fn main() {
println!("{}", slow(1000));
// stdout:499500
// stderr:[timed] slow took 3.5µs
}为什么 async fn 会算错:#sig 里带着 async,宏生成的是
rust
async fn f() -> T {
let start = Instant::now(); // ← 在「构造 Future」时就执行了
let result = { /* 原函数体,可能含 .await */ };
eprintln!("{:?}", start.elapsed()); // ← 取决于 await 是否在此前完成
result
}async fn 的函数体在被轮询(poll)时才执行,而 Instant::now() 就在函数体里, 所以它其实是在第一次 poll 时执行的——但如果原函数体里有 .await, 「耗时」就把等待时间也算进去了,而不是纯计算时间。 更关键的是:如果你把这个 async fn 生成的 Future 存起来延后 poll, 计时的起点就漂移了。
正确做法是把计时代码放进 async move 内部、并只包住真正要测的段:
rust
// 思路示意:`#[timed]` 用在 async fn 上时,应该生成这样的代码。
// 完整项目结构见练习 8,这里只是宏产物的形状(不是独立可编译的程序)。
let __start = Instant::now();
let __result = async move { /* 原函数体 */ }.await;
eprintln!("[timed] took {:?}", __start.elapsed());
__result要点解析:
- 属性宏的四个字段(
attrs/vis/sig/block)必须从 AST 里取出来再放回去, 漏掉#vis会让pub fn静默变成私有函数。 #sig已经包含函数名,不要再写一遍fn #name(...),否则重复定义。#block已含{},所以写{ #block }会多一层块(能编译,但触发unused_braces警告);这里为了让「先计时、再取结果」的语义清晰而接受它。- 变量名加
__dsh_前缀,是为了在被包裹的函数体里不撞上同名局部变量 (过程宏生成的标识符默认是call_site卫生的,不像macro_rules!那样自动卫生)。
练习 9:分析宏展开失败的原因
难度:★★☆
要求:下面这段代码编译失败。请指出失败的原因、报错信息、 以及至少两种修法,并说明哪一种更好。
rust
macro_rules! sum_internal {
($($rest:tt)*) => { sum_internal!(@acc 0; $($rest)*) };
(@acc $acc:expr;) => { $acc };
(@acc $acc:expr; $next:expr, $($rest:tt)*) => {
sum_internal!(@acc $acc + $next; $($rest)*)
};
(@acc $acc:expr; $next:expr) => { $acc + $next };
}
fn main() {
println!("{}", sum_internal!(1, 2, 3));
}提示:关注臂的顺序,以及兜底臂能匹配什么。
参考答案(先自己写再看)
参考答案(先自己写再看)
失败原因:兜底臂 ($($rest:tt)*) 被写在了第一条。 tt 能匹配任何 token 树,包括内部标记 @。于是展开过程是:
sum_internal!(1, 2, 3)
→ sum_internal!(@acc 0; 1, 2, 3) // 命中兜底臂(第一臂)
→ sum_internal!(@acc 0; @acc 0; 1, 2, 3) // 又命中兜底臂!把整个输入当成 $rest
→ ... // 永远收敛不了实测报错:
error: recursion limit reached while expanding `sum_internal!`
|
= help: consider increasing the recursion limit by adding a
`#![recursion_limit = "256"]` attribute to your crate修法一(推荐):把兜底臂移到最后。
rust
macro_rules! sum_internal {
(@acc $acc:expr;) => { $acc };
(@acc $acc:expr; $next:expr, $($rest:tt)*) => {
sum_internal!(@acc $acc + $next; $($rest)*)
};
(@acc $acc:expr; $next:expr) => { $acc + $next };
($($rest:tt)*) => { sum_internal!(@acc 0; $($rest)*) }; // ← 最后
}
fn main() {
println!("{}", sum_internal!(1, 2, 3)); // 输出:6
}修法二:让兜底臂不可能匹配到 @ 开头的输入。 macro_rules! 没有否定匹配,但可以把兜底臂的第一位写成 expr 这类 「@ 无法作为开头」的片段,从语法上把它和内部臂隔开:
rust
macro_rules! sum_internal_v2 {
(@acc $acc:expr;) => { $acc };
(@acc $acc:expr; $next:expr, $($rest:tt)*) => {
sum_internal_v2!(@acc $acc + $next; $($rest)*)
};
(@acc $acc:expr; $next:expr) => { $acc + $next };
// 用 expr 起头:`@` 不能开始一个表达式,所以不会吞掉内部调用
($first:expr $(, $rest:expr)* $(,)?) => {
sum_internal_v2!(@acc 0; $first $(, $rest)*)
};
}修法三(不推荐):加 #![recursion_limit = "512"]。 这只是把「立刻失败」改成「更晚失败」——真正的问题是展开不收敛, 提高上限只会让报错更晚、更难看。
结论:修法一最好。它不改变能力、不依赖片段匹配的细节、读者一眼能看出 「内部臂在前、入口臂在后」的意图,也就是把 @ 标记臂当作「私有函数」, 把 tt 兜底臂当作「公开入口」。
练习 10:判断宏代码能否编译
难度:★★☆
要求:判断下面每段代码能否编译,并说明原因。全部基于 rustc 1.98.1 / edition 2024。
rust
// 片段 A
macro_rules! a {
($x:expr) => { $x + 1 };
}
fn main_a() { println!("{}", a!(1) * 2); }
// 片段 B
macro_rules! b {
() => {
fn inner() -> u32 { 1 } // 宏体里有「语句/项」,但没有外层块
inner()
};
}
fn main_b() { println!("{}", b!()); }
// 片段 C
macro_rules! c {
() => {{ let x = 1; x }};
}
fn main_c() { let x = 100; println!("{} {}", c!(), x); }
// 片段 D
macro_rules! d {
() => { fn helper() -> u32 { 1 } };
}
fn main_d() { d!(); d!(); println!("{}", helper()); }提示:分别考虑「片段是否原子」「语句与表达式的位置差异」「局部变量卫生性」 「类型/函数名的卫生性」。
参考答案(先自己写再看)
参考答案(先自己写再看)
片段 A:能编译,输出 4(不是一个「陷阱」)。
rust
macro_rules! a {
($x:expr) => { $x + 1 }; // 展开结果没有外层块
}
fn main_a() { println!("{}", a!(1) * 2); }很多人(包括大量中文教程)会说这里展开成 1 + 1 * 2、按优先级算得 3。 这是错的,实测为 4。 原因是 $x:expr 这类片段在展开时已经被包成 单个原子 AST 节点,宏调用整体对外等价于 (1 + 1):
a!(1) * 2
→ { 1 + 1 } * 2 // 片段 1 是原子节点,+1 作用在它上面
→ (1 + 1) * 2 // 宏调用整体也是一个原子表达式
→ 4补充实测(rustc 1.98.1):bare_add!(1, 2) * 10(() => { $a + $b }) 同样得到 30,与写成 {{ $a + $b }} 完全一致。 结论:expr 片段不需要为了运算符优先级再加括号。 加双花括号的理由是别的(见片段 B,以及「宏体为什么普遍写双花括号」的说明)。
片段 B:不能编译。
rust
macro_rules! b {
() => {
fn inner() -> u32 { 1 } // 宏体里有「项」,但没有外层块
inner()
};
}
fn main_b() { println!("{}", b!()); }error: expected expression, found keyword `fn`
--> src\main.rs:10:9
|
10 | fn inner() -> u32 { 1 }
| ^^ expected expression
...
14 | fn main_b() { println!("{}", b!()); }
| ---- in this macro invocation原因:宏的展开结果被放进表达式位置(println! 的一个参数),而 fn inner() {...} 是一个项(item),不是表达式。没有外层块时,展开产物就是 fn inner() -> u32 { 1 } inner() 这两条语句,编译器在表达式位置看到 fn 就报错。
修法:给展开器加一层块,让产物成为块表达式(块里合法地包含项与语句):
rust
macro_rules! b_fixed {
() => {{
fn inner() -> u32 { 1 }
inner()
}};
}
fn main_b_fixed() { println!("{}", b_fixed!()); } // 输出:1这就是双花括号真正解决的问题:不是优先级,而是「宏内部有语句或项时, 如何让整个产物仍然是一个值」。
片段 C:能编译,输出 1 100。
rust
macro_rules! c {
() => {{ let x = 1; x }};
}
fn main_c() { let x = 100; println!("{} {}", c!(), x); }局部变量是卫生的:宏内的 let x = 1 与外部的 let x = 100 是两个不同的符号。 宏内的 x 不会污染调用者,调用者的 x 也不会影响宏内。 注意这里双花括号是必须的——正是为了让 let x = 1; x 成为一个块表达式。
片段 D:不能编译。
rust
macro_rules! d {
() => { fn helper() -> u32 { 1 } };
}
fn main_d() { d!(); d!(); println!("{}", helper()); }函数名不卫生:每次展开都真的定义了一个叫 helper 的函数, 第二次展开时报:
error[E0428]: the name `helper` is defined multiple times
--> src\main.rs:6:20
|
6 | fn main_d() { d!(); d!(); println!("{}", helper()); }
| -- previous definition of `helper` here
| ^^^^^^ `helper` redefined here修法:
- 不要导出同名函数,改成让宏产出一个表达式或闭包。
- 如果确实要生成项,把生成的名字交给调用者(
($n:ident) => { fn $n() ... })。 - 或者把产物包进匿名
const块,让它在独立作用域里:
rust
#![allow(dead_code)]
macro_rules! d_fixed {
() => {{
const _: () = {
fn helper() -> u32 { 1 }
// 这个 helper 只在这个匿名 const 作用域内可见,展开多次也不会撞名
};
1u32
}};
}
fn main() {
let a = d_fixed!();
let b = d_fixed!(); // 展开两次也不会重复定义
println!("{a} {b}"); // 输出:1 1
}⚠️ 注意:匿名
const里的代码必须是 const 可求值的, 所以这条修法只适合「定义签名、不做运行期调用」的场景 (比如生成impl或类型别名)。真正想生成可调用的函数时,用第 2 条修法。
总结这四段的关键差异:
| 片段 | 是否编译 | 原因 |
|---|---|---|
| A | ✅ 输出 4 | expr 片段与宏调用本身都是原子 AST 节点,不存在优先级被吸走的问题 |
| B | ❌ expected expression, found keyword `fn` | 宏体含项/语句却没有外层块,产物在表达式位置不是表达式 |
| C | ✅ 输出 1 100 | 局部变量卫生 |
| D | ❌ E0428 | 函数名不卫生 |
练习 11:宏、泛型与 trait 的选型
难度:★★★
要求:下面三个需求,分别判断该用泛型、trait、macro_rules!、 过程宏还是直接写代码,并各给一个最小实现或明确说明为什么不用宏:
- 给
i32、i64、f64各写一份「求切片最大值」的代码。 - 让 20 个不同的结构体都能「转成 JSON 字符串」,字段名要出现在 JSON 里。
- 提供一个
sum!(a, b, c, ...)宏,参数个数不定、类型都是i32。
提示:第 2 题的关键是「宏能不能知道字段名」;第 3 题的关键是「泛型能不能表达不定长参数」。
参考答案(先自己写再看)
参考答案(先自己写再看)
需求 1:给 i32/i64/f64 求切片最大值 → 用泛型,不要用宏。
rust
fn max_of<T: PartialOrd + Copy>(xs: &[T]) -> Option<T> {
xs.iter().copied().reduce(|a, b| if b > a { b } else { a })
}
fn main() {
println!("{:?}", max_of(&[3, 1, 4, 1, 5])); // Some(5)
println!("{:?}", max_of(&[3i64, 1, 4])); // Some(4)
println!("{:?}", max_of(&[3.0f64, 1.5])); // Some(3.0)
}理由:三个类型的逻辑完全一样,唯一区别是类型本身,而类型正是泛型的本职工作。 用宏的话你会得到三份重复代码、三份错误信息、三倍的维护成本, 而且完全享受不到类型检查的好处。
需求 2:20 个结构体转 JSON,字段名要出现 → 用过程宏(derive),不能用 macro_rules!。
rust
// 定义侧(过程宏),放在 proc-macro = true 的独立 crate 里
#[proc_macro_derive(ToJson)]
pub fn derive_to_json(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
let fields = /* 从 Data::Struct / Fields::Named 取出字段 ident 列表 */;
quote! {
impl ToJson for #name {
fn to_json(&self) -> String {
let parts: Vec<String> = vec![
#( format!("\"{}\":{}", stringify!(#f), self.#f.to_json_value()) ),*
];
format!("{{{}}}", parts.join(","))
}
}
}
.into()
}
// 使用侧
#[derive(ToJson)]
struct User { id: u64, name: String }理由:macro_rules! 不能内省类型——它看不到结构体有哪些字段, 只能匹配「调用处传给它的 token」。字段名只有 syn 解析 AST 之后才知道。 这是「过程宏不可替代」的典型场景。 (如果只是「没有字段名的 JSON 数组」,macro_rules! 也能凑合; 一旦需要字段名/字段类型,就必须过程宏。)
需求 3:sum!(a, b, c, ...) 不定长、类型都是 i32 → 用 macro_rules!。
rust
macro_rules! sum {
() => { 0i32 };
($($x:expr),+ $(,)?) => { 0i32 $(+ $x)+ };
}
fn main() {
assert_eq!(sum!(), 0);
assert_eq!(sum!(1, 2, 3), 6);
assert_eq!(sum!(1, 2, 3,), 6);
}理由:泛型无法表达「不定长的参数列表」——泛型参数个数在编译期必须写死。 正是这个缺口,让 println!、vec!、format! 这些标准库宏非存在不可。 如果类型全是 i32,其实更推荐直接用切片:fn sum(xs: &[i32]) -> i32—— 能写成函数就别写成宏。
三个需求的共性结论:
| 需求 | 选型 | 决定性因素 |
|---|---|---|
| 1 | 泛型 | 逻辑相同、仅类型不同 → 类型系统能表达 |
| 2 | 过程宏 | 需要读取结构信息(字段名)→ 只有 AST 能提供 |
| 3 | macro_rules!(或干脆用切片) | 需要不定长参数 → 泛型表达不了 |
练习 12:用 cfg 驱动跨平台代码
难度:★★☆
要求:写一个 cfg 驱动的跨平台函数 temp_dir():Windows 上读 TEMP、 Unix 上读 TMPDIR,都没有时返回当前目录。 再写一个 #[cfg_attr(test, derive(PartialEq))] 的结构体并加一个 #[test]。 说明 cfg!(windows) 与 #[cfg(windows)] 的区别。
提示:cfg! 只是返回 bool,两边都会编译。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
use std::path::PathBuf;
/// Windows 读 TEMP,Unix 读 TMPDIR,都没有就用当前目录
#[cfg(windows)]
fn temp_dir() -> PathBuf {
std::env::var_os("TEMP")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
#[cfg(unix)]
fn temp_dir() -> PathBuf {
std::env::var_os("TMPDIR")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
/// 只在测试构建里派生 PartialEq,生产构建里不加这个负担
#[cfg_attr(test, derive(Debug, PartialEq))]
struct Point {
x: i32,
y: i32,
}
fn main() {
println!("{}", temp_dir().display());
let p = Point { x: 1, y: 2 };
// 非测试构建里 Point 没有实现 Debug,所以这里手动打印字段
println!("{} {}", p.x, p.y); // 输出:1 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn temp_dir_is_not_empty() {
assert!(!temp_dir().as_os_str().is_empty());
}
#[test]
fn point_equality_only_in_tests() {
// 只有在 #[cfg(test)] 下 Point 才实现 PartialEq / Debug,因此这个断言只在测试里能编译
assert_eq!(Point { x: 1, y: 2 }, Point { x: 1, y: 2 });
}
}cargo test 的输出(预期):
running 2 tests
test tests::point_equality_only_in_tests ... ok
test tests::temp_dir_is_not_empty ... ok
test result: ok. 2 passed; 0 failedcfg!(windows) 与 #[cfg(windows)] 的区别:
#[cfg(windows)] | cfg!(windows) | |
|---|---|---|
| 类型 | 属性(attribute) | 宏,求值成 bool |
| 作用 | 删除不满足条件的 item | 不删代码,只是替换成 true/false |
| 被排除的代码是否检查 | ❌ 完全不参与编译(连语法错误也看不见) | ✅ 两边都参与编译 |
| 典型用途 | 平台专属实现(同一签名、两套函数体) | 运行期分支里的小差异(如路径分隔符) |
用错会怎样:想调用 Windows-only API 却写成 if cfg!(windows) { win_api() }, 在 Linux 上编译时 win_api() 依然会被编译,于是报「找不到该函数」。
要点解析:
- 两个
#[cfg]版本的temp_dir()签名相同,在任何平台上都恰好只有一个存在, 所以main里的调用不需要任何cfg。 #[cfg_attr(test, derive(Debug, PartialEq))]是「条件加属性」的标准用法: 生产构建的Point不含PartialEq,省掉实现与代码体积;测试构建才有。- 注意
#[cfg(test)]只作用于当前 crate 的测试编译:cargo test会以--test模式重新编译你的 crate,所以cfg(test)里的代码 在cargo build的产物里完全不存在。 std::env::var_os而不是var:环境变量可能不是合法 UTF-8,var_os返回OsString,在 Windows 上更稳妥。