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

19.4 宏(macro)

19.4.1 什么是宏

在 Rust 中,是一组相关特性的统称:

  • macro_rules! 构建的声明宏
  • 三种过程宏:
    • 派生宏,用于 structenum,让你指定通过 derive 属性添加的代码
    • 类属性宏,可为任意条目添加自定义属性
    • 类函数宏,看起来像函数调用,并对作为参数传入的 token 进行操作

19.4.2 函数与宏的区别

  • 从本质上说,宏是生成其它代码的代码,这称为元编程
  • 函数必须在签名中声明参数的个数和类型;宏可以处理可变数量的参数。
  • 编译器会在解释代码之前展开宏。
  • 宏的定义比函数定义复杂得多,更难阅读、理解和维护。
  • 在某个文件中调用宏时,宏必须已经定义好,或已导入当前作用域。函数可以在任何位置定义并在任何位置使用。

19.4.3 使用 macro_rules! 的声明宏

声明宏有时叫做宏模板,有时叫做 macro_rules 宏,有时就直接叫做宏。

它们是 Rust 中最常见的宏形式。它们有点类似于 match 表达式的模式匹配,定义声明宏时我们使用 macro_rules!

例如:

#![allow(unused)]
fn main() {
#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}
}

这是用于创建 Vecvec! 宏的简化定义。我们逐行看一下:

  • #[macro_export] 表示只有在所属 crate 被引入作用域后,这个宏才能使用。没有这个属性,宏就不能被导入作用域。

  • macro_rules! 是声明宏的关键字。宏的名字是 vec,后面 {} 里的内容是宏体。

  • 宏体有点像 match 模式匹配,看起来像分支。实际上这里只有一个分支。虽然我们说宏体类似于 match 模式匹配,但它与 match 有本质区别:match 匹配的是模式,而宏匹配的是 Rust 代码结构。

  • ( $( $x:expr ),* ) 是它的模式,后面是代码。因为这里只有一个模式,其它任何模式都会导致编译时错误。更复杂的宏可能包含多个分支。

    首先,我们用括号包住整个模式。在宏系统中,我们用美元符号($)声明一个变量,它会包含与模式匹配的 Rust 代码。美元符号清楚地表明这是宏变量,而不是普通 Rust 变量。接下来是另一组括号,它们捕获与括号内模式匹配的值,以便在替换代码中使用。$() 里面是 $x:expr,它匹配任意 Rust 表达式,并把名字设为 $x* 表示该模式可以匹配零个或多个前面的项。

    假设我们写 let v: Vec<u32> = vec![1, 2, 3];——那么 $x 会分别匹配 123

    现在看与该分支关联的代码体:$()* 中的 temp_vec.push() 会根据模式中 $() 部分的匹配次数生成零次或多次。$x 会被替换成每个匹配到的表达式。当我们用 vec![1, 2, 3]; 调用这个宏时,替换宏调用所生成的代码是:

#![allow(unused)]
fn main() {
{
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec
}
}

关于如何编写宏的更多内容,见 Daniel Keep 撰写、Lukas Wirth 续写的 The Little Book of Rust Macros

大多数程序员只使用宏,从不编写宏,所以这里不再深入。

19.4.4 基于属性生成代码的过程宏

第二种宏是过程宏。它更像函数,或者说某种过程。过程宏把代码作为输入,处理后生成代码作为输出,而不像声明宏那样匹配模式并用其它代码替换。

过程宏有三种:

  • 派生宏
  • 类属性宏
  • 类函数宏

创建过程宏时,定义必须放在它自己的 crate 中,而且该 crate 必须使用特殊的 crate 类型。这是出于复杂的技术原因。Rust 正在努力取消这一要求,但目前仍然存在。

例如:

