宏
我们在本书中一直使用着像 println!
这样的宏,但我们还没有完全探讨过宏是什么以及它是如何工作的。宏 一词指的是 Rust 中的一系列功能:使用 macro_rules!
的声明性宏和三种过程性宏
- 自定义
#[derive]
宏,用于指定使用在结构体和枚举上的derive
属性添加的代码 - 类似属性的宏,用于定义可在任何项上使用的自定义属性
- 类似函数的宏,看起来像函数调用,但操作的是指定为其参数的标记
我们将依次讨论这些内容,但首先,让我们看看为什么在已经拥有函数的情况下还需要宏。
宏与函数的区别
从根本上说,宏是一种编写代码来编写其他代码的方式,这被称为元编程。在附录 C 中,我们讨论了 derive
属性,它可以为你生成各种 Trait 的实现。我们还在整本书中使用了 println!
和 vec!
宏。所有这些宏都会展开以生成比手动编写的代码更多的代码。
元编程对于减少必须编写和维护的代码量非常有用,这也是函数的作用之一。但是,宏具有一些函数所没有的额外功能。
函数签名必须声明函数具有的参数数量和类型。另一方面,宏可以接受可变数量的参数:我们可以使用一个参数调用 println!("hello")
,或者使用两个参数调用 println!("hello {}", name)
。此外,宏在编译器解释代码含义之前就已经展开,因此宏可以,例如,为给定类型实现一个 Trait。函数则不能,因为它是在运行时调用的,而 Trait 需要在编译时实现。
实现宏而不是函数的缺点是,宏定义比函数定义更复杂,因为你正在编写的是编写 Rust 代码的 Rust 代码。由于这种间接性,宏定义通常比函数定义更难阅读、理解和维护。
宏和函数的另一个重要区别是,你必须在文件中调用宏之前定义宏或将其引入作用域,而函数则可以在任何地方定义和调用。
使用 macro_rules!
进行通用元编程的声明性宏
Rust 中使用最广泛的宏形式是*声明性宏*。它们有时也被称为“示例宏”、“`macro_rules!` 宏”或仅仅是“宏”。从本质上讲,声明性宏允许你编写类似于 Rust `match` 表达式的代码。正如第 6 章所讨论的,`match` 表达式是一种控制结构,它接受一个表达式,将表达式的结果值与模式进行比较,然后运行与匹配模式相关联的代码。宏也比较值与与特定代码相关联的模式:在这种情况下,值是传递给宏的字面 Rust 源代码;模式与该源代码的结构进行比较;与每个模式相关联的代码在匹配时替换传递给宏的代码。这一切都发生在编译期间。
要定义宏,可以使用 `macro_rules!` 结构。让我们通过查看 `vec!` 宏的定义来探索如何使用 `macro_rules!`。第 8 章介绍了如何使用 `vec!` 宏创建包含特定值的新向量。例如,以下宏创建了一个包含三个整数的新向量
#![allow(unused)] fn main() { let v: Vec<u32> = vec![1, 2, 3]; }
我们也可以使用 `vec!` 宏来创建一个包含两个整数的向量或一个包含五个字符串切片的向量。我们不能使用函数来做同样的事情,因为我们事先不知道值的数目或类型。
清单 19-28 展示了 `vec!` 宏的简化定义。
文件名:src/lib.rs
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
注意:标准库中 `vec!` 宏的实际定义包含预先分配正确内存量的代码。该代码是一种优化,为了使示例更简单,我们在此不包含该代码。
`#[macro_export]` 注解表示每当定义宏的 crate 被引入作用域时,都应该使该宏可用。如果没有此注解,则无法将宏引入作用域。
然后,我们使用 `macro_rules!` 和我们正在定义的宏的名称(*不带*感叹号)来开始宏定义。名称(在本例中为 `vec`)后面跟着一对花括号,表示宏定义的主体。
`vec!` 主体中的结构类似于 `match` 表达式的结构。这里我们有一个带有模式 `( $( $x:expr ),* )` 的分支,后面跟着 `=>` 和与此模式关联的代码块。如果模式匹配,则将发出关联的代码块。鉴于这是此宏中唯一的模式,因此只有一种有效的匹配方式;任何其他模式都将导致错误。更复杂的宏将具有多个分支。
宏定义中的有效模式语法与第 18 章中介绍的模式语法不同,因为宏模式是针对 Rust 代码结构而不是值进行匹配的。让我们逐步了解清单 19-28 中的模式片段的含义;有关完整的宏模式语法,请参阅Rust 参考。
首先,我们使用一组括号来包含整个模式。我们使用美元符号 (`$`) 在宏系统中声明一个变量,该变量将包含与模式匹配的 Rust 代码。美元符号表明这是一个宏变量,而不是常规的 Rust 变量。接下来是一组括号,用于捕获与括号内的模式匹配的值,以便在替换代码中使用。在 `$()` 中是 `$x:expr`,它匹配任何 Rust 表达式,并为该表达式指定名称 `$x`。
`$()` 后面的逗号表示在与 `$()` 中的代码匹配的代码之后,可以选择性地出现一个文字逗号分隔符。`*` 指定模式匹配零个或多个 `*` 之前的任何内容。
当我们使用 `vec![1, 2, 3];` 调用此宏时,`$x` 模式将与三个表达式 `1`、`2` 和 `3` 匹配三次。
现在,让我们看一下与此分支关联的代码主体中的模式:`temp_vec.push()` 在 `$()*` 中,为与模式中 `$()` 匹配的每个部分生成零次或多次,具体取决于模式匹配的次数。`$x` 将被每个匹配的表达式替换。当我们使用 `vec![1, 2, 3];` 调用此宏时,替换此宏调用的生成的代码将如下所示
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
}
我们定义了一个宏,它可以接受任意数量的任意类型的参数,并且可以生成代码来创建一个包含指定元素的向量。
要详细了解如何编写宏,请查阅在线文档或其他资源,例如 Daniel Keep 创建并由 Lukas Wirth 继续维护的“Rust 宏小书”。
用于从属性生成代码的过程宏
第二种形式的宏是*过程宏*,它的作用更像函数(并且是一种过程)。过程宏接受一些代码作为输入,对该代码进行操作,并生成一些代码作为输出,而不是像声明性宏那样匹配模式并用其他代码替换代码。过程宏有三种:自定义派生、类属性和类函数,它们的工作方式都类似。
创建过程宏时,定义必须位于具有特殊 crate 类型的自己的 crate 中。这是由于复杂的技术原因,我们希望将来能够消除这些原因。在清单 19-29 中,我们展示了如何定义过程宏,其中 `some_attribute` 是使用特定宏变体的占位符。
文件名:src/lib.rs
use proc_macro;
#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
定义过程宏的函数接受一个 `TokenStream` 作为输入,并生成一个 `TokenStream` 作为输出。`TokenStream` 类型由 Rust 附带的 `proc_macro` crate 定义,表示一系列标记。这是宏的核心:宏操作的源代码构成输入 `TokenStream`,宏生成的代码是输出 `TokenStream`。该函数还附加了一个属性,该属性指定了我们正在创建哪种过程宏。我们可以在同一个 crate 中拥有多种过程宏。
让我们看一下不同类型的过程宏。我们将从自定义派生宏开始,然后解释使其他形式不同的细微差异。
如何编写自定义 `derive` 宏
让我们创建一个名为 `hello_macro` 的 crate,它定义一个名为 `HelloMacro` 的 trait,该 trait 带有一个名为 `hello_macro` 的关联函数。我们不会让用户为他们的每种类型都实现 `HelloMacro` trait,而是提供一个过程宏,以便用户可以使用 `#[derive(HelloMacro)]` 注解他们的类型,以获取 `hello_macro` 函数的默认实现。默认实现将打印 `Hello, Macro! My name is TypeName!`,其中 `TypeName` 是定义此 trait 的类型的名称。换句话说,我们将编写一个 crate,使其他程序员能够使用我们的 crate 编写清单 19-30 中的代码。
文件名: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!`。第一步是创建一个新的库 crate,如下所示
$ cargo new hello_macro --lib
接下来,我们将定义 `HelloMacro` trait 及其关联函数
文件名:src/lib.rs
pub trait HelloMacro {
fn hello_macro();
}
我们有一个 trait 及其函数。此时,我们的 crate 用户可以实现该 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` 一起使用的每种类型编写实现块;我们希望避免他们必须做这项工作。
此外,我们还不能为 `hello_macro` 函数提供默认实现,该实现将打印实现该 trait 的类型的名称:Rust 没有反射功能,因此它无法在运行时查找类型的名称。我们需要一个宏在编译时生成代码。
下一步是定义过程宏。在撰写本文时,过程宏需要位于自己的 crate 中。最终,此限制可能会被解除。结构化 crate 和宏 crate 的约定如下:对于名为 `foo` 的 crate,自定义派生过程宏 crate 称为 `foo_derive`。让我们在 `hello_macro` 项目中启动一个名为 `hello_macro_derive` 的新 crate
$ cargo new hello_macro_derive --lib
我们的两个 crate 紧密相关,因此我们在 `hello_macro` crate 的目录中创建过程宏 crate。如果我们更改 `hello_macro` 中的 trait 定义,我们也必须更改 `hello_macro_derive` 中过程宏的实现。这两个 crate 需要单独发布,使用这些 crate 的程序员需要将它们都添加为依赖项,并将它们都引入作用域。我们可以改为让 `hello_macro` crate 使用 `hello_macro_derive` 作为依赖项并重新导出过程宏代码。但是,我们构建项目的方式使得程序员即使不需要 `derive` 功能也可以使用 `hello_macro`。
我们需要将 `hello_macro_derive` crate 声明为过程宏 crate。我们还需要来自 `syn` 和 `quote` crate 的功能,您稍后会看到,因此我们需要将它们添加为依赖项。将以下内容添加到 `hello_macro_derive` 的 *Cargo.toml* 文件中
文件名:hello_macro_derive/Cargo.toml
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"
要开始定义过程宏,请将清单 19-31 中的代码放入 `hello_macro_derive` crate 的 *src/lib.rs* 文件中。请注意,在我们添加 `impl_hello_macro` 函数的定义之前,此代码将无法编译。
文件名:hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree
// that we can manipulate
let ast = syn::parse(input).unwrap();
// Build the trait implementation
impl_hello_macro(&ast)
}
请注意,我们将代码拆分为 `hello_macro_derive` 函数(负责解析 `TokenStream`)和 `impl_hello_macro` 函数(负责转换语法树):这使得编写过程宏更加方便。外部函数(在本例中为 `hello_macro_derive`)中的代码对于您看到或创建的几乎所有过程宏 crate 都是相同的。您在内部函数(在本例中为 `impl_hello_macro`)的主体中指定的代码将根据您的过程宏的目的而有所不同。
我们引入了三个新的 crate:`proc_macro`、`syn` 和 `quote`。`proc_macro` crate 随 Rust 一起提供,因此我们不需要将其添加到 *Cargo.toml* 中的依赖项中。`proc_macro` crate 是编译器的 API,它允许我们从代码中读取和操作 Rust 代码。
`syn` crate 将字符串形式的 Rust 代码解析为我们可以对其执行操作的数据结构。`quote` crate 将 `syn` 数据结构转换回 Rust 代码。这些 crate 使解析我们可能想要处理的任何类型的 Rust 代码变得更加容易:为 Rust 代码编写完整的解析器并非易事。
当我们库的用户在类型上指定 `#[derive(HelloMacro)]` 时,将调用 `hello_macro_derive` 函数。这是可能的,因为我们在这里使用 `proc_macro_derive` 注解了 `hello_macro_derive` 函数,并指定了与我们的 trait 名称匹配的名称 `HelloMacro`;这是大多数过程宏遵循的约定。
`hello_macro_derive` 函数首先将 `input` 从 `TokenStream` 转换为我们可以解释和操作的数据结构。这就是 `syn` 发挥作用的地方。`syn` 中的 `parse` 函数接受一个 `TokenStream` 并返回一个表示已解析的 Rust 代码的 `DeriveInput` 结构。清单 19-32 显示了我们从解析 `struct Pancakes;` 字符串中获得的 `DeriveInput` 结构的相关部分
DeriveInput {
// --snip--
ident: Ident {
ident: "Pancakes",
span: #0 bytes(95..103)
},
data: Struct(
DataStruct {
struct_token: Struct,
fields: Unit,
semi_token: Some(
Semi
)
}
)
}
这个结构体的字段表明,我们解析的 Rust 代码是一个单元结构体,其 ident
(标识符,表示名称)为 Pancakes
。这个结构体还有更多字段用于描述各种 Rust 代码;查看 syn
文档中关于 DeriveInput
的内容 了解更多信息。
很快我们就会定义 impl_hello_macro
函数,我们将在其中构建想要包含的新 Rust 代码。但在我们这样做之前,请注意,派生宏的输出也是一个 TokenStream
。返回的 TokenStream
会被添加到我们的库用户编写的代码中,因此当他们编译他们的库时,他们将获得我们在修改后的 TokenStream
中提供的额外功能。
你可能已经注意到,如果对 syn::parse
函数的调用失败,我们在这里调用 unwrap
会导致 hello_macro_derive
函数 panic。我们的过程宏必须在出错时 panic,因为 proc_macro_derive
函数必须返回 TokenStream
而不是 Result
,以符合过程宏 API。我们通过使用 unwrap
简化了这个例子;在生产代码中,你应该使用 panic!
或 expect
提供更具体的错误信息。
现在我们有了将带注释的 Rust 代码从 TokenStream
转换为 DeriveInput
实例的代码,让我们生成在带注释的类型上实现 HelloMacro
trait 的代码,如代码清单 19-33 所示。
文件名:hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree
// 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 gen = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
gen.into()
}
我们使用 ast.ident
获取一个包含带注释类型的名称(标识符)的 Ident
结构体实例。代码清单 19-32 中的结构体显示,当我们在代码清单 19-30 的代码上运行 impl_hello_macro
函数时,我们得到的 ident
的 ident
字段的值将为 "Pancakes"
。因此,代码清单 19-33 中的 name
变量将包含一个 Ident
结构体实例,当打印时,它将是字符串 "Pancakes"
,即代码清单 19-30 中结构体的名称。
quote!
宏允许我们定义想要返回的 Rust 代码。编译器期望的结果与 quote!
宏执行的直接结果不同,因此我们需要将其转换为 TokenStream
。我们通过调用 into
方法来做到这一点,该方法会消耗这个中间表示形式并返回所需 TokenStream
类型的值。
quote!
宏还提供了一些非常酷的模板机制:我们可以输入 #name
,quote!
会将其替换为变量 name
中的值。你甚至可以像普通宏那样进行一些重复操作。查看 quote
库的文档 以获得全面的介绍。
我们希望我们的过程宏为用户注释的类型生成 HelloMacro
trait 的实现,我们可以使用 #name
来获取它。trait 实现有一个函数 hello_macro
,其主体包含我们想要提供的功能:打印 Hello, Macro! My name is
,然后是带注释类型的名称。
这里使用的 stringify!
宏是 Rust 内置的。它接受一个 Rust 表达式,例如 1 + 2
,并在编译时将表达式转换为字符串字面量,例如 "1 + 2"
。这与 format!
或 println!
不同,后者是先计算表达式,然后将结果转换为 String
的宏。#name
输入有可能是一个要按字面打印的表达式,因此我们使用 stringify!
。使用 stringify!
还可以通过在编译时将 #name
转换为字符串字面量来节省内存分配。
此时,cargo build
应该在 hello_macro
和 hello_macro_derive
中都成功完成。让我们将这些库连接到代码清单 19-30 中的代码,以查看过程宏的实际效果!使用 cargo new pancakes
在你的 projects 目录中创建一个新的二进制项目。我们需要在 pancakes
库的 Cargo.toml 中添加 hello_macro
和 hello_macro_derive
作为依赖项。如果你要将你的 hello_macro
和 hello_macro_derive
版本发布到 crates.io,它们将是常规依赖项;如果不是,你可以将它们指定为 path
依赖项,如下所示
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }
将代码清单 19-30 中的代码放入 src/main.rs,并运行 cargo run
:它应该打印 Hello, Macro! My name is Pancakes!
。来自过程宏的 HelloMacro
trait 的实现被包含在内,而 pancakes
库不需要实现它;#[derive(HelloMacro)]
添加了 trait 实现。
接下来,让我们探讨其他类型的过程宏与自定义派生宏的区别。
类属性宏
类属性宏类似于自定义派生宏,但它们不是为 derive
属性生成代码,而是允许你创建新的属性。它们也更加灵活:derive
只能用于结构体和枚举;属性可以应用于其他项,例如函数。下面是一个使用类属性宏的例子:假设你有一个名为 route
的属性,用于在使用 Web 应用程序框架时注释函数
#[route(GET, "/")]
fn index() {
这个 #[route]
属性将由框架定义为一个过程宏。宏定义函数的签名如下所示
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
这里,我们有两个类型为 TokenStream
的参数。第一个参数用于属性的内容:GET, "/"
部分。第二个参数是属性附加到的项的主体:在本例中,是 fn index() {}
和函数主体的其余部分。
除此之外,类属性宏的工作方式与自定义派生宏相同:你创建一个类型为 proc-macro
的库,并实现一个生成所需代码的函数!
类函数宏
类函数宏定义的宏看起来像函数调用。与 macro_rules!
宏类似,它们比函数更灵活;例如,它们可以接受未知数量的参数。但是,macro_rules!
宏只能使用我们在 “使用 macro_rules!
进行声明式宏编程” 部分中讨论的类似匹配的语法来定义。类函数宏接受一个 TokenStream
参数,它们的定义使用 Rust 代码来操作该 TokenStream
,就像其他两种类型的过程宏一样。类函数宏的一个例子是 sql!
宏,它可以像这样被调用
let sql = sql!(SELECT * FROM posts WHERE id=1);
这个宏将解析其中的 SQL 语句并检查其语法是否正确,这比 macro_rules!
宏所能做的处理要复杂得多。sql!
宏的定义如下
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
这个定义类似于自定义派生宏的签名:我们接收括号内的标记,并返回我们想要生成的代码。
总结
呼!现在你的工具箱中有一些你可能不会经常使用的 Rust 功能,但你会知道它们在非常特殊的情况下是可用的。我们介绍了几个复杂的主题,这样当你遇到错误信息建议或其他人的代码中出现这些主题时,你就能识别出这些概念和语法。将本章作为参考,指导你找到解决方案。
接下来,我们将把我们在本书中讨论的所有内容付诸实践,再做一个项目!