Rust 学习笔记:宏

Rust 学习笔记:宏

术语宏指的是 Rust 中的一系列特性,主要分为两大类:

  • 声明性宏:通过 macro_rules! 定义的宏
  • 过程宏,细分为三种不同的形式:
    • 自定义的 #[derive] 宏:用于结构体或枚举上,自动为类型生成一些 derive 属性的代码
    • 类属性宏:定义可用于任何项的自定义属性
    • 类函数宏:看起来像函数调用,但实际操作的是指定为其参数的代码片段(token)

我们将依次讨论它们,但首先,让我们看看为什么在已经有函数的情况下还需要宏。

宏与函数的区别

从根本上说,宏是一种编写代码的代码,这被称为元编程。所有这些宏都会展开,生成比你手动编写的更多的代码。

元编程有助于减少必须编写和维护的代码量,这也是函数的作用之一。然而,宏具有一些函数所不具备的额外功能。

函数签名必须声明函数拥有的参数的数量和类型。但是,宏可以接受可变数量的形参。

此外,在编译器解释代码的含义之前,宏就被展开。因此,宏可以在给定类型上实现 trait。而函数不能,因为函数在运行时才被调用,trait 需要在编译时实现。

宏定义比函数定义更复杂,因为你正在编写“编写 Rust 代码”的 Rust 代码。由于这种间接性,宏定义通常比函数定义更难阅读、理解和维护。

宏和函数之间的另一个重要区别是,在调用宏之前,必须定义宏或将它们引入作用域,而不像函数那样可以在任何地方定义和调用。

使用声明性宏进行通用元编程

声明性宏的核心是允许你编写类似于 Rust 匹配表达式的东西。

匹配表达式是一种控制结构,它接受一个表达式,将表达式的结果值与模式进行比较,然后运行与匹配模式相关的代码。

宏还将值与与特定代码相关联的模式进行比较:在这种情况下,值是传递给宏的字面 Rust 源代码,将模式与源代码的结构进行比较,与每个模式相关联的代码在匹配时将替换传递给宏的代码。这一切都发生在编译期间。

要定义宏,可以使用 macro_rules! 构造。

以一个稍微简化的 vec! 宏的定义为例。注意,标准库中 vec! 宏的实际定义包括预先分配正确内存量的代码。为了使示例更简单,这里没有包括这些代码。

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

#[macro_export] 注释表明,当定义宏的 crate 进入作用域时,应该使这个宏可用。如果没有这个注释,就不能将宏引入作用域。

然后用 macro_rules! 开始宏定义。我们定义的宏的名称不带感叹号。该名称(在本例中为 vec)后跟花括号,表示宏定义的主体。

vec! 的主体类似于匹配表达式的结构。在这里,我们有一个带有模式 ( $( $x:expr ),* ) 的分支,后面跟着 => 和与该模式相关的代码块。如果模式匹配,将发出相关的代码块。鉴于这是该宏中唯一的模式,因此只有一种有效的匹配方式,任何其他模式都将导致错误。更复杂的宏将有多个分支。

宏定义中的有效模式语法与之前介绍的模式语法不同,因为宏模式匹配的是 Rust 代码结构而不是值。

首先,我们使用一组括号来包含整个模式。我们使用美元符号($)在宏系统中声明一个变量,该变量将包含与模式匹配的 Rust 代码。$ 清楚地表明,这是一个宏变量,而不是普通的 Rust 变量。接下来是一组圆括号,它捕获与圆括号内的模式匹配的值,以便在替换代码中使用。在 $() 中是 $x:expr,它匹配任何 Rust 表达式并给表达式命名为 $x。

$() 后面的逗号表示在与 $() 中的代码匹配的每个代码实例之间必须出现一个字面逗号分隔符。* 指定模式匹配 0 个或多个 * 前面的内容。

现在让我们来看看与该分支相关的代码体中的模式:$()* 中的 temp_vec.push() 是为模式中匹配 $() 0 次或更多次的每个部分生成的,具体取决于模式匹配的次数。$x 替换为匹配的每个表达式。

当我们用 vec![1, 2, 3];,则 $x 模式与三个表达式 1、2 和 3 匹配三次,生成的替换宏调用的代码如下:

{
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec
}

我们已经定义了一个宏,它可以接受任意数量的任意类型的实参,并且可以生成代码来创建包含指定元素的 vector。

要了解有关如何编写宏的更多信息,请参阅:The Little Book of Rust Macros

过程性宏:从属性生成代码

过程性宏的行为更像函数(并且是一种过程),它接受一些代码作为输入,对这些代码进行操作,并生成一些代码作为输出,而不是像声明性宏那样根据模式进行匹配,并用其他代码替换代码。自定义派生、类属性和类函数这三种过程性宏都以类似的方式工作。

