宏
在本书中,我们一直在使用像 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!
宏创建具有特定值的新 vector。例如,以下宏创建一个包含三个整数的新 vector
#![allow(unused)] fn main() { let v: Vec<u32> = vec![1, 2, 3]; }
我们也可以使用 vec!
宏来创建一个包含两个整数的 vector 或一个包含五个字符串切片的 vector。我们将无法使用函数执行相同的操作,因为我们事先不知道值的数量或类型。
列表 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
}
};
}
列表 19-28:vec!
宏定义的简化版本
注意:标准库中 vec!
宏的实际定义包括预先分配正确内存量的代码。该代码是一种优化,我们在此处不包含该代码以使示例更简单。
#[macro_export]
注解指示,每当宏定义所在的 crate 引入作用域时,都应使此宏可用。如果没有此注解,则无法将宏引入作用域。
然后,我们使用 macro_rules!
和我们正在定义的宏的名称(不带感叹号)开始宏定义。名称(在本例中为 vec
)后跟大括号,表示宏定义的主体。
vec!
主体中的结构类似于 match
表达式的结构。这里我们有一个 arm,其模式为 ( $( $x:expr ),* )
,后跟 =>
和与此模式关联的代码块。如果模式匹配,则将发出关联的代码块。鉴于这是此宏中唯一的模式,因此只有一种有效的匹配方式;任何其他模式都将导致错误。更复杂的宏将具有多个 arm。
宏定义中的有效模式语法与第 19 章中介绍的模式语法不同,因为宏模式与 Rust 代码结构而不是值匹配。例如,声明式宏可以匹配表达式 (expr
)、类型 (ty
) 甚至整个项 (item
)。让我们逐步了解列表 19-28 中模式部分的含义;有关完整的宏模式语法,请参阅 Rust 参考。
首先,我们使用一组括号来包含整个模式。我们使用美元符号 ($
) 在宏系统中声明一个变量,该变量将包含与模式匹配的 Rust 代码。美元符号清楚地表明这是一个宏变量,而不是常规 Rust 变量。接下来是一组括号,它捕获与括号内模式匹配的值,以便在替换代码中使用。在 $()
内是 $x:expr
,它匹配任何 Rust 表达式,并将表达式命名为 $x
。
$()
后面的逗号表示,在与 $()
中代码匹配的代码之后,可以选择性地出现文字逗号分隔符。*
指定模式匹配零个或多个在 *
之前的内容。
当我们使用 vec![1, 2, 3];
调用此宏时,$x
模式与三个表达式 1
、2
和 3
匹配三次。
现在让我们看一下与此 arm 关联的代码主体中的模式:在 $()*
中的 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
}
我们已经定义了一个宏,它可以接受任意数量的任意类型参数,并且可以生成代码来创建一个包含指定元素的 vector。
要了解有关如何编写宏的更多信息,请查阅在线文档或其他资源,例如 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 {
}
列表 19-29:定义过程式宏的示例
定义过程式宏的函数接受 TokenStream
作为输入,并生成 TokenStream
作为输出。TokenStream
类型由 Rust 附带的 proc_macro
crate 定义,表示标记序列。这是宏的核心:宏正在操作的源代码构成了输入 TokenStream
,而宏生成的代码是输出 TokenStream
。该函数还附加了一个属性,用于指定我们正在创建的过程式宏的类型。我们可以在同一个 crate 中拥有多种类型的过程式宏。
让我们看看不同类型的过程式宏。我们将从自定义派生宏开始,然后解释使其他形式不同的细微差异。
如何编写自定义 derive
宏
让我们创建一个名为 hello_macro
的 crate,它定义了一个名为 HelloMacro
的 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();
}
列表 19-30:我们的 crate 的用户在使用我们的过程式宏时将能够编写的代码
当我们完成时,此代码将打印 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)
}
列表 19-31:大多数过程式宏 crate 将需要用来处理 Rust 代码的代码
请注意,我们已将代码拆分为 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
函数,并指定了名称 HelloMacro
,该名称与我们的 trait 名称匹配;这是大多数过程式宏遵循的约定。
hello_macro_derive
函数首先将来自 TokenStream
的 input
转换为我们可以随后解释和执行操作的数据结构。这就是 syn
发挥作用的地方。syn
中的 parse
函数接受 TokenStream
并返回 DeriveInput
结构,该结构表示已解析的 Rust 代码。列表 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
)
}
)
}
列表 19-32:当解析在列表 19-30 中具有宏属性的代码时,我们获得的 DeriveInput
实例
此结构的字段表明,我们解析的 Rust 代码是一个单元结构,其 ident
(标识符,表示名称)为 Pancakes
。此结构上还有更多字段用于描述各种 Rust 代码;有关更多信息,请查看 syn
文档中的 DeriveInput
。
很快我们将定义 impl_hello_macro
函数,我们将在其中构建我们想要包含的新 Rust 代码。但在我们这样做之前,请注意,我们的派生宏的输出也是 TokenStream
。返回的 TokenStream
被添加到我们的 crate 用户编写的代码中,因此当他们编译他们的 crate 时,他们将获得我们在修改后的 TokenStream
中提供的额外功能。
您可能已经注意到,我们正在调用 unwrap
以便在对 syn::parse
函数的调用在此处失败时导致 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()
}
列表 19-33:使用已解析的 Rust 代码实现 HelloMacro
trait
我们使用 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
crate 的文档以获得全面的介绍。
我们希望我们的过程式宏为用户注解的类型生成我们的 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
中都成功完成。让我们将这些 crate 连接到列表 19-30 中的代码,以查看过程式宏的实际效果!使用 cargo new pancakes
在您的 projects 目录中创建一个新的二进制项目。我们需要将 hello_macro
和 hello_macro_derive
作为依赖项添加到 pancakes
crate 的 Cargo.toml 中。如果您要将您的 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
crate 实现它;#[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
crate 类型的 crate,并实现一个生成您想要的代码的函数!
类似函数的宏
类似函数的宏定义了看起来像函数调用的宏。与 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 {
这个定义类似于自定义派生宏的签名:我们接收括号内的令牌(tokens)并返回我们想要生成的代码。
总结
呼!现在你的工具箱中拥有了一些你可能不常使用的 Rust 功能,但你会知道它们在非常特殊的情况下是可用的。我们已经介绍了几个复杂的主题,这样当你在错误消息建议或在其他人的代码中遇到它们时,你将能够识别这些概念和语法。使用本章作为参考来指导你找到解决方案。
接下来,我们将把我们在整本书中讨论的所有内容付诸实践,再做一个项目!