Skip to content

过程宏与条件编译

三种过程宏、cfg 条件编译与代码生成的取舍。

过程宏(proc macro)全览

macro_rules! 只能做「语法层面的模式替换」。要读结构体字段、要访问类型信息、 要生成带正确 Span 的错误,就得用过程宏:一个在编译期被编译器调用的普通 Rust 函数

三类过程宏

类型声明方式调用形式输入 → 输出典型用途
函数式(function-like)#[proc_macro]my_macro!(...)TokenStreamTokenStreamsql!html!make_answer!
派生(derive)#[proc_macro_derive(Name)]#[derive(Name)]TokenStream(被派生的 item)→ TokenStream(新增的 implSerializeClapHelloName
属性(attribute)#[proc_macro_attribute]#[my_attr]两个 TokenStream(属性参数 + 被标注的 item)→ TokenStream#[tokio::main]#[timed]#[test] 风格

三者的能力边界:

  • 派生宏只能「追加」代码,不能修改或替换原类型。它拿到 struct Foo;,只能额外产出 impl ... for Foo。 想改原类型(比如删掉一个字段)必须用属性宏。
  • 属性宏可以「替换」原 item:拿到函数/结构体的 AST,可以返回完全不同的东西, 甚至什么都不返回(把函数删掉)。
  • 函数式宏最自由:输入是完全任意的 token 流,输出也是。

独立的 proc-macro = true crate

过程宏有两条硬性约束:

  1. 必须放在独立的 crate 里,该 crate 的 Cargo.toml 必须声明 [lib] proc-macro = true
  2. 该 crate 只能导出过程宏,不可以导出普通函数/类型给用户直接 use
toml
# hello-derive/Cargo.toml
[package]
name = "hello-derive"
version = "0.1.0"
edition = "2024"

[lib]
proc-macro = true          # ← 少了这一行,报错见「常见坑与编译错误」

[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full", "extra-traits"] }

⚠️ 陷阱:想在过程宏 crate 里放一个「供生成的代码调用的运行时辅助函数」是不行的。 标准做法是拆成两个 crate:hello-derive(proc-macro)+ hello-core(普通 lib, 放 trait 与辅助函数),由用户在 Cargo.toml 里同时依赖(或用 hello-derive[dependencies] hello-core + pub use 重导出)。

proc-macro2 / quote / syn 三件套

三者的职责划分非常清晰,缺一不可:

crate职责为什么需要它
proc-macro2提供 TokenStreamIdentSpanLiteral 等类型的可测试版本proc_macro 是编译器内部 API,只能在过程宏里用,无法写单元测试proc-macro2 提供同样的类型但可在普通测试里构造与断言
quotequote! { ... } 宏:把 Rust 代码写成模板,模板里的 #var 会被替换成变量手写 TokenStream 构建代码极其痛苦;quote! 让「生成代码」看起来像「写代码」
synTokenStream 解析成结构化 ASTDeriveInputItemFnField……)不解析就只能做 token 层面的字符串拼接,无法知道「字段名是什么」「函数签名是什么」

三者的关系:

              ┌───────────────────────────┐
TokenStream ──►│ syn::parse_macro_input!   │──► DeriveInput / ItemFn / ...
   (输入)      └───────────────────────────┘         (结构化数据)


              ┌───────────────────────────┐      quote! { ... #var ... }
TokenStream ◄─│ .to_token_stream() / ...  │◄──── 模板展开
   (输出)      └───────────────────────────┘

proc_macro2quote/syn 的约定是:过程宏入口处做一次转换proc_macro::TokenStreamproc_macro2::TokenStream),出口处再转回来syn::parse_macro_input! 已经替你做了入口转换。

🚀 进阶syn 的 feature 是必须显式打开的。

  • full:解析完整的项(ItemFnItemStruct、表达式等)。属性宏处理函数体必须开。
  • extra-traits:给 AST 类型加 Debug/Eq/Hash 等实现(调试和缓存用)。
  • 默认只开 derive + parsing + printing,够写最基本的 derive 宏。

完整示例:#[derive(HelloName)]

目录结构(这是完整的可运行项目):

hello-demo/
├── Cargo.toml                 # 工作区或普通二进制 crate
├── src/
│   └── main.rs
└── hello-derive/
    ├── Cargo.toml
    └── src/
        └── lib.rs

Cargo.toml

toml
[package]
name = "hello-demo"
version = "0.1.0"
edition = "2024"

[dependencies]
hello-derive = { path = "hello-derive" }

hello-derive/Cargo.toml

toml
[package]
name = "hello-derive"
version = "0.1.0"
edition = "2024"

[lib]
proc-macro = true

[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full", "extra-traits"] }

hello-derive/src/lib.rs(完整):

rust
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Data, Fields};

/// 为具名结构体派生 `HelloName`:用第一个字段的值问好。
#[proc_macro_derive(HelloName)]
pub fn derive_hello_name(input: TokenStream) -> TokenStream {
    // 1) 入口:把 token 流解析成结构化 AST;失败时自动 compile_error!
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;                       // 结构体名

    // 2) 只支持「具名结构体」,其余情况给出可读的编译期错误
    let fields = match &input.data {
        Data::Struct(s) => match &s.fields {
            Fields::Named(named) => &named.named,
            _ => {
                return syn::Error::new_spanned(
                    &input.ident,
                    "HelloName 只支持带具名字段的结构体,例如 `struct User { name: String }`",
                )
                .to_compile_error()
                .into();
            }
        },
        _ => {
            return syn::Error::new_spanned(
                &input.ident,
                "HelloName 只能用于 struct,不能用于 enum 或 union",
            )
            .to_compile_error()
            .into();
        }
    };

    let first = match fields.first() {
        Some(f) => f,
        None => {
            return syn::Error::new_spanned(&input.ident, "HelloName 至少需要一个字段")
                .to_compile_error()
                .into();
        }
    };
    let first_ident = first.ident.as_ref().expect("具名字段一定有 ident");

    // 3) 出口:用 quote! 生成 impl 块。派生宏只能「追加」代码,不能改原类型
    let expanded = quote! {
        impl HelloName for #name {
            fn hello_name(&self) -> String {
                let #first_ident = &self.#first_ident;
                format!("Hello, {}!", #first_ident)
            }
        }
    };

    expanded.into()                                // proc_macro2 -> proc_macro
}

