Skip to content

练习与自测

本章练习共 11 题,答案折叠在每题下方。建议先自己写、编译通过后再展开答案;难度标记:★☆☆ 基础 / ★★☆ 综合 / ★★★ 挑战。

练习 1:实现 my_vec! 的三种形态

难度:★☆☆

要求:实现 my_vec! 宏,支持三种调用形态: my_vec![](空)、my_vec![elem; n](重复 n 个)、my_vec![a, b, c]my_vec![a, b, c,](列表,允许尾逗号)。 必须返回真正的 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! 调用共用一条臂,就需要用 tt muncher 逐个吃参数, 但那样会让「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 invocationnote: 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 invocationtt 逐个吃是最省心的写法
  • @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)] 过程宏项目,要求:

  1. 给出完整目录树、两个 Cargo.tomllib.rsmain.rs
  2. 具名结构体生成 fn display_name(&self) -> String, 输出形如 Config(host="localhost", port=8080):字段名用 stringify!,值用 {:?}
  3. 元组结构体、枚举、union 给出可读的编译期错误(用 syn::Error::new_spanned)。
  4. 说明为什么 derive 宏不能修改原类型。

提示syn::DeriveInputData::StructFields::Named; 用 quote! 里的 #(#fields)* 重复展开多个字段。

参考答案(先自己写再看)参考答案(先自己写再看)

目录树

display-demo/
├── Cargo.toml
├── src/
│   └── main.rs
└── display-derive/
    ├── Cargo.toml
    └── src/
        └── lib.rs

Cargo.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)]), 就要用 synAttribute::parse_nested_metadarling 来解析, 代码量会明显上升——这也说明「什么时候该引入 darling」。

练习 8:编写属性宏 #[timed]

难度:★★★

要求:编写 #[timed] 属性宏:

  1. 打印函数名(用 stringify!)与执行耗时到 stderr。
  2. 保留原函数的可见性、其它属性(如 #[inline])、参数与返回类型。
  3. 说明为什么它不能正确处理 async fn,以及正确的做法是什么。

提示syn::ItemFnattrs / 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

修法:

  1. 不要导出同名函数,改成让宏产出一个表达式或闭包。
  2. 如果确实要生成项,把生成的名字交给调用者(($n:ident) => { fn $n() ... })。
  3. 或者把产物包进匿名 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✅ 输出 4expr 片段与宏调用本身都是原子 AST 节点,不存在优先级被吸走的问题
Bexpected expression, found keyword `fn`宏体含项/语句却没有外层块,产物在表达式位置不是表达式
C✅ 输出 1 100局部变量卫生
D❌ E0428函数名不卫生

练习 11:宏、泛型与 trait 的选型

难度:★★★

要求:下面三个需求,分别判断该用泛型traitmacro_rules!过程宏还是直接写代码,并各给一个最小实现或明确说明为什么不用宏:

  1. i32i64f64 各写一份「求切片最大值」的代码。
  2. 让 20 个不同的结构体都能「转成 JSON 字符串」,字段名要出现在 JSON 里。
  3. 提供一个 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 能提供
3macro_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 failed

cfg!(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 上更稳妥。

本章小结 / 自测清单

内容以 rustc 1.98.1 · Rust 2024 edition 为基准