#![allow(unused)]
fn main() {
use proc_macro;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
}
  • some_attribute 是用来指定过程宏类型的占位符。
  • 下面是过程宏函数,它以 TokenStream 为输入,并以 TokenStream 为输出。 TokenStream 定义在 proc_macro crate 中。它表示一串 token,而这正是过程宏操作的对象:需要处理的源代码成为输入 TokenStream,宏生成的代码成为输出 TokenStream。 附加在函数上的属性决定了我们创建的是哪一种过程宏。一个 crate 可以包含多种过程宏。

派生宏

来看一个例子:

创建一个名为 hello_macro 的 crate,定义一个带有关联函数 hello_macroHelloMacro trait,并提供一个过程宏来自动实现该 trait,这样用户就可以在类型上写 #[derive(HelloMacro)],并得到默认的 hello_macro 实现。

首先,我们需要创建一个新的工作空间,并把其它项目放在这个工作空间下。创建并打开 Cargo.toml

touch Cargo.toml

在里面写入:

[workspace]

members = [
    "hello_macro",
    "hello_macro_derive",
    "pancakes",
]

先创建一个库 crate:

cargo new hello_macro --lib

hello_macro/src/lib.rs 中写入:

#![allow(unused)]
fn main() {
pub trait HelloMacro {
    fn hello_macro();
}
}

这样我们就有了一个 hello_macro trait 和一个 hello_macro 方法,但还没有任何具体实现。

然后我们可以在 main.rs 中实现该 trait,并提供实际的方法体:

use hello_macro::HelloMacro;

struct Pancakes;

impl HelloMacro for Pancakes {
    fn hello_macro() {
        println!("Hello, Macro! My name is Pancakes!");
    }
}

fn main() {
    Pancakes::hello_macro();
}

这样做可以工作,但有一个缺点:如果用户希望很多类型都使用 hello_macro,就必须为每个类型编写相似的代码。这非常繁琐。

所以我们想用过程宏来生成相关代码。另外,因为宏需要打印类型名,而这部分是可变的。例如,如果类型是 Pancakes,应打印 "Hello, Macro! My name is Pancakes!";如果是 Apple,应打印 "Hello, Macro! My name is Apple!"。因为 Rust 没有反射,所以这里只能用宏。

过程宏需要自己的库,所以我们在工作空间中再创建一个库 crate:

cargo new hello_macro_derive --lib

hello_macro_derive 是存放过程宏的 crate。把 hello_macro 宏代码放在 hello_macro_derive 中是命名惯例。

在这个 crate 的 Cargo.toml添加以下内容,不要覆盖现有内容:

[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"

我们会用到 synquote crate,所以把它们加为依赖。

然后看这个 crate 的 lib.rs 该怎么写:

#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Build a syntax tree representation of Rust code
    // that we can manipulate
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation
    impl_hello_macro(&ast)
}

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let generated = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };
    generated.into()
}
}
  • 通过 proc_macro 提供的编译器接口,我们可以读取并操作 Rust 代码。因为它内置于 Rust,所以不必把它加为依赖。
  • syn crate 用于把 Rust 代码从文本转换成我们可以进一步操作的数据结构。
  • quote crate 把 syn 产生的数据结构再转回 Rust 代码。

这三个 crate 让解析 Rust 代码容易得多。编写完整的 Rust 解析器并不简单。

简而言之,这里的逻辑是:

  • hello_macro_derive 函数解析 TokenStream
  • impl_hello_macro 转换语法树(ast

hello_macro_derive 中的代码对每个派生宏来说大体相同;不同的是 impl_hello_macro 部分。效果是:当用户在类型上写 #[derive(HelloMacro)] 时,hello_macro_derive 函数会被自动调用。

它之所以会自动调用,是因为我们在定义宏时使用了 #[proc_macro_derive(HelloMacro)],这个属性告诉 Rust 它作用于 HelloMacro trait。

这个函数首先把输入的 TokenStream 转换成我们可以解释和操作的数据结构。它把 TokenStream 传给 syn::parse,输出一个表示解析后 Rust 代码的 DeriveInput 结构体。以上面的 Pancakes 类型为例,输出大致如下:

#![allow(unused)]
fn main() {
DeriveInput {
    // ...

    ident: Ident {
        ident: "Pancakes",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}
}

它的 ident(标识符,也就是名称)是 Pancakes。其余部分不详细解释;见官方 DeriveInput 文档

impl_hello_macro 是最终生成 Rust 代码并以 TokenStream 返回的地方。

#![allow(unused)]
fn main() {
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let generated = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };
    generated.into()
}
}