src/main.rs(完整):

rust
use hello_derive::HelloName;

/// 这个 trait 由 `#[derive(HelloName)]` 自动实现
pub trait HelloName {
    fn hello_name(&self) -> String;
}

#[derive(HelloName)]
struct Greeter {
    name: String,
}

fn main() {
    let g = Greeter { name: "Rust".to_string() };
    println!("{}", g.hello_name());     // 输出:Hello, Rust!
}

🧠 原理quote! 里的 #name 会把 Ident 直接插进生成的 token 流,保留 Span, 所以报错位置能指回用户写的结构体。#first_identletself. 两处各用一次, 这正是「同一个变量在模板里复用」的写法。

关于 #[derive] 的边界,务必记牢两句话:

  • derive 只能附加代码:用途是「为已有类型自动实现 trait」。
  • derive 不能修改原类型:想在结构体里加字段、删方法,必须用属性宏。

属性宏示例:#[timed]

属性宏的签名是 fn(attr: TokenStream, item: TokenStream) -> TokenStreamattr 是属性自身的参数(#[timed] 时为空,#[timed(unit = "ms")] 时是 unit = "ms")。

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 {
    // 属性宏拿到的是完整的 ItemFn(需要 syn 的 "full" feature)
    let func = parse_macro_input!(item as ItemFn);

    let attrs = &func.attrs;          // 保留用户写的其它属性,如 #[inline]、#[doc]
    let vis = &func.vis;              // 保留可见性
    let sig = &func.sig;              // 保留签名(含泛型、async、返回值)
    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()
}

使用侧:

rust
use timed_derive::timed;

#[timed]
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));   // 输出:499500,同时 stderr 打印 [timed] slow took 3.5µs
}

💡 对照:这就是 Python 的 @decorator 的编译期版本。 Python 的装饰器可以在运行期替换函数对象;Rust 的属性宏在编译期替换整个 item。 前者灵活(能改闭包、能包装任意对象),后者零运行期开销。

关于属性宏的三个关键点:

  1. 它能替换原 item:返回什么都行,返回 TokenStream::new() 就等于删掉这个函数。
  2. 必须手工保留想要的东西attrsvissig 都得自己从 AST 里取出来再放回去, 漏掉 #vis 会让 pub fn 变成私有。
  3. #sig 已经包含函数名与参数,所以不能再写一遍签名;#block 已含花括号, 写 { #block } 会多一层(能编译,但会触发 unused_braces 警告)。

⚠️ 陷阱#[timed] 用在 async fn 上时,#sig 带着 async, 而你插入的 Instant::now()elapsed() 不会与被计时的 Future 一起运行—— 前者在 Future 构造时就执行了。要对 async fn 正确计时,需要把计时代码放进 async move { ... } 内部的 await 之后,即在 #blockawait 完成后取 elapsed()

函数式宏示例:make_answer!

函数式宏最自由:输入是完全任意的 token 流。下面这个宏读取一个标识符,生成一个返回常量的函数。

rust
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Ident};

/// `make_answer!(the_answer)` 生成 `pub fn the_answer() -> u32 { 42 }`
#[proc_macro]
pub fn make_answer(input: TokenStream) -> TokenStream {
    let name = parse_macro_input!(input as Ident);
    let doc = format!("由 make_answer! 生成,返回 {name} 的值");

    quote! {
        #[doc = #doc]                 // #[doc = "..."] 是 #[doc("...")] 的可编程形式
        pub fn #name() -> u32 {
            42
        }
    }
    .into()
}

用法:

rust
use answer_derive::make_answer;

make_answer!(the_answer);      // 在 item 位置调用,生成一个函数

fn main() {
    println!("{}", the_answer());   // 输出:42
}

sql! 风格(更贴近真实工程):把编译期字符串变成经过校验的查询常量。 思路是函数式宏解析一个字符串字面量,在编译期做校验,然后生成类型安全的代码。

rust
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, LitStr};

/// `sql!(SELECT)` 把关键字在编译期转成 `&'static str`,顺便校验非空。
/// 真实工程里会在这一步做语法校验、拼装参数占位符、生成 `Query` 类型。
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
    let lit = parse_macro_input!(input as LitStr);
    let text = lit.value();
    if text.trim().is_empty() {
        return syn::Error::new(lit.span(), "SQL 语句不能为空")
            .to_compile_error()
            .into();
    }
    let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
    quote! { #normalized }
        .into()
}

sql!("SELECT * FROM users") 展开成 "SELECT * FROM users" 这个 &'static str——全部发生在编译期,运行期零成本

调试过程宏:cargo expand

宏最痛的点是「看不见展开结果」。cargo expandrustc-Zunpretty=expanded 把展开后的完整源码打印出来:

