过程宏与条件编译
三种过程宏、
cfg条件编译与代码生成的取舍。
过程宏(proc macro)全览
macro_rules! 只能做「语法层面的模式替换」。要读结构体字段、要访问类型信息、 要生成带正确 Span 的错误,就得用过程宏:一个在编译期被编译器调用的普通 Rust 函数。
三类过程宏
| 类型 | 声明方式 | 调用形式 | 输入 → 输出 | 典型用途 |
|---|---|---|---|---|
| 函数式(function-like) | #[proc_macro] | my_macro!(...) | TokenStream → TokenStream | sql!、html!、make_answer! |
| 派生(derive) | #[proc_macro_derive(Name)] | #[derive(Name)] | TokenStream(被派生的 item)→ TokenStream(新增的 impl) | Serialize、Clap、HelloName |
| 属性(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
过程宏有两条硬性约束:
- 必须放在独立的 crate 里,该 crate 的
Cargo.toml必须声明[lib] proc-macro = true。 - 该 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 | 提供 TokenStream、Ident、Span、Literal 等类型的可测试版本 | proc_macro 是编译器内部 API,只能在过程宏里用,无法写单元测试;proc-macro2 提供同样的类型但可在普通测试里构造与断言 |
quote | quote! { ... } 宏:把 Rust 代码写成模板,模板里的 #var 会被替换成变量 | 手写 TokenStream 构建代码极其痛苦;quote! 让「生成代码」看起来像「写代码」 |
syn | 把 TokenStream 解析成结构化 AST(DeriveInput、ItemFn、Field……) | 不解析就只能做 token 层面的字符串拼接,无法知道「字段名是什么」「函数签名是什么」 |
三者的关系:
┌───────────────────────────┐
TokenStream ──►│ syn::parse_macro_input! │──► DeriveInput / ItemFn / ...
(输入) └───────────────────────────┘ (结构化数据)
│
▼
┌───────────────────────────┐ quote! { ... #var ... }
TokenStream ◄─│ .to_token_stream() / ... │◄──── 模板展开
(输出) └───────────────────────────┘proc_macro2 与 quote/syn 的约定是:过程宏入口处做一次转换 (proc_macro::TokenStream → proc_macro2::TokenStream),出口处再转回来。 syn::parse_macro_input! 已经替你做了入口转换。
🚀 进阶:
syn的 feature 是必须显式打开的。
full:解析完整的项(ItemFn、ItemStruct、表达式等)。属性宏处理函数体必须开。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_ident在let与self.两处各用一次, 这正是「同一个变量在模板里复用」的写法。
关于 #[derive] 的边界,务必记牢两句话:
- derive 只能附加代码:用途是「为已有类型自动实现 trait」。
- derive 不能修改原类型:想在结构体里加字段、删方法,必须用属性宏。
属性宏示例:#[timed]
属性宏的签名是 fn(attr: TokenStream, item: TokenStream) -> TokenStream。 attr 是属性自身的参数(#[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。 前者灵活(能改闭包、能包装任意对象),后者零运行期开销。
关于属性宏的三个关键点:
- 它能替换原 item:返回什么都行,返回
TokenStream::new()就等于删掉这个函数。 - 必须手工保留想要的东西:
attrs、vis、sig都得自己从 AST 里取出来再放回去, 漏掉#vis会让pub fn变成私有。 #sig已经包含函数名与参数,所以不能再写一遍签名;#block已含花括号, 写{ #block }会多一层(能编译,但会触发unused_braces警告)。
⚠️ 陷阱:
#[timed]用在async fn上时,#sig带着async, 而你插入的Instant::now()与elapsed()不会与被计时的Future一起运行—— 前者在 Future 构造时就执行了。要对async fn正确计时,需要把计时代码放进async move { ... }内部的await之后,即在#block的await完成后取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 expand 用 rustc 的 -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-macro2 的 TokenStream::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 遍历代码 |
syn 的 full | 解析完整项(ItemFn、ItemStruct……)与表达式;写属性宏必开 |
syn 的 extra-traits | 给 AST 类型加 Debug、Eq、Hash;调试与做缓存时开 |
syn 的 visit / visit-mut | 生成访问者(visitor),用来遍历/改写整棵 AST |
proc-macro2 的 span-locations | 让 Span 能报出行列号(部分场景调试用) |
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 test) | cfg(test) |
debug_assertions | 开启了调试断言(默认 dev profile) | 常用于「昂贵的检查」 |
target_os / target_arch / target_family / target_env / target_pointer_width | 目标平台信息 | target_pointer_width = "64" |
unix / windows | target_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 之前运行它。 它最实际的两个用途:
- 生成源码,再用
include!包含进来(代码生成)。 - 探测环境(库版本、系统头文件、环境变量),通过
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 在宿主上编译运行,为嵌入式/异构目标生成代码时容易踩坑 |
| 可复现性受损 | 依赖宿主的文件系统、环境变量、外部工具(protoc、python……) |
| 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-analyzer 对 macro_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 语言里怎么做的」快速定位。
| 需求 | C | C++ | Java | Python | Go | Rust |
|---|---|---|---|---|---|---|
| 定义常量表达式 | #define MAX 100 | constexpr int MAX = 100; | static final int MAX = 100; | MAX = 100 | const MAX = 100 | const 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.printf | print(f"{a} {b}") | fmt.Printf | println!("{} {}", a, b)(宏) |
| 编译期代码生成 | 预处理器文本替换 | 模板实例化 / constexpr | 注解处理器 + 代码生成库 | 装饰器 / 元类 / exec | go 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 generate | include_str! / include_bytes! / build.rs |
| 标识符拼接 | ## 预处理 | 模板特化曲线救国 | APT 拼名字 | exec / getattr | 生成器写字符串 | 声明宏 $x 直接插;过程宏 format_ident! |
| 宏的卫生性 | ❌ 完全没有 | 模板基本卫生 | 不适用 | 不适用(装饰器是函数) | 不适用 | ✅ 局部变量/标签卫生 |
| 报错质量 | 预处理器错误几乎不可读 | 模板报错以冗长闻名 | APT 报错中等 | 运行期 TypeError | 生成器报错在生成的文件里 | 声明宏一般;过程宏可做到优秀(syn::Error) |
| 运行期开销 | 0 | 0(模板单态化) | 注解处理 0 / 反射有 | 装饰器有包装开销 | 0 | 0(全部编译期) |
| 调试手段 | gcc -E | 模板实例化 dump | 生成源码落盘查看 | 直接读装饰器代码 | 读生成文件 | cargo expand / -Z macro-backtrace |
三条对照结论:
- Rust 把「编译期代码生成」做成了语言一等公民,而 Java/Go/Python 都靠外部工具或运行期机制。
- Rust 是少数同时拥有「卫生的声明宏」与「能读 AST 的过程宏」的语言 (另一个是 Lisp 家族与 Scala 3)。C 的文本宏与 C++ 的模板各占一半能力。
- 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——编译器在告诉你「连序列开头都匹配不上」。
修法(按优先级):
- 换片段类型:需要语句就用
$x:stmt,需要任意 token 就用$x:tt。 - 加一条更宽的兜底臂,并给出
compile_error!明确消息。 - 检查是不是调用方写错了(很多时候确实是调用方的问题,报错本身是对的)。
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」 的陷阱框)。
syn 与 proc-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-macro2 是 syn 与 quote 之间的公共依赖,两边的大版本必须一致 (都停在 1.x)。如果你显式写了 proc-macro2 = "0.4" 而 syn = "2", 就会出现两个不同的 proc_macro2::TokenStream 类型,它们互不兼容。
修法:
- 不要手工锁
proc-macro2的小版本,交给 Cargo 统一解析。 - 在
Cargo.lock里检查是否出现两个不同 major 的proc-macro2:
powershell
cargo tree -i proc-macro2 # 看谁在依赖它、有几个版本
cargo tree -d # 看所有重复依赖- 多 crate 工作区里,各成员声明
syn = "2"/quote = "1"即可,别写=2.0.1这种精确锁。
🚀 进阶:
syn2.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) | 打印位置+表达式文本,并原样返回 |
| 占位 panic | todo!() / unimplemented!() / unreachable!() | 都会 panic,慎入公开 API |
| 声明过程宏 crate | [lib] proc-macro = true | 独立 crate,只导出过程宏 |
| 函数式过程宏 | #[proc_macro] fn f(t: TokenStream) -> TokenStream | my_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"] | 治症状不治根因 |