rust 宏 入门指南
1. 什么是宏与 Rust 宏的分类
1.1 什么是宏?
在 Rust 中,宏 是一种编写“编写代码的代码”的方式。你可能听说过这个词:元编程。 如果普通的函数是在运行时处理数据,那么宏就是在编译时处理代码。宏允许你通过一段代码来生成另一段代码,这极大地减少了重复劳动,并且能够做到普通函数做不到的事情(例如操作语法结构本身)。
1.2 Rust 宏的分类
Rust 中的宏主要分为两大类:
- 声明宏
- 定义方式:使用
macro_rules!定义。 - 特点:这是最常用、最基础的宏。它像“模式匹配”一样,匹配你传入的代码模式,然后展开成新的代码。它主要用于定义语法层面的替换。
- 别名:有时也被老一辈开发者称为“示例宏”或“宏_rules 宏”(Mbe)。
- 定义方式:使用
- 过程宏
- 定义方式:编写独立的 Rust 函数(通常是单独的包/Crate)。
- 特点:它更像是一个函数,接收代码作为输入,经过处理后输出新的代码。它允许你操作 Rust 的抽象语法树(AST)。
- 细分:
- 函数式过程宏:看起来像函数调用,例如
make_fn!(name, "msg")。 - 派生宏:用于结构体和枚举,自动实现 trait,例如
#[derive(Debug)]。 - 属性宏:用于标记函数或模块,例如
#[route(GET, "/")]。
- 函数式过程宏:看起来像函数调用,例如
2. 声明宏
2.1 声明宏的概念
声明宏的核心思想是**“模式匹配”**。你可以把它想象成老式 C 语言预处理器的“查找并替换”的超级进化版。它不仅匹配文本,还匹配 Rust 的语法结构。
2.2 声明宏最基本的原理
当你调用一个宏(例如 macro_demo!())时,编译器会在编译的早期阶段进行以下操作:
- 解析:编译器读取宏调用传入的代码。
- 匹配:将传入的代码与
macro_rules!中定义的每一个“分支”进行比对。 - 展开:一旦匹配成功,将宏体中右侧的代码模板生成出来,替换掉宏调用。
- 编译:将展开后的代码当作普通的 Rust 代码进行后续的编译。
2.3 声明宏最基本结构
一个标准的声明宏结构如下:
#![allow(unused)]
fn main() {
macro_rules! 宏的名字 {
// 分支 1: (模式) => { 展开的代码 };
(模式1) => {
// 展开代码1
};
// 分支 2
(模式2) => {
// 展开代码2
};
}
}
macro_rules!是定义宏的入口。- 括号
(){}[]在宏定义中只作为分组符号,通常使用()用于匹配模式,{}用于展开代码块。 - 分支之间使用分号
;分隔。
2.4 Fragment Specifier (片段指定符)
在声明宏的模式中,我们不能只写死一个变量名,而是需要捕获某种类型的代码片段。这时就需要用到片段指定符。它告诉编译器:“这里请匹配一个表达式”或者“这里请匹配一个标识符”。 常见指定符:
| 指定符 | 匹配内容 | 解释 | 示例 |
|---|---|---|---|
expr | 表达式 | 任何计算值的代码,如 1 + 2, func(), "hello" | x + 1 |
ident | 标识符 | 变量名、函数名、类型名等 | my_var, String |
literal | 字面量 | 常量值,如字符串、数字 | "abc", 100 |
ty | 类型 | 类型定义 | i32, Vec<u8> |
stmt | 语句 | 通常指以分号结尾的代码行或声明 | let x = 1; |
path | 路径 | 类似模块路径,如 std::collections::HashMap | super::foo |
tt | 标记树 | 单个标记或括号包围的整个树 | (a, b), { x } |
block | 代码块 | 大括号包围的语句块 | { ... } |
2.5 重复语法
如果我们想匹配多个参数(比如打印多个变量),就需要用到重复语法。 基本格式:
#![allow(unused)]
fn main() {
$()*
// 或者
$()+
}
$:代表重复的起始。(...):括号内是要重复的模式,可以包含片段指定符(如$x:expr)。*:代表重复 0 次或多次。+:代表重复 1 次或多次。?:代表重复 0 次或 1 次(可选)。
2.6 重复语法的宏展开
当宏在展开时,重复部分会像“解包”一样被展开。例如:
#![allow(unused)]
fn main() {
($($name:expr),*) => {
$(
println!("{}", $name);
)*
};
}
如果你调用 macro_demo!(a, b, c),它会展开成:
#![allow(unused)]
fn main() {
println!("{}", a);
println!("{}", b);
println!("{}", c);
}
2.7 分隔符
在重复语法中,我们经常需要指定参数之间的分隔符(比如逗号)。
格式:$($var:expr),*
- 这里的
,就是分隔符。 - 它表示匹配到的每一个
expr之间必须有一个逗号。 - 最后一个元素后面通常允许没有逗号(但在宏定义解析中,逗号是用来区分不同匹配项的)。 常见示例:
$(x),*:a, b, c(逗号分隔)$(x);*:a; b; c;(分号分隔)$(x)*:a b c(空格分隔)
2.8 代码示例讲解
让我们通过你提供的代码来详细理解声明宏的用法。
#![allow(unused)]
fn main() {
macro_rules! macro_demo {
// 1. 无参数匹配
// 如果调用 macro_demo!(),匹配到这里
() => {
println!("hello world!");
};
// 2. 有参数匹配-字面量
// $name:literal 捕获一个字面量(如字符串或数字)
($name:literal)=>{
println!("iteral match:{}", $name);
};
// 3. 有参数匹配-任意表达式
// $name:expr 捕获一个表达式(可以是函数调用、加减乘除等)
// 注意:这里我们把 $name 当作语句执行了一次 $name;
($name:expr)=>{
println!("有参数匹配-任意表达式(示例传入函数)");
$name; // 展开后直接调用传入的函数
};
// 4. 重复匹配-任意数量表达式
// $($name:expr),* 捕获 0 个或多个由逗号分隔的表达式
($($name:expr),*)=>{
println!("重复匹配-任意数量表达式:");
// $(...)* 内部是对每一个捕获到的表达式进行重复展开
$(
$name; // 对每一个传入的函数进行调用
)*
};
}
}
调用演示:
#![allow(unused)]
fn main() {
fn func1(){
println!("func1 called");
}
pub fn demo(){
// 1. 匹配第一个分支
macro_demo!();
// 输出: hello world!
// 2. 匹配第二个分支 (字面量 "this is a literal")
macro_demo!("this is a literal");
// 输出: iteral match:this is a literal
// 3. 匹配第三个分支 (表达式 func1())
macro_demo!(func1());
// 输出:
// 有参数匹配-任意表达式(示例传入函数)
// func1 called
// 4. 匹配第四个分支 (重复匹配)
macro_demo!(func1(), func2(), func3());
// 输出:
// 重复匹配-任意数量表达式:
// func1 called
// func2 called
// func3 called
}
}
3. 过程宏
过程宏比声明宏更高级,它允许你像写普通 Rust 代码一样去操作代码结构。
3.1 过程宏的概念与分类
过程宏本质上是一个从 TokenStream 到 TokenStream 的函数。
它在编译阶段运行,接收源代码的 Token 流,可以对其做任意复杂的逻辑分析、修改,最后输出新的 Token 流给编译器。
分类:
- 函数式过程宏
- 形式:
function_name!(input) - 用途:类似声明宏,但处理逻辑更灵活,能处理复杂的输入解析。
- 形式:
- 派生宏
- 形式:
#[derive(MyTrait)] - 用途:自动为结构体或枚举实现 Trait。这是最常用的一种。
- 形式:
- 属性宏
- 形式:
#[my_attribute]或#[my_attribute(args)] - 形式:可以作用在函数、模块、结构体等上面,用于修改或增强其功能。
- 形式:
3.2 过程宏的基本语法结构
定义过程宏的函数签名非常固定:
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
#[proc_macro] // 函数式宏注解
pub fn my_macro(input: TokenStream) -> TokenStream {
// 1. 解析 input
// 2. 处理逻辑
// 3. 返回新的 TokenStream
}
}
input:编译器传给你的原始代码(Token 流形式)。TokenStream:这是过程宏唯一能理解和操作的数据结构。
3.3 如何写一个过程宏?
过程宏不能写在普通的 src/main.rs 或普通库的 src/lib.rs 中,它们必须单独放在一个特殊的 Proc Macro Crate 中。
步骤:
- 创建新 Crate:
在你的项目目录下,使用 Cargo 创建一个新的库:
cargo new my_macro_lib --lib - 修改
Cargo.toml: 必须声明这个库是一个过程宏库,并添加依赖syn和quote。[lib] proc-macro = true # 关键配置!告诉编译器这是过程宏库 [dependencies] syn = { version = "2.0", features = ["full"] } # 用于解析 TokenStream quote = "1.0" # 用于生成 TokenStream proc-macro2 = "1.0" # syn 和 quote 依赖它,通常间接引入 - 引入包:
在
src/lib.rs中:#![allow(unused)] fn main() { use proc_macro::TokenStream; use syn::{parse_macro_input, DeriveInput}; // 常用引入 use quote::quote; }
3.4 过程宏最基本的原理与编译流程
要理解过程宏,必须先简单了解 Rust 编译器是如何把你的源代码变成可执行程序的。
3.4.1 编译流程简介与 Token/AST
- 词法分析:
编译器读取源代码字符串,把它们切割成一个个“单词”或“符号”,我们称之为 Token(标记)。
- 例如:
fn main() {}会被切分成fn(标识符),main(标识符),((标点),)(标点),{(标点),}(标点)。 - Token Tree (标记树):Token 不是散乱排列的,它们被括号(
(),[],{})组织成树状结构。这对于宏来说很重要,因为宏在完全解析语法之前就能看到这些树。
- 例如:
- 语法分析:
编译器根据语法规则,将 Token 组织成一颗巨大的树,这就是 AST (抽象语法树)。
- AST 结构:它描述了代码的逻辑结构。比如,“这是一个函数定义,名字叫 main,函数体是一个块,块里有一条语句”。
- 在 AST 中,我们知道
a + b是一个二元表达式,而不是三个无关的词。
- 中间代码生成: AST 被转化为更底层的表示,常被称为 HIR (High-level IR) 或 MIR,最后变成机器码。
3.4.2 过程宏介入了哪一步?
过程宏介入在语法分析之后,正式的 AST 构建之前(或者说是在 AST 处理的早期阶段,针对特定的 Item)。
更准确地说,编译器先将输入源代码解析为 TokenStream。过程宏接收这个 TokenStream。
- TokenStream 是什么? 它是一串 Token 序列的包装器。对于过程宏来说,它本质上是代表代码的“扁平”序列或树结构,但还没具备完整的语义信息。
3.4.3 核心组件的作用:syn 和 quote
在 Rust 生态中,我们几乎不直接操作原始的 TokenStream,而是通过两个神器:syn 和 quote。
syn(Syntax 的缩写):解析器- 作用:将
TokenStream解析成 Rust 具体的数据结构(也就是 AST 的具体表现形式,如DeriveInput,ItemFn)。 - 为什么需要它?因为直接判断“第一个 token 是 fn,第二个是 main”太累了。
syn帮我们把输入变成了结构体,让我们可以用input.ident这种方式直接获取名字。
- 作用:将
quote:代码生成器- 作用:将 Rust 数据结构转换回
TokenStream。 - 为什么需要它?因为写一大堆
.push(Token)来生成代码非常反人类。quote!宏允许我们直接写看起来像 Rust 代码的模板,它会自动帮我们转换成TokenStream。
- 作用:将 Rust 数据结构转换回
3.4.4 它们是怎么配合的?
流程如下:
Input (TokenStream) –> [ syn ] –> Rust Struct (AST) –> [ 你的逻辑处理 ] –> 修改后的 Rust Struct –> [ quote ] –> Output (TokenStream)
为什么input和output都是tokenstream,却要把toekenstream解析成ast再去操作再生成tokenstream
- 因为 TokenStream 本质上只是一个扁平、无语义的符号序列,就像散乱的砖块和字母,很难直接进行处理。
- 而AST(抽象语法树)最大的特点是它拥有层级结构和明确的语义分类。这种树状结构天然地就像一个个容器,能够根据代码的逻辑关系(如“谁在谁里面”、“这是什么类型的代码”),自动将散乱的数据归类并放入对应的结构层级中。
- 通过 syn 解析成 AST 后,我们就不需要手动去匹配括号或计算优先级,代码中复杂的嵌套和关联关系都已经被整理成了清晰的结构,数据自然就“各就各位”了,这样我们后续的修改和生成逻辑才能直观地进行。
3.5 动手写过程宏
接下来我们详解你提供的三种过程宏代码。假设你已经创建了上面的 my_macro_lib。
3.5.1 函数式过程宏
目标:调用 make_fn!(hello, "world"); 自动生成一个名为 hello 的函数。
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use quote::quote;
use syn::{
parse::{Parse, ParseStream}, // 用于自定义解析逻辑
parse_macro_input,
Ident, // 标识符类型
LitStr, // 字符串字面量类型
Token, // 标点符号类型
};
// 1. 定义一个结构体来保存我们解析出来的数据
struct MakeFnInput {
name: Ident, // 对应 "hello"
_comma: Token![,], // 对应 "," (下划线前缀表示我们要读取它但不使用它)
message: LitStr, // 对应 "world"
}
// 2. 为这个结构体实现 Parse trait,告诉 syn 如何解析 TokenStream
impl Parse for MakeFnInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
Ok(Self {
name: input.parse()?, // 尝试解析一个标识符
_comma: input.parse()?, // 尝试解析一个逗号
message: input.parse()?, // 尝试解析一个字符串
})
}
}
// 3. 定义宏入口函数
#[proc_macro]
pub fn make_fn(input: TokenStream) -> TokenStream {
// 使用 parse_macro_input! 宏,自动调用我们上面实现的 Parse 逻辑
// 如果解析失败,会直接报错并优雅退出
let MakeFnInput { name, message, .. } =
parse_macro_input!(input as MakeFnInput);
// 4. 使用 quote! 生成我们要返回的代码
quote! {
// #name 会把变量 name 的值填进去
fn #name() {
println!("{}", #message);
}
}
.into() // 5. 将 quote 生成的代码转换为 TokenStream 返回
}
}
使用方式:
// 在另一个 crate 的 main.rs 中
use my_macro_lib::make_fn;
make_fn!(hello, "world"); // 这一行被展开成了函数定义
fn main() {
hello(); // 直接调用生成的函数
}
3.5.2 Derive 过程宏
目标:使用 #[derive(HelloDerive)] 自动为结构体实现 Hello trait。
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
// 定义我们的宏类型
#[proc_macro_derive(HelloDerive)]
pub fn hello_derive(input: TokenStream) -> TokenStream {
// 1. 解析输入
// DeriveInput 是 syn 提供的现成结构体,专门用于解析结构体/枚举的输入
let input = parse_macro_input!(input as DeriveInput);
// 获取结构体/枚举的名字 (例如 struct MyStruct)
let name = input.ident;
// 获取泛型信息 (例如 struct MyStruct<T>)
let generics = input.generics;
// 将泛型拆分为三部分,用于在 impl 块中正确引用
let (impl_generics, ty_generics, where_clause) =
generics.split_for_impl();
// 2. 生成代码
quote! {
// 为 #name 实现Hello trait
impl #impl_generics Hello for #name #ty_generics #where_clause {
fn hello(&self) {
// stringify! 是 Rust 内置宏,在编译时把代码转为字符串
println!("Hello from {}", stringify!(#name));
}
}
}
.into()
}
}
使用方式:
// 注意:Hello trait 需要在某个地方定义(通常是过程宏库的同名依赖中,或者用户自己定义)
trait Hello {
fn hello(&self);
}
#[derive(HelloDerive)]
struct MyStruct;
fn main() {
let s = MyStruct;
s.hello(); // 输出: Hello from MyStruct
}
3.5.3 属性过程宏
目标:使用 #[log_call] 自动给函数添加打印日志的功能。
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use quote::quote;
use syn::{
parse_macro_input,
ItemFn, // 代表一个函数的 AST 结构
Token,
};
#[proc_macro_attribute]
pub fn log_call(_attr: TokenStream, item: TokenStream) -> TokenStream {
// 1. 解析输入的 item (即被标记的那个函数)
let input_fn = parse_macro_input!(item as ItemFn);
// 2. 提取函数的各个部分
let attrs = input_fn.attrs; // 函数上原本的其他属性 (如 #[inline])
let vis = input_fn.vis; // 可见性
let sig = input_fn.sig; // 函数签名 (fn name(args) -> ret)
let block = input_fn.block; // 函数体代码块 { ... }
// 获取函数名
let fn_name = &sig.ident;
// 3. 生成新的函数
// 我们不修改原函数逻辑,而是把它包裹在一个新的代码块里
quote! {
// 把原来的属性放回去
#(#attrs)*
#vis #sig {
// 在函数体最开始插入日志打印
println!("calling {}", stringify!(#fn_name));
// 执行原本的函数体
#block
}
}
.into()
}
}
使用方式:
use my_macro_lib::log_call;
#[log_call]
fn my_function() {
println!("Inside function");
}
fn main() {
my_function();
}
// 展开后的效果类似于:
// fn my_function() {
// println!("calling my_function");
// println!("Inside function");
// }