Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

rust 宏 入门指南

1. 什么是宏与 Rust 宏的分类

1.1 什么是宏?

在 Rust 中, 是一种编写“编写代码的代码”的方式。你可能听说过这个词:元编程。 如果普通的函数是在运行时处理数据,那么宏就是在编译时处理代码。宏允许你通过一段代码来生成另一段代码,这极大地减少了重复劳动,并且能够做到普通函数做不到的事情(例如操作语法结构本身)。

1.2 Rust 宏的分类

Rust 中的宏主要分为两大类:

  1. 声明宏
    • 定义方式:使用 macro_rules! 定义。
    • 特点:这是最常用、最基础的宏。它像“模式匹配”一样,匹配你传入的代码模式,然后展开成新的代码。它主要用于定义语法层面的替换。
    • 别名:有时也被老一辈开发者称为“示例宏”或“宏_rules 宏”(Mbe)。
  2. 过程宏
    • 定义方式:编写独立的 Rust 函数(通常是单独的包/Crate)。
    • 特点:它更像是一个函数,接收代码作为输入,经过处理后输出新的代码。它允许你操作 Rust 的抽象语法树(AST)。
    • 细分
      • 函数式过程宏:看起来像函数调用,例如 make_fn!(name, "msg")
      • 派生宏:用于结构体和枚举,自动实现 trait,例如 #[derive(Debug)]
      • 属性宏:用于标记函数或模块,例如 #[route(GET, "/")]

2. 声明宏

2.1 声明宏的概念

声明宏的核心思想是**“模式匹配”**。你可以把它想象成老式 C 语言预处理器的“查找并替换”的超级进化版。它不仅匹配文本,还匹配 Rust 的语法结构。

2.2 声明宏最基本的原理

当你调用一个宏(例如 macro_demo!())时,编译器会在编译的早期阶段进行以下操作:

  1. 解析:编译器读取宏调用传入的代码。
  2. 匹配:将传入的代码与 macro_rules! 中定义的每一个“分支”进行比对。
  3. 展开:一旦匹配成功,将宏体中右侧的代码模板生成出来,替换掉宏调用。
  4. 编译:将展开后的代码当作普通的 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::HashMapsuper::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 过程宏的概念与分类

过程宏本质上是一个TokenStreamTokenStream 的函数。 它在编译阶段运行,接收源代码的 Token 流,可以对其做任意复杂的逻辑分析、修改,最后输出新的 Token 流给编译器。 分类:

  1. 函数式过程宏
    • 形式:function_name!(input)
    • 用途:类似声明宏,但处理逻辑更灵活,能处理复杂的输入解析。
  2. 派生宏
    • 形式:#[derive(MyTrait)]
    • 用途:自动为结构体或枚举实现 Trait。这是最常用的一种。
  3. 属性宏
    • 形式:#[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 中。 步骤:

  1. 创建新 Crate: 在你的项目目录下,使用 Cargo 创建一个新的库:
    cargo new my_macro_lib --lib
    
  2. 修改 Cargo.toml: 必须声明这个库是一个过程宏库,并添加依赖 synquote
    [lib]
    proc-macro = true  # 关键配置!告诉编译器这是过程宏库
    [dependencies]
    syn = { version = "2.0", features = ["full"] } # 用于解析 TokenStream
    quote = "1.0"                                # 用于生成 TokenStream
    proc-macro2 = "1.0"                           # syn 和 quote 依赖它,通常间接引入
    
  3. 引入包: 在 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

  1. 词法分析: 编译器读取源代码字符串,把它们切割成一个个“单词”或“符号”,我们称之为 Token(标记)
    • 例如:fn main() {} 会被切分成 fn (标识符), main (标识符), ( (标点), ) (标点), { (标点), } (标点)。
    • Token Tree (标记树):Token 不是散乱排列的,它们被括号((), [], {})组织成树状结构。这对于宏来说很重要,因为宏在完全解析语法之前就能看到这些树。
  2. 语法分析: 编译器根据语法规则,将 Token 组织成一颗巨大的树,这就是 AST (抽象语法树)
    • AST 结构:它描述了代码的逻辑结构。比如,“这是一个函数定义,名字叫 main,函数体是一个块,块里有一条语句”。
    • 在 AST 中,我们知道 a + b 是一个二元表达式,而不是三个无关的词。
  3. 中间代码生成: AST 被转化为更底层的表示,常被称为 HIR (High-level IR)MIR,最后变成机器码。

3.4.2 过程宏介入了哪一步?

过程宏介入在语法分析之后,正式的 AST 构建之前(或者说是在 AST 处理的早期阶段,针对特定的 Item)。 更准确地说,编译器先将输入源代码解析为 TokenStream。过程宏接收这个 TokenStream

  • TokenStream 是什么? 它是一串 Token 序列的包装器。对于过程宏来说,它本质上是代表代码的“扁平”序列或树结构,但还没具备完整的语义信息。

3.4.3 核心组件的作用:syn 和 quote

在 Rust 生态中,我们几乎不直接操作原始的 TokenStream,而是通过两个神器:synquote

  1. syn (Syntax 的缩写):解析器
    • 作用:将 TokenStream 解析成 Rust 具体的数据结构(也就是 AST 的具体表现形式,如 DeriveInput, ItemFn)。
    • 为什么需要它?因为直接判断“第一个 token 是 fn,第二个是 main”太累了。syn 帮我们把输入变成了结构体,让我们可以用 input.ident 这种方式直接获取名字。
  2. quote:代码生成器
    • 作用:将 Rust 数据结构转换回 TokenStream
    • 为什么需要它?因为写一大堆 .push(Token) 来生成代码非常反人类。quote! 宏允许我们直接写看起来像 Rust 代码的模板,它会自动帮我们转换成 TokenStream

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");
// }