宏
我们在这本书中使用了像 println! 这样的宏,但还没有完全探讨宏是什么以及它是如何工作的。术语 宏 指的是 Rust 中的一组特性——声明式宏使用 macro_rules! 和三种过程宏:
- 自定义
#[derive]宏,用于指定通过derive属性添加到结构体和枚举上的代码 - 定义可用于任何项的自定义属性的属性宏
- 看起来像函数调用但操作其参数指定的标记的函数式宏
我们将逐一讨论这些内容,但首先,让我们看看为什么在已经有函数的情况下,我们还需要宏。
宏和函数之间的区别
从根本上说,宏是一种编写生成其他代码的代码的方式,这被称为元编程。在附录C中,我们讨论了derive属性,它为你生成各种特征的实现。我们还在书中使用了println!和vec!宏。所有这些宏都会扩展以生成比你手动编写的代码更多的代码。
元编程有助于减少你需要编写和维护的代码量,这也是函数的作用之一。然而,宏具有一些函数不具备的额外功能。
函数签名必须声明函数的参数数量和类型。另一方面,宏可以接受可变数量的参数:我们可以用一个参数调用 println!("hello") 或用两个参数调用 println!("hello {}", name)。此外,宏在编译器解释代码含义之前就会展开,因此宏可以例如在给定类型上实现一个特质。函数则不能,因为函数是在运行时被调用的,而特质需要在编译时实现。
实现宏而不是函数的缺点是宏定义比函数定义更复杂,因为您编写的Rust代码会生成Rust代码。由于这种间接性,宏定义通常比函数定义更难以阅读、理解和维护。
宏和函数之间的另一个重要区别是,你必须在文件中在调用它们之前定义宏或将它们引入作用域,而函数则可以在任何地方定义并在任何地方调用。
用于通用元编程的声明式宏
在 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! 宏来创建一个包含两个整数的向量或一个包含五个字符串切片的向量。我们不能使用函数来完成相同的操作,因为我们在一开始不知道值的数量或类型。
列表 20-35 显示了 vec! 宏的一个稍微简化的定义。
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
vec! macro definition注意:标准库中 vec! 宏的实际定义包括预先分配正确数量的内存的代码。这部分代码是一种优化,我们在这里没有包含,以使示例更简单。
#[macro_export] 注解表示每当定义该宏的 crate 被引入作用域时,该宏应该被提供。没有这个注解,宏不能被引入作用域。
然后我们用 macro_rules! 和我们定义的宏的名称(不带感叹号)开始宏定义。名称,在这个例子中为 vec,后面跟着表示宏定义主体的大括号。
vec! 体内的结构类似于 match 表达式的结构。这里我们有一个带有模式 ( $( $x:expr ),* ) 的分支,后面跟着 => 和与此模式关联的代码块。如果模式匹配,将生成关联的代码块。鉴于这是此宏中唯一的模式,只有一种有效的匹配方式;任何其他模式都将导致错误。更复杂的宏将有多个分支。
有效的宏定义中的模式语法与第19章中介绍的模式语法不同,因为宏模式是与Rust代码结构匹配,而不是与值匹配。让我们来分析一下列表20-29中的模式片段的含义;有关完整的宏模式语法,请参阅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
}
我们定义了一个宏,可以接受任意数量的任意类型的参数,并生成代码以创建包含指定元素的向量。
要了解更多关于如何编写宏的信息,请参阅在线文档或其他资源,如“Rust宏小书”,该书由Daniel Keep发起并由Lukas Wirth继续编写。
用于从属性生成代码的过程宏
第二种宏的形式是过程宏,它更像一个函数(并且是一种过程)。过程宏接受一些代码作为输入,对这些代码进行操作,并生成一些代码作为输出,而不是像声明式宏那样通过匹配模式并用其他代码替换这些代码。过程宏的三种类型是自定义派生、属性样式的和函数样式的,它们都以类似的方式工作。
在创建过程宏时,定义必须位于具有特殊crate类型的独立crate中。这是出于复杂的技
术原因,我们希望将来能够消除。在清单20-36中,我们展示了如何定义一个过程宏,其中some_attribute是用于特定宏变体的占位符。
use proc_macro::TokenStream;
#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
定义过程宏的函数以 TokenStream 作为输入,并生成一个 TokenStream 作为输出。 TokenStream 类型由 Rust 自带的 proc_macro crate 定义,表示一个令牌序列。这是宏的核心:宏操作的源代码构成了输入 TokenStream,而宏生成的代码则是输出 TokenStream。该函数还附加了一个属性,指定了我们正在创建的过程宏的类型。我们可以在同一个 crate 中有多种类型的过程宏。
让我们看看不同种类的过程宏。我们先从自定义派生宏开始,然后解释使其他形式不同的细微差异。
自定义 derive 宏
让我们创建一个名为 hello_macro 的 crate,该 crate 定义了一个名为 HelloMacro 的 trait,其中有一个关联函数名为 hello_macro。我们不是让用户为他们的每个类型实现 HelloMacro trait,而是提供一个过程宏,以便用户可以通过 #[derive(HelloMacro)] 注解他们的类型来获得 hello_macro 函数的默认实现。默认实现将打印 Hello, Macro! My name is TypeName!,其中 TypeName 是定义此 trait 的类型的名称。换句话说,我们将编写一个 crate,使其他程序员能够使用我们的 crate 编写如清单 20-37 所示的代码。
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
接下来,在清单 20-38 中,我们将定义 HelloMacro 特性和其关联的函数。
pub trait HelloMacro {
fn hello_macro();
}
derive macro我们有一个特质及其函数。此时,我们的 crate 用户可以通过实现该特质来实现所需的功能,如清单 20-39 所示。
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();
}
HelloMacro trait但是,他们需要为每个想要与hello_macro一起使用的类型编写实现块;我们希望免除他们做这项工作的需要。
此外,我们还不能为 hello_macro 函数提供默认实现来打印实现该特征的类型的名称:Rust 没有反射功能,因此无法在运行时查找类型的名称。我们需要一个宏在编译时生成代码。
下一步是定义过程宏。在撰写本文时,
过程宏需要位于它们自己的 crate 中。最终,这一限制
可能会被取消。crate 和宏 crate 的结构约定如下:对于名为 foo 的 crate,自定义 derive 过程宏 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文件中:
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"
要开始定义过程宏,请将清单 20-40 中的代码放入 src/lib.rs 文件中,用于 hello_macro_derive crate。请注意,此代码在我们添加 impl_hello_macro 函数的定义之前将无法编译。
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 代码解析器绝非易事。
hello_macro_derive 函数将在我们的库的用户在类型上指定 #[derive(HelloMacro)] 时被调用。这是因为我们在这里用 proc_macro_derive 注解了 hello_macro_derive 函数,并指定了名称 HelloMacro,这与我们的特征名称匹配;这是大多数过程宏遵循的惯例。
hello_macro_derive 函数首先将 input 从一个 TokenStream 转换为一个我们可以解释和执行操作的数据结构。这就是 syn 发挥作用的地方。syn 中的 parse 函数接受一个 TokenStream 并返回一个表示解析后的 Rust 代码的 DeriveInput 结构体。列表 20-41 显示了从解析 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
)
}
)
}
DeriveInput instance we get when parsing the code that has the macro’s attribute in Listing 20-37这个结构体的字段显示我们解析的 Rust 代码是一个单元结构体,其 ident(标识符,即名称)为 Pancakes。这个结构体还有更多字段用于描述各种 Rust 代码;有关更多信息,请参阅 syn 中 DeriveInput 的文档。
很快我们将定义impl_hello_macro函数,这将是构建我们想要包含的新Rust代码的地方。但在我们这样做之前,请注意,我们派生宏的输出也是一个TokenStream。返回的TokenStream将添加到我们的crate用户编写的代码中,因此当他们编译他们的crate时,他们将获得我们在修改后的TokenStream中提供的额外功能。
你可能已经注意到,我们在这里调用 unwrap 以使 hello_macro_derive 函数在 syn::parse 函数调用失败时引发 panic。我们的过程宏在遇到错误时必须 panic,因为 proc_macro_derive 函数必须返回 TokenStream 而不是 Result 以符合过程宏 API。我们通过使用 unwrap 简化了这个示例;在生产代码中,你应该使用 panic! 或 expect 提供更具体的错误信息。
现在我们有了将带有注解的 Rust 代码从 TokenStream 转换为 DeriveInput 实例的代码,让我们生成实现 HelloMacro 特性到带有注解的类型的代码,如清单 20-42 所示。
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 generated = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
generated.into()
}
HelloMacro trait using the parsed Rust code我们使用 ast.ident 获取一个包含注解类型名称(标识符)的 Ident 结构体实例。列表 20-41 中的结构体显示,当我们对列表 20-37 中的代码运行 impl_hello_macro 函数时,我们得到的 ident 将具有值为 "Pancakes" 的 ident 字段。因此,列表 20-42 中的 name 变量将包含一个 Ident 结构体实例,当打印时,它将是字符串 "Pancakes",即列表 20-37 中结构体的名称。
quote! 宏让我们定义我们想要返回的 Rust 代码。编译器期望从 quote! 宏的直接执行结果中得到不同的东西,所以我们需要将其转换为 TokenStream。我们通过调用 into 方法来实现这一点,该方法消耗这个中间表示并返回所需 TokenStream 类型的值。
quote! 宏还提供了一些非常酷的模板机制:我们可以输入 #name,quote! 会将其替换为变量 name 中的值。你甚至可以像普通宏那样进行一些重复操作。查看 the quote crate’s docs 以获得详细的介绍。
我们希望我们的过程宏为用户注解的类型生成一个HelloMacro特性的实现,这可以通过使用#name来获得。特性的实现包含一个函数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 中都成功完成。让我们将这些 crates 连接到列表 20-37 中的代码,看看过程宏如何工作!在 projects 目录中使用 cargo new pancakes 创建一个新的二进制项目。我们需要在 pancakes crate 的 Cargo.toml 中添加 hello_macro 和 hello_macro_derive 作为依赖项。如果你将 hello_macro 和 hello_macro_derive 的版本发布到 crates.io,它们将是常规依赖项;如果不是,你可以将它们指定为 path 依赖项,如下所示:
[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }
将清单 20-37 中的代码放入 src/main.rs,并运行 cargo run:它应该打印 Hello, Macro! My name is Pancakes!。来自过程宏的 HelloMacro 特性的实现被包含进来,而无需 pancakes crate 实现它;#[derive(HelloMacro)] 添加了特性实现。
接下来,让我们探讨其他种类的过程宏与自定义派生宏有何不同。
类似属性的宏
属性宏类似于自定义derive宏,但它们不是为derive属性生成代码,而是允许你创建新的属性。它们也更加灵活:derive仅适用于结构体和枚举;而属性可以应用于其他项目,例如函数。以下是一个使用属性宏的示例。假设你有一个名为route的属性,用于在使用Web应用程序框架时注解函数:
#[route(GET, "/")]
fn index() {
这个 #[route] 属性将由框架定义为过程宏。宏定义函数的签名将如下所示:
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
这里,我们有两个类型为TokenStream的参数。第一个是属性的内容:GET, "/" 部分。第二个是属性附加到的项的主体:在这种情况下,是fn index() {}及其余的函数主体。
除此之外,属性宏的工作方式与自定义derive宏相同:您需要创建一个proc-macro类型的crate,并实现一个生成所需代码的函数!
函数式宏
函数式宏定义了看起来像函数调用的宏。类似于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 {
这个定义与自定义 derive 宏的签名类似:我们接收括号内的标记并返回我们想要生成的代码。
摘要
呼!现在你已经掌握了一些你可能不会经常使用,但在特定情况下会知道它们可用的 Rust 特性。我们介绍了几个复杂的话题,这样当你在错误消息建议或其他人的代码中遇到它们时,你能够识别这些概念和语法。将本章作为参考,引导你找到解决方案。
接下来,我们将把本书中讨论的所有内容付诸实践,并再做一个项目!