要定义过程性宏,我们必须将它们写在一个单独的 crate 中,而且这个 crate 还必须有特殊的 crate 类型。

在下面代码中,我们展示了如何定义过程性宏,其中 some_attribute 是使用特定宏变体的占位符。

use proc_macro;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}

定义过程性宏的函数接受 TokenStream 作为输入,并产生 TokenStream 作为输出。TokenStream 类型由 Rust 中包含的 proc_macro crate 定义,它表示一系列 token。这是宏的核心:宏操作的源代码构成了输入 TokenStream,而宏产生的代码是输出 TokenStream。该函数还附加了一个属性,用于指定要创建的过程性宏的类型。在同一个 crate 中可以有多种过程性宏。

让我们来看看不同类型的过程性宏。我们将从一个自定义的派生宏开始,然后解释使其他形式不同的小差异。

如何编写自定义派生宏

让我们创建一个名为 hello_macro 的库 crate,它定义了一个名为 HelloMacro 的 trait 和一个名为 hello_macro 的关联函数。

我们将提供一个过程性宏,以便用户可以用 #[derive(HelloMacro)] 注释他们的类型,以获得 hello_macro 函数的默认实现,而不是让用户为他们的每个类型实现 HelloMacro trait。默认实现将打印 Hello, Macro! My name is TypeName!,其中 TypeName 是定义了该 trait 的类型的名称。换句话说,我们将编写一个 crate,使其他程序员能够使用我们的 crate 编写类似下面的代码。

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

#[derive(HelloMacro)]
struct Pancakes;

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

第一步是创建一个新的库 crate,如下所示:

cargo new hello_macro --lib

接下来,我们将定义 HelloMacro trait 及其相关函数:

pub trait HelloMacro {
    fn hello_macro();
}

我们有一个 trait 和它的 hello_macro 函数。此时,我们的 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();
}

然而,crate 用户需要为他们想要使用 hello_macro 的每种类型编写实现块,我们希望他们不必做这些工作。

此外,我们还不能提供 hello_macro 函数的默认实现,它将打印 trait 实现的类型的名称。Rust 没有反射功能,所以它不能在运行时查找类型的名称。我们需要一个宏来在编译时生成代码。

下一步是定义过程性宏。过程性宏需要放在它们自己的 crate 中。构造 crate 和宏 crate 的约定如下:对于名为 foo 的 crate,自定义派生过程宏 crate 称为 foo_derived。让我们在 hello_macro 项目中创建一个名为 hello_macro_derived 的新的库 crate:

 cargo new hello_macro_derive --lib

这两个 crate 是紧密相关的,因此我们在 hello_macro crate 的目录中创建过程性宏 crate。如果我们改变 hello_macro 中的 trait 定义,我们也必须改变 hello_macro_derived 中的过程性宏的实现。这两个 crate 需要分别发布,使用这两个 crate 的程序员需要将它们作为依赖项添加,并将它们都纳入作用域。

我们当然可以让 hello_macro crate 使用 hello_macro_derived 作为依赖项,然后重新导出过程宏代码。然而,我们构建项目的方式使得程序员即使不需要派生功能也可以使用 hello_macro 函数。

我们需要将 hello_macro_derived crate 声明为一个过程性宏 crate。我们还需要 syn 和 quote crate 的功能,因此我们需要将它们作为依赖项添加。因此,将以下内容添加到 hello_macro_derived crate 的 Cargo.toml 文件中:

[lib]
proc-macro = true

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

要开始定义过程性宏,请将下列代码放入 hello_macro_derived crate 的 src/lib.rs 中。注意,在为 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_derived 函数和 impl_hello_macro 函数,前者负责解析 TokenStream,后者负责转换语法树,这使得编写过程宏更加方便。

外部函数(在本例中为 hello_macro_derived)中的代码对于你看到或创建的几乎每个过程性宏 crate 都是相同的。在内部函数体中指定的代码(本例中为 impl_hello_macro)将根据过程性宏的目的而有所不同。

我们引入了三个新的 crate:proc_macro、syn 和 quote。

proc_macro crate 是 Rust 自带的,所以我们不需要将它添加到 Cargo.toml 中的依赖项中。该 crate 是编译器的 API,它允许我们从自己的代码中读取和操作 Rust 代码。

syn crate 将 Rust 代码从字符串解析为可以对其执行操作的数据结构。

quote crate 将 syn 数据结构转换回 Rust 代码。

这些 crate 使得解析我们想要处理的任何类型的 Rust 代码变得更加简单。为 Rust 代码编写一个完整的解析器并不是一件简单的任务。

当库的用户在类型上指定 #[derive(HelloMacro)] 时,将调用 hello_macro_derived 函数。这是可能的,因为我们在这里用 proc_macro_derived 注释了 hello_macro_derived 函数,并指定了名称 HelloMacro,它与我们的 trait 名称相匹配;这是大多数过程性宏遵循的约定。