powershell
cargo install cargo-expand          # 一次性安装(需要 nightly 工具链支持其内部机制)
cargo expand                        # 展开当前 crate 的所有宏
cargo expand my_module              # 只展开某个模块
cargo expand --bin my_app           # 指定目标

其他常用手段:

手段用途
cargo expand看最终展开结果,定位「宏到底生成了什么」
cargo rustc -- -Zunpretty=expanded不装额外工具时的等价做法(需 nightly)
rustc -Z macro-backtrace打印宏展开的调用栈,定位递归/muncher 的层级
#[macro_export] + 单元测试声明宏可以放在 tests/ 里断言 stringify! 的结果
proc-macro2TokenStream::from_str给过程宏写真正的单元测试(不依赖编译器)
syn::Error::to_compile_error()把错误变成 compile_error!{...},报错位置指向用户的 token

过程宏的错误处理范式:不要在过程宏里 panic!panic! 会得到 proc macro panicked 这种毫无上下文的报错;正确做法是构造 syn::Error

rust
// 这是过程宏函数体内部的一个分支(片段),完整项目结构见「完整示例:`#[derive(HelloName)]`」

// ✅ 好的错误:位置在用户的 token 上,消息可读
return syn::Error::new_spanned(
    &input.ident,
    "HelloName 只支持带具名字段的结构体",
)
.to_compile_error()
.into();

// ❌ 坏的错误:panic 信息里有代码路径,但用户看不懂哪里错了
// panic!("不支持的输入");

生态工具:darling 与 feature 开关

工具作用
darling#[derive(FromMeta)] 之类的方式声明式解析属性参数,省掉手写 syn 遍历代码
synfull解析完整项(ItemFnItemStruct……)与表达式;写属性宏必开
synextra-traits给 AST 类型加 DebugEqHash;调试与做缓存时开
synvisit / visit-mut生成访问者(visitor),用来遍历/改写整棵 AST
proc-macro2span-locationsSpan 能报出行列号(部分场景调试用)
trybuild用「预期编译失败」的 UI 测试锁定宏的错误信息
insta快照测试宏的展开结果

darling 的写法(cargo add darling):

rust
// 需要 cargo add darling(外部 crate,无法脱离 cargo 工程编译)
use darling::FromMeta;

// `#[timed(unit = "ms", repeat = 3)]` 的参数可以用一个结构体承接
#[derive(FromMeta)]
struct TimedArgs {
    #[darling(default)]
    unit: String,        // 缺失时用 Default
    #[darling(default)]
    repeat: u32,
}

🚀 进阶:如果属性参数的取值集合有限(比如只允许 "ms" / "us"), 用 darling#[darling(rename_all = "lowercase")] 配合枚举, 能自动把「非法取值」变成指向该字符串的编译错误,比自己写 match 更省事也更友好。



cfg 与条件编译作为元编程

条件编译(conditional compilation)是最古老、最可靠的元编程:编译期选择代码, 被排除的代码完全不参与类型检查,也不进二进制。

#[cfg(...)]:删除不满足条件的 item

rust
// 只有在 Windows 上才编译这个函数
#[cfg(windows)]
fn platform_name() -> &'static str { "Windows" }

#[cfg(unix)]
fn platform_name() -> &'static str { "Unix" }

fn main() {
    println!("{}", platform_name());   // 在 Windows 上输出:Windows
}

#[cfg] 可以加在几乎所有 item 上:函数、结构体、字段、impl 块、use、模块、语句。

rust
struct Config {
    host: String,
    #[cfg(debug_assertions)]
    debug_dump: bool,          // 只在 debug 构建里存在的字段
    #[cfg(not(debug_assertions))]
    _reserved: (),
}

#[cfg(target_os = "windows")]
mod win_only {
    pub fn api() -> &'static str { "Win32" }
}

fn main() {
    let _c = Config {
        host: String::new(),
        #[cfg(debug_assertions)]
        debug_dump: false,
        #[cfg(not(debug_assertions))]
        _reserved: (),
    };
    #[cfg(target_os = "windows")]
    println!("{}", win_only::api());
    println!("ok");
}

组合条件:all / any / not

谓词含义例子
all(a, b, ...)全部为真all(windows, target_arch = "x86_64")
any(a, b, ...)任一为真any(target_os = "linux", target_os = "macos")
not(a)取反not(debug_assertions)
feature = "x"该 Cargo feature 已启用feature = "serde"
test正在编译测试目标(cargo testcfg(test)
debug_assertions开启了调试断言(默认 dev profile)常用于「昂贵的检查」
target_os / target_arch / target_family / target_env / target_pointer_width目标平台信息target_pointer_width = "64"
unix / windowstarget_family 的语法糖#[cfg(unix)]
doc正在被 rustdoc 处理#[cfg(doc)]
rust
// 复杂条件读起来容易糊,建议用 cfg_attr 组合或在注释里写出等价布尔式
#[cfg(all(feature = "fast", not(debug_assertions)))]
fn fast_path() -> u32 { 1 }

#[cfg(any(
    all(target_os = "windows", target_pointer_width = "64"),
    all(target_os = "linux", target_pointer_width = "64"),
))]
fn sixty_four_bit_both() -> &'static str { "win/linux 64-bit" }

#[cfg_attr(...)]:条件地加属性

#[cfg_attr(条件, 属性)] 的意思是「条件成立时才附加这个属性」。最经典的用途是 只在测试构建里派生 PartialEq

rust
// 平时不派生,只有 cargo test 时才派生 PartialEq
#[cfg_attr(test, derive(Debug, PartialEq))]
struct Point { x: i32, y: i32 }