我们用 ast.ident 获得一个包含被注解类型名称的 Ident 结构体实例。以 Pancakes 为例,当我们在清单中的代码上运行 impl_hello_macro 时,得到的 ident 有一个值为 "Pancakes"ident 字段。因此 name 变量包含一个 Ident 结构体实例,打印时会是字符串 "Pancakes"

quote! 宏让我们定义想要返回的 Rust 代码。因为 quote! 的结果不能直接被编译器理解,所以我们需要把它转换成 TokenStream。做法是调用 into,它接收这个中间表示并返回所需的 TokenStream 值。

quote! 宏还提供模板机制:我们可以写 #name,然后 quote! 会用 name 中的值替换它。你甚至可以用类似普通宏的方式做重复。见官方 quote 文档

stringify! 宏内置于 Rust。它接受一个 Rust 表达式,例如 1 + 2,但不会求值。相反,1 + 2 会直接被转换成字符串 "1 + 2"。这与 format!println! 不同,后两者会先求值表达式,再把结果转换成 String#name 输入可能是一个按字面打印的表达式,所以我们使用 stringify!。使用 stringify! 还可以在编译时把 #name 变成字符串字面量,从而节省分配。

写完这些后,编译这两个 crate(带包名的 cargo build 即可;注意路径,否则 Cargo 找不到 crate)。然后在同一个工作空间中创建一个二进制 crate:

cargo new pancakes

pancakes crate 的 Cargo.toml添加以下内容,不要覆盖其它内容:

[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro_derive" }

添加 hello_macrohello_macro_derive 依赖。

pancakes/src/main.rs 中写入:

use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}

这样就完成了。运行后可以看到:

Hello, Macro! My name is Pancakes!

类属性宏

类属性宏也叫属性宏。它们类似于自定义派生宏,但不是为 derive 属性生成代码,而是让你创建新属性。它们也更灵活:derive 只适用于结构体和枚举,而属性还可以应用到其它条目上,例如函数。

下面是一个类属性宏的例子:

有一个名为 route 的属性(表示路由),在使用 Web 应用框架时,它会注解一个函数。

#![allow(unused)]
fn main() {
#[route(GET, "/")]
fn index() {
}

这段代码只是片段,并不完整。它表示如果路径是 / 且方法是 Get,就会执行 index 函数。route 属性由过程宏定义,宏的函数签名如下:

#![allow(unused)]
fn main() {
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
}

这里有两个 TokenStream 参数:attr 对应 (GET, "/")item 对应函数体,也就是 index 函数。

除此之外,属性宏的工作方式几乎与派生宏完全一样。它们也需要一个 proc_macro crate,以及一个生成相应代码的函数。

类函数宏

类函数宏也叫函数宏。它们用 macro_name!(...) 这种调用形式,类似于 macro_rules! 宏,但比 macro_rules! 宏更灵活:它们接收 TokenStream 作为输入,并且像另外两种过程宏一样,在定义中使用 Rust 代码来操作这个 TokenStream

例如:

#![allow(unused)]
fn main() {
let sql = sql!(SELECT * FROM posts WHERE id=1);
}

这只是片段,并不完整。假设我们想定义一个解析 SQL 语句的宏,具体是 SELECT * FROM posts WHERE id=1。宏定义可以是:

#![allow(unused)]
fn main() {
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
}

它的签名也类似于派生宏:接收一个 TokenStream,并返回带有所需行为的 TokenStream