hello_macro_derived 函数首先将来自 TokenStream 的输入转换为一个数据结构,然后我们可以对其进行解释和执行操作。这就是 syn crate 发挥作用的地方。syn::parse 函数接受一个 TokenStream 并返回一个表示解析过的 Rust 代码的 DeriveInput 结构体。下列代码显示了我们通过解析 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 代码是一个单元结构体,其标识符是 Pancakes。在这个结构体上有更多的字段来描述各种 Rust 代码。

注意,派生宏的输出也是一个 TokenStream。返回的 TokenStream 被添加到我们的 crate 用户编写的代码中,所以当他们编译他们的 crate 时,他们将获得我们在修改后的 TokenStream 中提供的额外功能。

你可能已经发现,如果对 syn::parse 函数的调用失败,我们调用 unwrap 是为了使 hello_macro_derived 函数出现 panic。我们的过程性宏有必要对错误进行恐慌,因为 proc_macro_derived 函数必须返回 TokenStream 而不是 Result,以符合过程性宏 API 的要求。我们通过使用 unwrap 简化了这个例子,但在实际开发中,应该使用 panic! 或者 expect 提供更具体的错误消息,说明出了什么问题。

现在我们已经有了将带注释的 Rust 代码从 TokenStream 转换为 DeriveInput 实例的代码,让我们编写在带注释的类型上实现 HelloMacro trait 的代码,如下所示:

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 结构体实例。当我们运行 impl_hello_macro 函数时,我们获得的 ident 将具有值为 “Pancakes” 的 ident 字段。因此,上面代码中的 name 变量是 Ident 结构体实例的引用,该实例在打印时将是字符串 “Pancakes”,即结构体的名称。

quote! 宏允许我们定义想要返回的 Rust 代码。编译器期望得到与 quote! 宏执行的直接结果不同的结果,因此我们需要将其转换为 TokenStream。我们通过调用 into 方法来实现这一点,该方法使用此中间表示并返回所需 TokenStream 类型的值。

quote! 宏还提供了一些非常酷的模板机制:我们可以输入 #name, quote! 将用变量名中的值替换它。你甚至可以执行一些类似于常规宏工作方式的重复操作。

我们希望过程宏为用户注释的类型生成 HelloMacro trait 的实现,我们可以通过使用 #name 来获得。trait 实现有一个 hello_macro 函数,它的主体包含了我们想要提供的功能:打印 Hello, Macro! My name is ,然后是注释类型的名字。

这里使用的 stringify! 宏是内置在 Rust 中的。它接受一个 Rust 表达式,并在编译时将该表达式转换为字符串字面值。这与 format! 或 println! 这两种宏不同,它们对表达式求值,然后将结果转换为字符串。输入的 #name 有可能是要按字面意思打印的表达式,因此我们使用 stringify!。使用 stringify! 还可以通过在编译时将 #name 转换为字符串字面值来节省分配。

hello_macro 和 hello_macro_derived crate 的构建完成了。

在这里插入图片描述

让我们使用它们,看看过程性宏是如何工作的。

使用 cargo new pancakes 在项目目录中创建一个新的二进制 crate。我们需要将 hello_macro 和 hello_macro_derived 作为依赖项添加到 pancakes crate 的 Cargo.toml 中,如下所示:

hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

main.rs 中的代码为:

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

#[derive(HelloMacro)]
struct Pancakes;

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

过程性宏中 HelloMacro trait 的实现被包括在内,而 pancakes crate 不需要实现它,因为 #[derive(HelloMacro)] 添加了 HelloMacro trait 的实现。

在这里插入图片描述

接下来,让我们探索其他类型的过程性宏与自定义派生宏的区别。

类属性宏

类属性宏类似于自定义派生宏,但不是为派生属性生成代码,而是允许你创建新属性。它们也更加灵活:派生只适用于结构体和枚举;属性也可以应用于其他项目,比如函数。

下面是一个使用类属性宏的示例。假设你有一个名为 route 的属性,它在使用 Web 应用程序框架时注释了函数:

#[route(GET, "/")]
fn index() {

这个 #[route] 属性将被框架定义为一个过程性宏。宏定义函数的签名是这样的:

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {

这里,我们有两个 TokenStream 类型的参数。第一个用于属性(attr)的内容:GET, “/” 部分。第二个是属性所附加的项(item)的主体:在本例中是 fn index() {} 和函数主体的其余部分。

除此之外,类属性宏的工作方式与自定义派生宏相同:使用 proc-macro crate 类型创建一个 crate,并实现一个生成所需代码的函数。

类函数宏

类函数宏定义了看起来像函数调用的宏。类似于 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 {

这个定义类似于定制的派生宏的签名:我们接收括号内的 token,并返回我们想要生成的代码。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

UestcXiye

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值