fn main() {
    let p = Point { x: 1, y: 2 };
    println!("{} {}", p.x, p.y);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn points_are_comparable() {
        assert_eq!(Point { x: 1, y: 2 }, Point { x: 1, y: 2 });
    }
}

cfg_attr 的另一个常见用法是「按 feature 开关 lint」:

rust
#![cfg_attr(feature = "strict", deny(warnings), allow(dead_code))]
#![cfg_attr(docsrs, feature(doc_cfg))]      // 只在 docs.rs 构建时开启

feature 与平台判定实战

feature 是 Cargo 层的开关,语法上是 #[cfg(feature = "name")]。 注意 feature 名是字符串,拼错不会被检查(只会永远为假)。

rust
// 可选依赖的常见写法:feature 打开时提供一个带默认实现的方法
pub trait Store {
    fn get(&self, key: &str) -> Option<String>;

    #[cfg(feature = "async-store")]
    fn get_async(&self, key: &str) -> impl core::future::Future<Output = Option<String>> + Send {
        let value = self.get(key);
        async move { value }
    }
}

平台判定的常见组合(写跨平台库时几乎每个文件都会用到):

rust
// 一套 API,两套实现:用 cfg 在编译期选一套,另一个根本不参与编译
#[cfg(unix)]
pub fn home_dir() -> std::path::PathBuf {
    std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default())
}

#[cfg(windows)]
pub fn home_dir() -> std::path::PathBuf {
    std::path::PathBuf::from(std::env::var("USERPROFILE").unwrap_or_default())
}

⚠️ 陷阱#[cfg] 只能看到编译期已知的事实,看不到「运行期这个值是不是 0」。 而且它不做语法检查——被 #[cfg] 排除的代码即使有语法错误也不会报。 所以「用 cfg 注释掉一段坏代码」是可行的,但也很容易留下长期腐烂的死代码, 建议同时用 cargo check --all-targets --all-features 覆盖常见组合。

build.rs:在编译前生成代码

构建脚本(build script)是 build.rs,Cargo 在编译你的 crate 之前运行它。 它最实际的两个用途:

  1. 生成源码,再用 include! 包含进来(代码生成)。
  2. 探测环境(库版本、系统头文件、环境变量),通过 println!("cargo::...") 告诉 Cargo。

build.rs 通过 println! 与 Cargo 通信(Cargo 1.77 起推荐新语法 cargo::, 旧语法 cargo: 已过时但仍在用):

rust
// build.rs —— 这是一个普通的 Rust 程序,在 Cargo 编译你的 crate 之前运行
use std::env;
use std::fs;
use std::path::PathBuf;

fn main() {
    // 1) 告诉 Cargo:这些文件变了就重新运行 build.rs
    println!("cargo::rerun-if-changed=build.rs");
    println!("cargo::rerun-if-changed=schema/buildings.json");

    // 2) OUT_DIR 是 Cargo 提供的、本次构建专属的输出目录
    let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR 一定由 cargo 提供"));

    // 3) 生成代码:真实项目里这里可能是解析 JSON/IDL/proto 的结果
    let generated = "\
/// 由 build.rs 生成,请勿手工修改
pub fn generated_at() -> &'static str {
    \"build-script\"
}
";
    fs::write(out_dir.join("generated.rs"), generated).expect("写入失败");

    // 4) 把自定义 cfg 传给 rustc,让主代码可以 #[cfg(my_feature)]
    println!("cargo::rustc-check-cfg=cfg(has_generated_code)");
    println!("cargo::rustc-cfg=has_generated_code");
}

主代码里用 include! 配合 OUT_DIR

rust
// src/main.rs
// env!("OUT_DIR") 在编译期展开成构建脚本的输出目录
include!(concat!(env!("OUT_DIR"), "/generated.rs"));

fn main() {
    println!("{}", generated_at());     // 输出:build-script

    #[cfg(has_generated_code)]
    println!("自定义 cfg 生效");         // 输出:自定义 cfg 生效
}

常用 cargo:: 指令:

指令作用
cargo::rerun-if-changed=PATH该文件/目录变化时重跑 build.rs
cargo::rerun-if-env-changed=VAR该环境变量变化时重跑
cargo::rustc-cfg=KEY给 rustc 传 --cfg KEY
cargo::rustc-check-cfg=cfg(KEY)声明这是已知的 cfg,避免 unexpected_cfgs 警告
cargo::rustc-env=K=V设置编译期环境变量,主代码可用 env!("K") 读取
cargo::rustc-link-lib=NAME / -link-search=PATH链接原生库
cargo::warning=MESSAGE打印构建警告

🧠 原理build.rs 的产物不会自动进入你的 crate——它只负责「把文件写进 OUT_DIR」, 真正把它拉进来的是主代码里的 include!。这条链路是: build.rs 运行 → 写 OUT_DIR/foo.rs → include!(concat!(env!("OUT_DIR"), "/foo.rs"))

build.rs 什么时候是坏主意

build.rs 很强,但它有明确的代价:

代价说明
构建变慢每次 rerun-if-changed 命中都要完整跑一遍
交叉编译困难build.rs宿主上编译运行,为嵌入式/异构目标生成代码时容易踩坑
可复现性受损依赖宿主的文件系统、环境变量、外部工具(protocpython……)
IDE 不友好生成的代码在 target/ 里,rust-analyzer 常常找不到,跳转与补全失效
调试复杂出错时要在「构建脚本失败」和「主 crate 编译失败」两层之间来回看

判断标准

  • ✅ 值得用:需要链接原生库、需要读 .proto/IDL 生成绑定、需要按目标平台选不同源文件。
  • ❌ 不值得用:只是想把一个常量表从别处搬进来(用 include_str! + 一次解析更好); 只是想少写重复代码(用宏或泛型);只是想读一个运行期环境变量(用 std::env::var)。
  • ⚠️ 折中方案:把生成器做成独立 crate/工具,在需要时手工运行,把生成结果提交进仓库。 这样构建快、IDE 友好、可 diff 审查,代价是「改了输入要记得重跑」。


代码生成与大 crate 的取舍

决策表:宏 vs 泛型 vs trait vs build.rs

需求首选理由反例(别这么干)
同一逻辑用于多种类型泛型类型检查完整、报错清晰、可内联i32/i64/f64 各写一个宏
一组类型共享行为契约trait可动态分发、可被下游实现用宏枚举所有可能的类型
可变参数 / 可变类型参数(println! 式)macro_rules!泛型表达不了「不定长参数 + 不同类型」硬塞 &[&dyn Any]
生成新类型/新函数(名字由参数决定)macro_rules!泛型只能作用于已有类型用泛型 + 巨大 match
简短、局部的语法糖macro_rules!展开可读、无额外 crate 成本为 3 行代码引入一个过程宏 crate
需要读 AST(字段名、属性、泛型参数)过程宏声明宏看不到结构用声明宏硬猜字段名
生成一组同构 impl(如 impl Foo for (A, B)macro_rules!纯 token 操作,够用且便宜用过程宏(引入编译成本)
需要外部信息(IDL、头文件、系统探测)build.rs + include!唯一能读外部世界的通道把外部文件内容手工复制进源码
只是想写 for 循环少打几个字什么都别用写直白的代码任何宏

一句话原则:

能用类型系统表达的,就不要用宏表达。 类型系统的错误发生在你写代码时;宏的错误发生在你读宏的时候。

宏带来的三类真实成本

(1)可读性:宏是「另一种语言」,读者需要额外学习成本。

rust
// 读者不知道这是宏时,会以为它们是某种特殊语法
fn main() {
    let v = vec![0; 10];                          // `expr; count` 这种形态只有宏能发明
    let m = matches!(Some(3), Some(n) if n > 2);  // 把「模式」当成参数传进去
    assert_eq!(v.len(), 10);
    assert!(m);
    // 自定义宏还能发明更多类似语法,例如 `query! { SELECT id, name FROM users }`——
    // 这正是宏既强大又难以阅读的原因。
}

(2)IDE 支持rust-analyzermacro_rules! 的支持已经不错(能跳转、能补全大部分场景), 但对过程宏生成的代码基本只能靠「宏展开预览」。所以:

  • 宏生成的公开 API,最好在文档里写清展开形态,或提供 #[doc = include_str!("...")]
  • 过程宏生成的结构体/枚举,rust-analyzer 往往无法给出字段补全。

(3)编译期成本

成本项说明
过程宏 crate 本身要编译一次性的,但 syn + quote 编译不便宜
宏每次展开都要执行展开次数与调用点数量成正比
展开产物参与后续所有阶段生成 10 万行代码 = 后续类型检查慢 10 万行
递归深度macro_rules! 默认 recursion_limit = 128,深层 muncher 可能撞上限

什么时候应该「写宏」,什么时候应该「写代码」

值得写宏的信号

  • 这段逻辑重复了 3 次以上,而且结构完全一样,只是类型/名字不同。
  • 重复的部分必须共享,否则会不一致(比如 enum 与它的 Display 实现必须同步)。
  • 拿不到泛型的表达能力(需要生成类型、可变参数、模式匹配语法糖)。

不该写宏的信号

  • 只需要一份逻辑 → 直接写函数。
  • 只需要对多种类型做同一件事 → 直接写泛型。
  • 只需要「看起来短」→ 写清楚的代码更值钱。
  • 团队里没有第二个人能读懂这个宏 → 这是最强的否决信号

💡 对照:这条经验在别的语言里也成立。Go 社区的口号是 「A little copying is better than a little dependency」, Rust 社区对应的说法更贴切:「每写一个宏,都是给未来的自己埋一个坑」。 标准库之所以敢用宏,是因为它有人专门维护、有 cargo expand 级别的测试、有文档。



与其他语言的对照

同一件事,各语言怎么写。这张表刻意横向拉长,方便你按「我在 X 语言里怎么做的」快速定位。

需求CC++JavaPythonGoRust
定义常量表达式#define MAX 100constexpr int MAX = 100;static final int MAX = 100;MAX = 100const MAX = 100const MAX: i32 = 100;(普通语言特性,不用宏)
泛型容器算法无(void* + 宏)template<class T> ...<T> List<T>鸭子类型func F[T any](...)fn f<T: Ord>(...)
可变参数打印printf("%d %s", ...)std::format / <<System.out.printfprint(f"{a} {b}")fmt.Printfprintln!("{} {}", a, b)(宏)
编译期代码生成预处理器文本替换模板实例化 / constexpr注解处理器 + 代码生成库装饰器 / 元类 / execgo generate + 生成器macro_rules! / proc macro
修改已有类型的 AST❌(有 UB 级 hack)✅(Lombok 改 AST)✅(元类)✅ 属性宏
自动实现序列化手写手写 / 反射库注解 + 反射 / APT运行期反射生成器 (easyjson)#[derive(Serialize)](编译期、零反射)
编译期条件编译#ifdef#if / if constexpr❌(靠构建工具)构建标签 //go:build#[cfg(...)] / cfg! / cfg_attr
编译期读外部文件#include#include / constexpr 受限APT 读文件❌(运行期读)go generateinclude_str! / include_bytes! / build.rs
标识符拼接## 预处理模板特化曲线救国APT 拼名字exec / getattr生成器写字符串声明宏 $x 直接插;过程宏 format_ident!
宏的卫生性❌ 完全没有模板基本卫生不适用不适用(装饰器是函数)不适用✅ 局部变量/标签卫生
报错质量预处理器错误几乎不可读模板报错以冗长闻名APT 报错中等运行期 TypeError生成器报错在生成的文件里声明宏一般;过程宏可做到优秀syn::Error
运行期开销00(模板单态化)注解处理 0 / 反射有装饰器有包装开销00(全部编译期)
调试手段gcc -E模板实例化 dump生成源码落盘查看直接读装饰器代码读生成文件cargo expand / -Z macro-backtrace

三条对照结论:

  1. Rust 把「编译期代码生成」做成了语言一等公民,而 Java/Go/Python 都靠外部工具或运行期机制。
  2. Rust 是少数同时拥有「卫生的声明宏」与「能读 AST 的过程宏」的语言 (另一个是 Lisp 家族与 Scala 3)。C 的文本宏与 C++ 的模板各占一半能力。
  3. Rust 拒绝运行期反射与求值,所以「框架自动发现」这类能力在 Rust 里 通常由 derive 宏在编译期产出,而不是运行期扫描。


常见坑与编译错误

每条给出:报错原文(rustc 1.98.1 实测)→ 原因 → 修法

no rules expected this token:片段类型选窄了

rust
macro_rules! sum {
    ($($x:expr),*) => { 0 $(+ $x)* };
}

fn main() {
    println!("{}", sum!(1 + 2, 3));
    println!("{}", sum!(let a = 1; a, 3));   // ❌ let 语句不是 expr
}
error: no rules expected keyword `let`
  --> src\main.rs:11:25
   |
2  | macro_rules! sum {
   | ---------------- when calling this macro
...
11 |     println!("{}", sum!(let a = 1; a, 3));
   |                         ^^^ no rules expected this token in macro call
   |
   = note: while trying to match sequence start

原因$x:expr 要求这个位置能开始一个表达式,而 let 是语句关键字。 注意提示 while trying to match sequence start——编译器在告诉你「连序列开头都匹配不上」。

修法(按优先级):

  1. 换片段类型:需要语句就用 $x:stmt,需要任意 token 就用 $x:tt
  2. 加一条更宽的兜底臂,并给出 compile_error! 明确消息。
  3. 检查是不是调用方写错了(很多时候确实是调用方的问题,报错本身是对的)。
rust
macro_rules! sum_tt {
    ($($x:tt),*) => { 0 $(+ $x)* };
}
// 注意:用 tt 会让宏「匹配成功但类型检查失败」,错误信息从宏内部转移到类型检查阶段,
// 不一定更好。优先考虑改调用方或加 compile_error!。

cannot find macro:定义在使用之后

rust
fn main() {
    let v = my_vec![1, 2, 3];      // ❌ 宏还没定义
}

macro_rules! my_vec {
    ($($x:expr),*) => { vec![$($x),*] };
}
error: cannot find macro `my_vec` in this scope
 --> src\main.rs:3:13
  |
3 |     let v = my_vec![1, 2, 3];
  |             ^^^^^^ consider moving the definition of `my_vec` before this call
  |
note: a macro with the same name exists, but it appears later

原因macro_rules! 是文本作用域,只对定义点之后的代码可见。

修法:把定义移到使用之前;或者加 #[macro_export] 后用 use crate::my_vec;; 或者把定义放进模块并用 #[macro_use] mod defs;(见「macro_rules! 必须在使用前定义」)。

⚠️ 陷阱:跨 crate 使用时,cannot find macro 的常见根因是忘了 #[macro_export]#[macro_export] 会把宏放在定义 crate 的根,所以导入路径是 use the_crate::macro_name;, 而不是它所在的模块路径。

跨 crate 宏:忘了 $crate,同 crate 测试全过、下游全炸

rust
// mylib/src/lib.rs
pub mod util { pub fn normal(x: i32) -> i32 { x } }

#[macro_export]
macro_rules! bad {
    ($e:expr) => { util::normal($e) };      // ❌ 裸路径 util 在调用者作用域解析
}

下游 use mylib::bad; bad!(1); 报:

error[E0433]: failed to resolve: use of undeclared crate or module `util`
 --> src\main.rs:5:13
  |
5 |     println!("{}", bad!(1));
  |                    ^^^^^^^ use of undeclared crate or module `util`
  |
  = note: this error originates in the macro `bad` which comes from the expansion of the macro
          `bad` (in Nightly builds, run with -Z macro-backtrace for more info)

原因:宏在调用点展开;裸路径在调用者的名字空间里解析。

修法:宏体内指向自身 crate 的路径一律写 $crate::

rust
#[macro_export]
macro_rules! good {
    ($e:expr) => { $crate::util::normal($e) };   // ✅
}

顺带记住:如果一个 #[macro_export] 的宏需要递归调用自己,递归处也要写 $crate::, 跨 crate 才安全;而没有 #[macro_export] 的局部宏则必须写裸名(写 $crate:: 会报 E0433)。

unexpected end of macro invocation:尾逗号与缺失的基础臂

情形 A:没写 $(,)?

rust
macro_rules! sum {
    ($($x:expr),*) => { 0 $(+ $x)* };
}
sum!(1, 2, 3,);      // ❌
error: unexpected end of macro invocation
 --> src\main.rs:8:33
  |
2 | macro_rules! sum {
  | ---------------- when calling this macro
...
8 |     println!("{}", sum!(1, 2, 3,));
  |                                 ^ missing tokens in macro arguments
  |
note: while trying to match meta-variable `$x:expr`

修法:模式末尾加 $(,)?

情形 B:tt muncher 缺基础臂

rust
macro_rules! countdown {
    ($first:expr, $($rest:tt)*) => { 1 + countdown!($($rest)*) };
}
countdown!(1, 2, 3);     // ❌ 递归到 countdown!() 时无臂可匹配
error: unexpected end of macro invocation
  |
7 |     println!("{}", countdown!(1, 2, 3));
  |                                      ^ missing tokens in macro arguments
  |
note: while trying to match `,`

修法:补一个能匹配「最后一个元素」或空输入的臂。

rust
macro_rules! countdown_ok {
    () => { 0 };
    ($single:expr) => { 1 };
    ($first:expr, $($rest:tt)*) => { 1 + countdown_ok!($($rest)*) };
}

🧠 原理unexpected end of macro invocation两种完全不同的成因, 看尾部的 note: 就能区分:while trying to match meta-variable ... → 尾逗号问题; while trying to match `,` → 递归缺少终止臂。

宏体为什么普遍写「双花括号」

先纠正一个流传很广的误解。很多人说「双花括号是为了防止运算符优先级把宏产物吸进去」, 但实测并非如此macro_rules! 的片段($x:expr)在展开时就已经被包成 单个原子 AST 节点,宏调用整体对外的结合性与 (...){...} 相同。实测:

rust
macro_rules! bare_add {
    ($a:expr, $b:expr) => { $a + $b };     // 没有外层块
}
macro_rules! block_add {
    ($a:expr, $b:expr) => {{ $a + $b }};   // 双花括号
}

fn main() {
    // 如果片段不原子,前者会展开成 1 + 2 * 10 = 21;实测两者都是 30
    println!("bare  = {}", bare_add!(1, 2) * 10);    // 输出:30
    println!("block = {}", block_add!(1, 2) * 10);   // 输出:30
}

所以 expr 片段不需要为了安全再加括号。那双花括号到底解决什么?

真正的理由有三个,都与「宏内部要不要写语句」有关。

理由一:只有块才能写语句。 宏体里想 let 一个中间变量、想开作用域, 就必须是一个块;而要让这个块在调用处看起来像一个值,就要让它成为块表达式。 双花括号就是「外层 {} 是宏的语法结构,内层 {} 是真正的块表达式」这个约定的写法。

rust
// ❌ 单花括号:展开成「三条语句」,不是表达式,不能 `let x = ...;`
macro_rules! single_stmts {
    () => {
        let mut v = Vec::new();   // 这些是宏体里的语句……
        v.push(1);
    };
}

// ✅ 双花括号:整个展开是「一个块表达式」,值是 v
macro_rules! block_expr {
    () => {{
        let mut v = Vec::new();   // 同样的语句,但被包进一个块表达式
        v.push(1);
        v                         // 块的值
    }};
}

fn main() {
    single_stmts!();             // 只能在「语句位置」用,值被丢弃
    let x = block_expr!();       // 能绑定,因为右侧是一个表达式
    println!("{x:?}");           // 输出:[1]
}

理由二:保持「能在表达式位置使用」。 没有外层块的宏在语句位置展开时, 分号会被解释成「语句结束、值被丢弃」,于是「宏的值」会静默消失:

rust
macro_rules! m_bare {
    () => { 1 + 1 };
}

fn main() {
    m_bare!();                  // 这是一条「表达式语句」,值 2 被直接丢掉
    // 展开成:1 + 1;   —— 编译通过,但什么都没留下
}

理由三:保证方法链、泛型实参等紧邻语法能正常解析。 双花括号让宏产物 在语法上是一个完整的块(其值即块的值),因此 mac!().to_string().len()take::<u8>(mac!()) 这类写法不会因为展开内容的结构而出问题。

⚠️ 陷阱(实证纠正):网上常见的 「() => { $a + $b } 会展开成 $a + $b,被外部 * 3 抢先结合」这个说法 在 Rust 里是不成立的——expr 片段自带原子性,实测 bare_add!(1, 2) * 10 == 30。 写括号/花括号在 Rust 里的价值在于 C 语言习惯的「防御性书写」, 而不是修复一个真实存在的优先级 bug。 真正需要双花括号的场合是「宏内部有语句或 let,以及「希望宏产物在 任何表达式位置都能当作一个值」。

💡 约定:社区习惯是——返回值的宏一律写 {{ ... }}。 这不是因为不写会错,而是因为「块表达式」是最不容易在未来改动中出问题的形态, 而且它天然允许宏内部演进(先只有一句,后来需要加 let)。 例外:宏本身的用途就是生成 item(如 state_machine!)或纯声明时,不需要外层块。

内部标记臂顺序写反 → recursion limit reached

rust
macro_rules! sum_internal {
    ($($rest:tt)*) => { sum_internal!(@acc 0; $($rest)*) };   // ❌ 兜底臂在最前
    (@acc $acc:expr;) => { $acc };
}
error: recursion limit reached while expanding `sum_internal!`
  |
  = help: consider increasing the recursion limit by adding a
          `#![recursion_limit = "256"]` attribute to your crate

原因:兜底臂 $($rest:tt)*@acc 0; 1, 2, 3 都能匹配, 于是每次展开都重新变成「先加 @acc 0;」,永不收敛。

修法:把具体模式放前面,兜底模式放最后;提高 recursion_limit 只是掩盖症状, 不能解决不收敛的问题。

过程宏 crate 忘记 proc-macro = true

toml
# Cargo.toml(缺少 [lib] proc-macro = true)
[package]
name = "dsh-proc"
version = "0.1.0"
edition = "2024"
rust
// 放在一个「忘了写 [lib] proc-macro = true」的 crate 的 src/lib.rs 里
use proc_macro::TokenStream;

#[proc_macro_derive(HelloName)]
pub fn hello_name(input: TokenStream) -> TokenStream { input }
error: the `#[proc_macro_derive]` attribute is only usable with crates of the `proc-macro` crate type
 --> src\lib.rs:3:1
  |
3 | #[proc_macro_derive(HelloName)]
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

error[E0432]: unresolved import `proc_macro`
 --> src\lib.rs:1:5
  |
1 | use proc_macro::TokenStream;
  |     ^^^^^^^^^^ use of unresolved module or unlinked crate `proc_macro`

修法:在过程宏 crate 的 Cargo.toml 里加:

toml
[lib]
proc-macro = true

顺带记住:这个 crate 不能再导出别的东西给用户直接调用。 需要共享的 trait/辅助函数要放进一个普通 lib crate(见「独立的 proc-macro = true crate」 的陷阱框)。

synproc-macro2 版本不匹配

典型现象:syn 内部产生的类型无法交给 quote!,或者你手写的 impl ToTokens for MyType 报「the trait bound is not satisfied」:

error[E0277]: the trait bound `MyAst: ToTokens` is not satisfied
  |
  = note: required by a bound in `quote::Tokens`

原因proc-macro2synquote 之间的公共依赖,两边的大版本必须一致 (都停在 1.x)。如果你显式写了 proc-macro2 = "0.4"syn = "2", 就会出现两个不同的 proc_macro2::TokenStream 类型,它们互不兼容。

修法

  1. 不要手工锁 proc-macro2 的小版本,交给 Cargo 统一解析。
  2. Cargo.lock 里检查是否出现两个不同 majorproc-macro2
powershell
cargo tree -i proc-macro2          # 看谁在依赖它、有几个版本
cargo tree -d                      # 看所有重复依赖
  1. 多 crate 工作区里,各成员声明 syn = "2" / quote = "1" 即可,别写 =2.0.1 这种精确锁。

🚀 进阶syn 2.x 与 1.x 的 API 差异很大(比如 syn::Error::new_spanned 的签名、 Attribute::parse_meta 被拆成 parse_args / parse_nested_meta)。 网上大量教程仍是 1.x,抄代码时先看 Cargo.toml 里的 syn 版本。



速查表

想做的事写法备注
定义声明宏macro_rules! name { (模式) => { 展开 }; }必须在使用前定义
导出宏给下游#[macro_export] + use crate_name::name;宏体里指向自身用 $crate::
匹配表达式$x:expr片段本身是原子节点;但模式里后面不能紧跟运算符
匹配任意单个 token$x:tt最宽,错误被推迟
匹配标识符$x:ident用来造名字
匹配类型 / 路径 / 可见性$x:ty / $x:path / $x:vis转发签名时用
0 或多个$( $x:expr ),*展开侧也要同深度 $( ... )*;展开侧带分隔符时必须为零个单列一臂
1 个或多个$( $x:expr ),+至少一个
允许尾逗号$( ... ),* $(,)?必须显式写
可选片段$( ... )?0 或 1 次
递归吃 token基础臂 + ($first:expr, $($rest:tt)*)基础臂必须存在
让产物成为一个值() => {{ ... }}宏体里有 let/语句时必须双花括号
内部标记(@mark $($x:tt)*) + 兜底臂放最后兜底臂别写最前
递归自己#[macro_export]$crate::m!;局部宏 → m!写反了报 E0433
把 token 变字符串stringify!($x)不递归展开内层宏
编译期拼字符串concat!("a", "b")只接受字面量
编译期条件#[cfg(all(...))] / cfg!(...)cfg! 不删代码
条件加属性#[cfg_attr(test, derive(Debug))]测试专用派生
内嵌文件内容include_str! / include_bytes!会注册为编译依赖
内联源码include!(concat!(env!("OUT_DIR"), "/x.rs"))配合 build.rs 生成
读编译期环境变量env!("K") / option_env!("K")缺失时前者报错、后者 None
位置信息file!() / line!() / column!() / module_path!()日志与断言
断言assert! / assert_eq! / debug_assert!debug_assert! 只在 debug 生效
模式判断matches!(v, Some(x) if x > 0)返回 bool,不 panic
调试打印dbg!(expr)打印位置+表达式文本,并原样返回
占位 panictodo!() / unimplemented!() / unreachable!()都会 panic,慎入公开 API
声明过程宏 crate[lib] proc-macro = true独立 crate,只导出过程宏
函数式过程宏#[proc_macro] fn f(t: TokenStream) -> TokenStreammy_macro!(...)
派生过程宏#[proc_macro_derive(Name)]只能追加 impl,不能改原类型
属性过程宏#[proc_macro_attribute] fn f(a: TokenStream, i: TokenStream) -> ...可替换原 item
解析输入syn::parse_macro_input!(input as DeriveInput)入口转换
生成代码quote! { impl #trait for #ty { ... } }#var 插值,保留 Span
报编译错误syn::Error::new_spanned(&t, "msg").to_compile_error()别 panic
看展开结果cargo expand调试宏的第一手段
看展开调用栈cargo rustc -- -Z macro-backtrace需 nightly
提高递归上限#![recursion_limit = "256"]治症状不治根因


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