编写强大的 Rust 宏——通过属性宏将字段设为公共字段

516 阅读32分钟

本章内容包括:

  • 理解派生宏和属性宏之间的区别
  • 在抽象语法树中查找字段信息
  • 使用匹配提取字段
  • 使用自定义结构体提取字段
  • 使用自定义结构体和解析实现提取字段
  • quote 中添加多个输出
  • 使用日志语句调试宏
  • 理解 no-panic crate

Rust 喜欢隐藏信息。函数、结构体或枚举默认是私有的,结构体的字段也是如此。这是非常合理的,尽管有时当你有一个字段很多的结构体时,稍微有点烦人,因为这些字段最好是公开的。数据传输对象(DTOs)就是一个经典的例子,这在许多编程语言中是一个常见的模式,用于在系统之间或单一系统的不同部分之间传输信息。由于它们只是信息的简单封装,因此不应包含任何业务逻辑。而“信息隐藏”,保持结构体/类字段私有的主要原因,在这种情况下并不适用,因为它的唯一价值就是暴露字段中包含的信息。

为了进行实验,我们将展示如何通过几行代码改变默认行为。我们将创建一个宏,一旦将其添加到结构体中,就会使结构体及其所有字段变为公共。这与我们在上一章中面对的挑战非常不同。那时,我们是在为现有代码添加内容,而现在我们需要修改已经存在的内容。因此,派生宏不适用了,属性宏才是正确的选择。

属性宏得名于它们定义了一个新的属性。当你使用该自定义属性注解结构体或枚举时,宏会被触发。否则,编写属性宏的库和代码与创建派生宏非常相似。但也有区别:属性宏还会接收一个包含附加属性(如果有的话)的 TokenStream。更重要的是,在本章中,属性宏的输出令牌将替代输入。这听起来像是能够解决问题的东西。

4.1 属性宏项目的设置

让我们逐步完成本章的设置过程,这与上一章非常相似:

  1. 创建一个新目录(make-public),在其中再创建一个目录(make-public-macro)。
  2. 在嵌套的 make-public-macro 目录内,运行 cargo init --lib 来初始化我们的宏。
  3. 添加 synquote 依赖项(通过 cargo add syn quote),并将 lib 设置为 proc-macro = true
  4. 在外部的 make-public 目录中运行 cargo init 并将我们的库作为依赖项添加。

Listing 4.1 make-public-macro 目录中的 Cargo.toml 文件部分内容:

[dependencies]
quote = "1.0.33"
syn = "2.0.39"

[lib]
proc-macro = true

Listing 4.2 make-public 目录中的 Cargo.toml 文件部分内容:

[dependencies]
make-public-macro = { path = "./make-public-macro" }

4.2 属性宏与派生宏的比较

完成设置后,我们从简单的步骤开始,在嵌套目录的 lib.rs 文件中添加一些代码。我们定义了一个公共函数,生成一个 TokenStream。它接受一个 item 参数,并将其解析成抽象语法树(AST)。这样我们就能够像之前一样从原始代码中提取所需的内容。与之前一样,输出是通过 quote! 宏创建的。

Listing 4.3 初始设置:

extern crate core;

use quote::quote;
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_attribute]             #1
pub fn public(_attr: TokenStream, item: TokenStream) #2
    -> TokenStream {              
    let _ast = parse_macro_input!(item as DeriveInput);     #3

    let public_version = quote! {}; #4

    public_version.into()
}

#1 注释告诉 Rust 这是一个属性宏。 #2 属性宏的名称由函数名决定(这里是“public”)。它接受两个 TokenStream 作为参数。 #3 由于我们目前还没有使用 AST,这里前面加了下划线。 #4 如之前一样,我们的初始实现接受输入但不生成输出。

虽然代码看起来很熟悉,但与上一章相比,还是有一些不同之处。首先,我们使用了不同的属性:#[proc_macro_attribute],而不是 #[proc_macro_derive]。而且,与之前不同的是,我们不在括号内指定宏的名称(#[proc_macro_derive(Hello)])。相反,函数的名称决定了属性的名称。所以,在我们的例子中,这个宏会创建一个自定义的属性 #[public]。你还可以看到,我们接收了一个额外的 TokenStream(我们暂时会忽略它),它包含了关于我们属性的信息(见图 4.1)。

image.png

我们代码的起点与上一章相同:一个不生成任何输出的宏。但这一次,通过不返回任何内容,我们确实对现有代码产生了影响。试着将以下代码添加到你的应用程序 main.rs 中并运行 cargo expand

Listing 4.4 使用这个 main.rs 运行 cargo expand

use make_public_macro::public;

#[public]
struct Example {}

fn main() {}

现在,Example 结构体已经消失了!有点神秘吧。

Listing 4.5 我们的结构体消失的表现

#![feature(prelude_import)]
#[prelude_import]
use std::prelude::rust_2021::*;
#[macro_use]
extern crate std;

use make_public_macro::public;

fn main() {}

正如我们之前所说,Rust 期望一个属性宏的输出能够替换输入,这使得事情比之前稍微复杂了一些。

4.3 公共可见性的第一步

回到我们手头的任务:我们现在可以向结构体添加属性,使其更具具体性。

Listing 4.6 我们的示例结构体

#[public]
struct Example {
    first: String,
    pub second: u32,
}

我们希望宏能够输出以下更改后的结构体:结构体本身应该是公共的,first 应该变为公共的,而 second 应该保持公共。

我们可以像之前一样处理这个问题。因此,我们从最简单的实现开始,这个实现仅适用于这个特定的结构体。

Listing 4.7 硬编码的实现

extern crate core;

use quote::quote;
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_attribute]
pub fn public(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let _ast = parse_macro_input!(item as DeriveInput);     #1

    let public_version = quote! { #2
        pub struct Example {
            pub first: String,
            pub second: u32,
        }
    };             

    public_version.into()
}

#1 仍然没有使用 AST!
#2 我们将 Example 结构体以之前定义的方式硬编码返回。

显然,这个方法是有效的。接下来,作为下一步,我们之前学到如何获取输入结构体的名称。通过在这里应用这些知识,我们将能够接受任何具有与示例相同字段的结构体。

Listing 4.8 使用从输入中获取的结构体名称的硬编码属性

#[proc_macro_attribute]
pub fn public(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let ast = parse_macro_input!(item as DeriveInput);
    let name = ast.ident;           

    let public_version = quote! {
        pub struct #name {           
            pub first: String,
            pub second: u32,
        }
    };

    public_version.into()
}

通过应用我们已经知道的知识,我们获取结构体的名称并将其传递给 quote。我们终于开始使用我们的 AST,并且在使结构体更具通用性方面又迈出了小小的一步。很好。接下来我们希望做的是获取并使用传入的字段。它们与名称来自同一个地方:我们的 AST。

4.4 获取并使用字段

如前所述,参数 item 包含所有相关代码,这意味着整个结构体都可以通过 ast 变量访问。该变量的类型是 DeriveInput,它表示我们可能从派生宏接收到的输入类型。是的,我们正在编写一个属性宏。但这个类型是有效的,因为这两个宏共享一些重要的输入目标:结构体、枚举和联合体。虽然属性宏也可以针对特征和函数,但目前对我们来说并不重要。如果你更喜欢一个更合适的类型,syn::ItemStruct 应该可以直接替换,但它的缺点是隐藏在“full”特性标志下。因此,我们需要修改 syn 的导入来使用它。

DeriveInput 包含了什么?这是源代码的内容(参考链接):

pub struct DeriveInput {        #1
    pub attrs: Vec<Attribute>,  #2
    pub vis: Visibility,       #3
    pub ident: Ident,          #4
    pub generics: Generics,    #5
    pub data: Data,            #6
}

#1 DeriveInput 是一个表示派生宏输入的结构体。
#2 它包含项目的属性。
#3 它的可见性修饰符。
#4 结构体的标识符。
#5 任何泛型。
#6 其他信息(内容)。

你了解 Rust,因此你可以猜到这些属性的含义:

  • attrs 包含结构体上定义的任何属性,例如 #[derive(Debug)]
  • vis 包含我们结构体的可见性(公共的、在 crate 中可见的,或者只是私有的,即“继承的”)。
  • ident 包含我们结构体的名称(标识符)。
  • generics 包含有关结构体是否是泛型的信息(例如 Example<T>)。但在这里并不是这种情况。
  • data 是我们可以找到结构体内部数据的详细信息的地方。

data 对我们来说似乎最有用,它包含了什么?深入研究 syn,你会发现 Data 是一个枚举,有三个选项:StructEnumUnion。这很有意义,因为 DeriveInput 是专门为派生宏创建的,而派生宏不同于属性宏,它只能用于这三种目标:

pub enum Data {
    Struct(DataStruct),    #1
    Enum(DataEnum),       
    Union(DataUnion),    
}

#1 Data 是一个有三个变体的枚举。

Struct 是我们需要的变体。那么,嵌套在 DataStruct 中的内容是什么?再深入一点,你会看到它有一个 struct_token,它包含 struct 关键字(不太有用);semi_token,它是一个可选的分号(不太感兴趣);以及结构体的字段(见图 4.2):

pub struct DataStruct {
    pub struct_token: Token![struct],
    pub fields: Fields,
    pub semi_token: Option<Token![;]>,
}

image.png

现在我们知道在哪里可以找到字段,让我们从数据中提取它们。目前,我们只想让它在结构体中工作,因此我们在匹配时将忽略枚举和联合体。我们还将使用匹配来深入提取命名字段——具有名称和类型的字段,例如 first: String。以下代码中的“..”帮助我们忽略 DataStructFieldsNamed 中我们不关心的属性,包括未命名的字段,这些我们将在后面讨论。如果你忘记添加这两个点,你会得到类似“结构体模式未提及字段 someField 和 anotherField”的错误。这是 Rust 告诉你在匹配中缺少了一些字段:

let fields = match ast.data {
    Struct(
        DataStruct {
            fields: Named(
                FieldsNamed {
                    ref named, ..
                }), ..
        }
    ) => named,            #1
    _ => unimplemented!( #2
        "only works for structs with named fields"
    ),                    
};

#1 匹配使得我们可以轻松地从输入中提取命名字段。
#2 当我们没有接收到带有命名字段的结构体时,我们会引发 panic。

现在我们有了这样的命名字段类型:Punctuated<Field, Token![,]>Punctuated 可以是任何具有标点符号的类型。在这种情况下,标点符号的泛型表明字段由逗号 Token 分隔。结构体中的字段确实是由逗号分隔的。值得知道的是,Punctuated 实现了 IntoIterator,所以我们可以对它进行迭代。深入挖掘,你会看到我们将迭代第一个泛型 Field,这也很有意义。毕竟,逗号本身并不是很有趣的迭代对象。

我们有了所有的结构体字段,并且可以对它们进行循环。接下来的问题是:Field 中包含什么?再次查看源代码,我们看到它包含了许多熟悉的东西:attrsvisident,以及一个名为 ty 的属性,它包含字段的类型(见图 4.3)。

image.png

现在,我们的目标是什么?我们希望将结构体重新填充其属性,但现在需要加上 pub 可见性修饰符。对于我们的 Example 结构体,我们希望使用 quotepub first: String 添加进去。完成后,我们还希望添加 pub second: u32。因此,我们所需要做的就是获取字段的名称和类型。其他信息——包括可见性(因为我们始终设置为 pub)——都是无关紧要的。

注意 我们忽略了结构体可能具有的其他属性,这意味着其他宏可能无法按预期工作。稍后我们会看到如何修复这个问题。

在以下代码中,你可以看到这个想法的实现。我们对字段进行迭代,并使用 map 来提取标识符和类型。通过 quote,我们生成一个包含公共前缀、名称和类型的 TokenStream

let builder_fields = fields.iter().map(|f| {
    let name = &f.ident;       #1
    let ty = &f.ty;           
    quote! { pub #name: #ty }  #2
});

#1 现在,我们在 map 中有了单个字段,可以获取字段的名称(标识符)和类型。
#2 使用 quote,我们返回一个 TokenStream,其中包含输入的内容,并为每个字段加上 pub 前缀。

到目前为止,我们知道,任何不是字面量的内容都应该在输出中加上井号(#)前缀。这样,quote 知道它需要将给定值替换为同名变量中的值。请注意,quote 无法访问变量的属性,因此像 quote! { pub #f.ident: #f.ty } 这样的写法只会导致混乱的错误,因为 #f 会被“解析”并将字面值 .ident 添加到输出中。另一个有趣的地方是,我们不需要收集 map 的输出——因为 quote 知道如何处理生成 TokenStreammap。不过,如果你想简化函数签名,仍然可以调用 collect 来获取一个 Vec<TokenStream> 输出。稍后我们将看到此的示例。

现在,我们所需要做的就是将这些流添加到我们要返回的结构体中。我们还需要告诉 Rust,这些流应该用逗号分隔,否则 Rust 会抱怨语法错误。quote 为此提供了一个便利,它看起来非常类似于你在声明宏中处理多个元素的方式:#(#name-of-your-variable,)*(即,拿这个变量,它包含零个或多个值,并在每个提取的元素后加上逗号)。请注意,quote 中的重复需要两个井号。如果你忘记外面的井号,你会遇到这个错误:“the trait ToTokens is not implemented for Map...”。

Listing 4.9 我们的公共字段宏

use quote::quote;
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput, DataStruct, FieldsNamed};
use syn::Data::Struct;
use syn::Fields::Named;

#[proc_macro_attribute]
pub fn public(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let ast = parse_macro_input!(item as DeriveInput);
    let name = ast.ident;

    let fields = match ast.data {
        Struct(
            DataStruct { #1
                fields: Named(
                    FieldsNamed {
                        ref named, ..
                    }), ..
            }
        ) => named,
        _ => unimplemented!(
            "only works for structs with named fields"
        ),
    };                              

    let builder_fields = fields.iter().map(|f| {
        let name = &f.ident; #2
        let ty = &f.ty;
        quote! { pub #name: #ty }
    });                                

    let public_version = quote! {
        pub struct #name {
            #(#builder_fields,)*         #3
        }
    };

    public_version.into()
}

#1 如果我们有一个带有命名字段的结构体,我们会提取字段。
#2 从这些字段中,我们获取字段的名称和类型,并返回一个 TokenStream,其中包含 pub、名称和类型。
#3 在我们的输出中,我们告诉 quotebuilder_fields 是一个元素列表,应该用逗号分隔并添加到结构体中。

如你所见,quote 和我们需要的功能一样多才多艺。我们可以用 #name 添加一个简单的 TokenStream,或者一次添加多个元素。在更复杂的宏中,输出通常是通过将多个变量组合在一起得到的,最终形成一个输出。这些变量可能也是通过 quote 的组合生成的,复杂度不确定。无论如何,得益于之前的代码,现在像 Example 这样的简单结构体已经将所有字段设置为公共的。隐私正式消失了!

4.5 可能的扩展

现在,我们有很多方法可以扩展我们的宏,使它变得更有用。例如,我们可以在接收到枚举时,不再触发 panic,而是编写代码来处理它。请记住,枚举的变体在枚举本身是公共时会自动变为公共,这意味着直接在枚举上添加 pub 可能比使用自定义宏更简单。

我们还忽略了未命名的字段,然而这些字段在像 struct Example(u32, String); 这样的结构体中是存在的。如果我们像这样编写结构体,就会进入未实现的匹配分支。那么,我们该如何处理这些结构体呢?第一步是提取未命名字段,这将非常类似于我们获取命名字段的方式。我们可以在额外的匹配分支中完成这一点。一旦我们得到了字段,就可以按照正确的样式输出它们——例如,pub struct Example(pub u32, pub String);。最后,你需要区分命名结构体和未命名结构体,并决定返回哪种类型的输出。幸运的是,这是一个简单的二元选择:结构体要么是命名的,要么是未命名的。

有些令人惊讶的是,我们的代码能够正确处理空结构体,但仅当它们后面跟着方括号或花括号时。如果结构体没有这些符号,比如 struct Empty;,它将没有任何字段,因此会进入我们的未实现分支。

还有一些潜在的问题(除了没有处理联合体)。例如,我们的宏不会将嵌套结构体的字段设置为公共。幸运的是,只需将 #[public] 添加到嵌套项上即可轻松解决。

一个更大的问题是,当我们有其他宏对结构体进行注解时,它们会被我们的宏删除。试着在我们的注解下方添加一个 #[derive(Debug)],然后运行 cargo expand。你会看到额外的注解消失得无影无踪吗?我们真是擅长让事情消失。由于宏是按顺序执行的,从上到下,解决方法是将 derive 放在 #[public] 之上。这样一切都会按预期工作。除非——请不要这样做。相反,应以正确的方式检索并重新附加所有可用的属性。

此外,虽然我们保留了命名字段的泛型,但我们正在重新创建结构体的签名,这意味着它的泛型会消失。解决这个问题需要使用并重新添加 DeriveInput 中的泛型字段。你可以在稍后的练习中尝试这些扩展。

4.6 多种解析流的方法

前面描述的方法展示了一种创建过程宏的方式。它使用 syn 来解析你的标记,使用 quote 来创建新的标记,使用匹配和函数来处理中间的所有内容。然而,如果你不喜欢通过函数将所有内容拼接在一起,还有其他替代方案,比如更“结构体聚焦”的方法。作为一个示例,我们将重新审视我们的 public 宏,并展示一些选择。

4.6.1 将任务委派给自定义结构体

首先,我们可以将获取和输出字段的任务委派给一个 StructField 结构体。首先,添加一个对 proc-macro2 的依赖,我们很快就会用到它。

Listing 4.10 在我们的库 toml 文件中添加了两个熟悉的依赖项和一个新的依赖项

[dependencies]
quote = "1.0.33"
syn = "2.0.39"
proc-macro2 = "1.0.69"

现在来看一下新的实现。这里有两个主要的变化。我们不再直接使用 syn 的原始类型,而是让我们的结构体收集和组织所有需要的信息(即,名称和类型)。按照惯例,名为 new 的方法应该用于创建结构体,因此我们创建一个,并在遍历字段时调用它。map(StructField::new) 是一个便捷的简写,也叫做无点风格(point-free style),它等价于 map(|f| StructField::new(f))

Listing 4.11 使用结构体来完成大部分拼接的实现

use proc_macro::TokenStream;
use quote::{quote, ToTokens};
// 之前的导入

struct StructField { #1
    name: Ident,
    ty: Type,
}

impl StructField {
    fn new(field: &Field) -> Self { #2
        Self {
            name: field.ident.as_ref().unwrap().clone(),
            ty: field.ty.clone(),
        }
    }
}

impl ToTokens for StructField {
    fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { #3
        let n = &self.name;
        let t = &self.ty;
        quote!(pub #n: #t).to_tokens(tokens)
    }
}

#[proc_macro_attribute]
pub fn public(_attr: TokenStream, item: TokenStream) -> TokenStream {
    // 未更改:获取字段
    let builder_fields = fields.iter().map(StructField::new);  #4
    // 未更改:使用 quote 输出
}

#1 这个结构体包含我们需要的每个字段的数据。
#2 我们的 new 方法包含了从字段中获取名称和类型的代码。
#3 ToTokens 输出一个带有公共前缀的标记流。因为它是 quote 库的一部分,它使用了 proc_macro2::TokenStream
#4 在遍历过程中,我们通过调用 new 将每个 Field 映射为 StructField

builder_fields 包含了我们的自定义结构体。那 quote 如何将其转换为 TokenStream 呢?它不能,因为它不知道如何将一个随机结构体转换为一堆标记。解决方案之一是编写一个第二个方法,将结构体转换为 TokenStream 并在遍历时调用它。但这里有一个现成的解决方案!ToTokens(来自 quote)可以将实现者转换为 TokenStream,因此我们将使用这个特性。

to_tokens 中的代码与我们之前在映射中所做的类似,唯一不同的是,to_tokens 要求我们将所有新标记传递给类型为 proc_macro2::TokenStream 的可变参数,正如我们简要提到的,proc_macro2::TokenStream 是一个社区构建的标记流,它包装了内置流并添加了附加功能。quote 在内部使用它,因此每次返回标记时,我们必须对 quote 的结果加上 into。现在我们显式地在 impl 块的签名中使用它,所以我们需要将它添加到项目的依赖中。另一个可行的方式是使用 quote::__private::TokenStream,但我们选择使用更简洁的方法。通过这个方式,我们的 builder_fields 自动转化为 TokenStream,而我们的最终 quote 代码无需更改。

这比之前的代码更多—也更复杂—but 也有一些积极的方面。我们实现了关注点的分离:我们让一个结构体负责字段的获取和输出。这使得代码更具可复用性,并可能更加易于阅读和结构化。在这个基本示例中,可能有些过度设计,但在更大的过程宏中,将数据组织为结构体可能会证明是有用的。

4.6.2 实现 Parse 特性

我们可以更进一步,结合 syn 中的一个内置特性——Parse 特性,它用于将 TokenStream 转换为结构体或枚举。在下一个代码示例中,我们添加了该特性的实现及其方法 parse,该方法接受 syn::parse::ParseStream 作为输入。你可以将其视为一种特殊的 TokenStream

Listing 4.12 使用 parse 替代 new

// imports

struct StructField {
    name: Ident,
    ty: Ident,        #1
}

// ToTokens 实现保持不变

impl Parse for StructField {        #2
    fn parse(input: ParseStream) -> Result<Self, syn::Error> {
        let _vis: Result<Visibility, _> = input.parse();     #3
        let list = Punctuated::<Ident, Colon>::parse_terminated(input)
            .unwrap();              #4

        Ok(StructField {
            name: list.first().unwrap().clone(), #5
            ty: list.last().unwrap().clone(),
        })             
    }
}

#[proc_macro_attribute]
pub fn public(_attr: TokenStream, item: TokenStream) -> TokenStream {
    // 未更改
    let builder_fields = fields.iter() #6
        .map(|f| {
            syn::parse2::<StructField>(f.to_token_stream())
                .unwrap()
        });               
    // 未更改
}

#1 ty 现在是一个 Ident,而不是 Type,因为这正是我们从 Parse 中接收到的。
#2 new 方法已经被 parse 替代。
#3 我们尝试将可见性解析为一个 Result<Visibility, _> 类型的变量,以便将指针移到下一个标记。
#4 没有可见性的字段是一个 Punctuated 类型,因此我们调用 parse_terminated 来解析字段的其余部分。
#5 第一个元素应该是名称,类型应该是最后一个。因为我们将字段解析为由冒号分隔的标识符,所以 ty 现在是 Ident
#6 遍历字段并使用 parse2Result 可能是任何类型,因此我们需要声明我们期望的是 StructField

在我们的 parse 实现中,我们期望接收到一个 ParseStream 表示单个字段。这意味着我们首先接收到的是一个标记,指示字段的可见性。我们对这个值不感兴趣,但现在处理它会让剩下的解析变得更容易。因此,我们调用 parse 方法并告诉它,我们想将这个 Visibility 绑定到一个变量 _visparse 可能会失败,因此 Visibility 被包装在 Result 中,我们不会展开它,因为这只是为了移除该值。现在我们的 ParseStream 不再包含字段的可见性修饰符,我们可以继续处理接下来的部分(见图 4.4)。

image.png

注意 也许你认为可见性是字段定义中的一个可选部分,确实,我以前也是这么认为的。但回顾一下 AST 图和源代码:ParseStream 始终包含一个 Visibility。默认情况下,它只是 Inherited,根据文档,“通常意味着私有(private)”。

在我们的下一步中,Punctuated::<Ident, Colon> 告诉 Rust 我们期待的是由标识符和冒号(:)分隔的内容。毕竟,字段声明的样子就是 field_name: FieldType,没有它的可见性。而且,正如我们的示例所示,第一个元素应该是名称,最后一个元素是类型。所以我们可以将它们添加到结构体中。请注意,由于 Punctuatedty 现在被解析为 Ident,这完全是有效的。

如果此时可见性仍然是我们流的一部分,Rust 会因为它期望一个标识符列表而得到一个 Visibility 类型时抱怨并报错:“Error("expected identifier")”。但是,尽管我们避免了这个错误,这仍然不是一个生产就绪的宏。我们现在只期望简单的、未注解的、命名的字段。其他的都不考虑。而这只是结构体可能包含的内容范围的一部分,尽管是一个重要部分。不过,现阶段这个方法已经足够了。

剩下的就是将字段标记传递给解析。我们可以使用 syn::parse2 来完成这个任务。它接受一个 TokenStream 并返回任何实现了 Parse 的类型。但由于有很多东西实现了 Parse,我们必须告诉函数我们想要什么返回类型。在我们的例子中,返回类型是 StructFieldparse2::<StructField> 可能是传递这些信息最优雅的方式。一个笨拙的替代方法是:map(|f| { let field: StructField = parse2(f.to_token_stream()).unwrap(); field })

注意 为什么是 parse2parse 也存在,并且非常相似,但它接受普通的 TokenStream,而 parse2 接受 proc_macro2 变体(因此有了这个名字)。顺便说一句,parse_macro_input! 只是 parse 的语法糖。

.map(|f| parse2::<StructField>(f.to_token_stream()) 不是很理想。我们已经有了从 syn 解析出来的漂亮数据,现在又把它转换回 TokenStream。你可以通过例如将所有字段(或整个输入)解析为自定义结构体,而不是解析每个单独的字段,来避免这种丑陋的写法。

4.6.3 使用 cursor 进行低级控制

作为最后一个示例,我们也可以选择使用 cursor 来进行对解析的低级控制:

impl Parse for StructField {
    fn parse(input: ParseStream) -> Result<Self, syn::Error> {
        let first = input.cursor().ident().unwrap();

        let res = if first.0.to_string().contains("pub") {
            let second = first.1.ident().unwrap();
            let third = second.1.punct().unwrap().1.ident().unwrap();
            Ok(StructField {
                name: second.0,
                ty: third.0,
            })
        } else {
            let second = first.1.punct().unwrap().1.ident().unwrap();
            Ok(StructField {
                name: first.0,
                ty: second.0,
            })
        };

        let _: Result<proc_macro2::TokenStream, _> = input.parse();
        res
    }
}

我们在输入参数上调用 cursor 并告诉它,首先遇到的应该是一个标识符。cursor 不区分名称、类型、可见性等,因此它会捕获字段的可见性或名称。如果名称包含字符串 "pub",我们就得到了可见性,这意味着下一个标识符是名称,我们通过调用 first.1.ident() 并解包结果来获取它。我们的变量 second 应该包含名称和剩余的数据。我们的类型位于冒号后面,因此我们期望找到标点符号(punct()),后面跟着标识符。现在我们已经得到了名称和类型,并将它们传递给 StructField。另一个条件分支类似。当我们没有显式的可见性修饰符时,我们已经得到了名称,只需要获取类型。

不过,最终的 let _: Result<proc_macro2::TokenStream, _> = input.parse(); 是比较麻烦的。cursor 给我们提供了对现有流的不可变访问,这很好——除了调用解析方法后,parse2 会进行检查,确保流中没有未处理的内容。在我们的例子中,我们没有——甚至不能——更改任何内容,所以我们会得到一个混乱的错误 Error("unexpected token")。因此,我们只需要调用 parse 并让它返回一个 Result,我们可以忽略它。这有点像我们在之前的示例中忽略可见性(在 Listing 4.12 中)。

你应该偏好哪种风格?
这取决于情况。函数和匹配相对简单,似乎非常适合较小的宏或编写概念验证。结构体可以为你的解决方案提供额外的结构,并提供一种很好的方式来委派责任:每个结构体处理部分解析和输出。当你传递给宏的内容不是标准的 Rust 代码,比如 DSL 时,结构体特别有用。默认的解析器显然不适合处理看起来随机的输入,这些输入只有在特定领域中才有意义。因此,你可以编写自己的结构体并捕获相关信息。与此同时,cursor 提供了低级控制和很大的能力,但它是冗长的,使用起来不太方便。因此,它可能不是你首选的方式。

在本书中,许多章节侧重于将函数作为构建宏的粘合剂,因为这种风格对于简短的示例非常方便。但因为结构体通常在“实际应用”中使用,我们也会提供使用这种风格的示例。

4.7 更多开发和调试方式

在之前的章节中,我们讨论了开发和调试工具,如 cargo expand。在本章中,我们也深入研究了源代码,这帮助我们了解了当 syn 解析输入时,哪些数据可供使用。另一个值得提及的有用工具是——请不要把书合上——打印到控制台,因为通过类型查看 AST 的结构很有用,但打印它可以告诉你里面实际包含了什么,这在你不确定自己收到的是什么时非常有帮助。

有两点需要注意:首先,要调试并打印 DeriveInputsyn 中的其他类型,你需要激活 synextra-traits 特性:

syn = { version = "2.0.39", features=["extra-traits"] }

其次,有很大的可能性标准输出会被捕获并不会显示在你的控制台中。简单的解决方法是使用错误打印,它应该总是能够回到你那里:

let ast = parse_macro_input!(item as DeriveInput);
eprintln!("{:#?}", &ast);

接下来,当我们在宏中添加 eprintln 命令时,你可以看到该命令的简略输出。里面有很多信息,学习如何阅读它需要一些时间。但是你应该能够识别我们在本章中讨论的许多类型——如 IdentStructDataStructFieldsNamed 等:

DeriveInput {
    vis: Inherited,
    ident: Ident {
        ident: "Example",
        span: #0 bytes(67..74),
    },
    ...
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Named(
                FieldsNamed {
                    named: [
                        Field {
                            attrs: [],
                            vis: Inherited,
                            ident: Some(
                                Ident {
                                    ident: "first",
                                    span: #0 bytes(81..86),
                                },
                            ),
                            colon_token: Some(
                                Colon,
                            ),
                            ...
        },
    ),
}

4.8 来自现实世界的示例

许多库使用属性宏来实现有趣的功能。例如,广泛使用的 Rust 异步运行时库 Tokio 使用其 #[tokio::main] 宏将你的 main 函数转换为可以处理异步调用的形式。这是源代码入口点:

#[proc_macro_attribute]
pub fn main(args: TokenStream, item: TokenStream) -> TokenStream {
    entry::main(args, item, true)
}

等一下,只有 main 吗?那么为什么所有的代码示例都显示 #[tokio::main]?这可能是为了让读者明确这是 Tokio 的 main 宏。但由于 :: 前面的部分仅表示我们从哪个 crate 获取宏,所以使用 tokio::main,加上 #[main] 也是完全有效的(越知道的越多……)。

还有许多库使用 ParseToTokens 特性。以下是来自 Rocket(一个 Web 框架,使用许多宏来生成端点等功能)的一些示例。想要响应一个 GET 调用吗?只需添加自定义的 #[get("/add/endpoint")] 注解:

impl Parse for Invocation {
  fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
      Ok(Invocation {
        ty_stream_ty: (input.parse()?, input.parse::<syn::Token![,]>()?).0,
        stream_mac: (input.parse()?, input.parse::<syn::Token![,]>()?).0,
        stream_trait: (input.parse()?, input.parse::<syn::Token![,]>()?).0,
        input: input.parse()?,
      })
  }
}

impl ToTokens for UriExpr {
    fn to_tokens(&self, t: &mut TokenStream) {
        match self {
            UriExpr::Uri(uri) => uri.to_tokens(t),
            UriExpr::Expr(e) => e.to_tokens(t),
        }
    }
}

如你所见,Parse 实现比我们的代码有更好的错误处理(它使用 ? 而不是直接解包)。对于生产级的宏来说,良好的错误处理是推荐的,因此我们将在稍后的章节中回到这一点。尽管如此,偶尔你会看到宏中出现 panic。Yew,一个用于编写和验证 HTML 的 Web 框架,确实在两个地方出现了 unimplemented!("only structs are supported")

作为最终示例,看看 no-panicGitHub 链接),这是一个让编译器证明某个给定的函数永远不会 panic 的宏——这非常神奇。它是如何做到的呢?考虑以下代码:

struct Dropper {}
impl Drop for Dropper {
    fn drop(&mut self) { #1
        println!("Dropping!")
    }
}              

fn some_fun() {
    let d = Dropper {};
    panic!("panic");      #2
    core::mem::forget(d);     #3
}

fn main() {
    some_fun();
}

#1 为我们的 Dropper 结构体实现 Drop
#2 在创建 Dropper 实例后触发 panic!
#3 core::mem::forget(d) 让 Rust 忘记 d,所以如果它运行,drop 方法不会被调用。

我们有一个叫做 Dropper 的结构体,它实现了 Drop,这是 Rust 中用于在需要自定义资源清理时使用的特性。当你运行代码时,some_fun 会触发一个 panic,接着堆栈将开始展开。此时,DropperDrop 被调用来清理结构体。

但如果我们注释掉 panic,那么下一行会触发 forget。这会让 Rust 忘记 Dropper 结构体和它的 Drop 实现。在这种情况下,Drop 将不会被调用。(记住,当遇到 panic 时,我们停止执行。所以在第一个例子中,我们永远不会到达 forget。但至少在默认情况下,Rust 确实会在 panic 时清理资源。)这有一个有趣的含义。如果编译器能够“证明” some_fun 永远不会 panic,那么合乎逻辑的结论是,由于 forget 的调用,Drop 永远不会被调用!因此,Rust 可以安全地优化掉 Drop 实现,因为它从未被使用。

这个库和其他类似的库做了一个巧妙的操作:它们创建了一个结构体,实现 Drop 特性,并在实现中调用一个不存在的 C 函数,随后添加了一个 forget 调用。意味着如果代码没有被优化掉,我们将会遇到链接错误。而当 panic 发生在 forget 调用之前时,代码不会被删除,因为在这种情况下,Rust 可能仍然需要调用 Drop。很巧妙吧?

struct __NoPanic;
extern "C" {
    #[link_name = "does_not_exist"]
    fn trigger() -> !;
}

impl Drop for __NoPanic {
    fn drop(&mut self) {
        unsafe {
            trigger();
        }
    }
}

库本身的代码非常简短(大约 150 行代码)。以下是一个经过注解的部分示例。为了简化,我移除了一些错误处理,因为我们尚未讨论过这部分。并且,目前你只需要知道 parse_quote 类似于常规的 quote 宏:

#[proc_macro_attribute]
pub fn no_panic(args: TokenStream, input: TokenStream) -> TokenStream {
    let expanded = match parse(args, input.clone()) { #1
        Ok(function) => expand_no_panic(function),
        // 错误处理
    };
    TokenStream::from(expanded)
}

fn parse(args: TokenStream2, input: TokenStream2) -> Result<ItemFn> {
    let function: ItemFn = syn::parse2(input)?; #2
    let _: Nothing = syn::parse2::<Nothing>(args)?;
    Ok(function)
}

fn expand_no_panic(mut function: ItemFn) -> TokenStream2 { #3
    // ...
    let stmts = function.block.stmts;
    let message = format!(
        "\n\nERROR[no-panic]: detected panic in function `{}`\n",
        function.sig.ident,
    );
    function.block = Box::new(parse_quote!({
        struct __NoPanic;
        extern "C" { #4
            #[link_name = #message]
            fn trigger() -> !;
        }
        impl core::ops::Drop for __NoPanic {
            // ...
        }
        let __guard = __NoPanic;
        let __result = (move || #ret {
            #move_self
            #(
                let #arg_pat = #arg_val;
            )*
            #(#stmts)*
        })();
        core::mem::forget(__guard);
        __result
    }));

    quote!(#function)
}

#1 这是宏的入口点。调用自定义的 parse 函数,并将 Ok 结果转发到 expand_no_panic
#2 ItemFnsyn 中内建的用于解析函数的类型,类似于 DeriveInput 帮助我们处理枚举和结构体。
#3 这个函数做了大量的工作。在 ItemFn 类型中,function.block 包含了函数的主体。__NoPanic 相关的内容被添加到这个主体中。
#4 这个函数做了大量的工作。function.block 包含了函数的主体,__NoPanic 相关的内容被添加到该主体。

你应该能在这段代码中认出许多熟悉的元素。我们首先使用属性宏声明了宏。因为函数名决定了宏的名称,所以我们知道使用这个宏时需要写 #[no_panic]。我们接收两个 TokenStream 作为参数,并使用 parse2 将输入解析为 ItemFn,这是 syn 中内建的解析函数的类型,稍后我们将在其他章节中详细讨论。args 没有被使用,因此它被解析成了一个被忽略的 Nothing 类型变量。如果 TokenStream 中有任何内容,解析到 Nothing 将会失败(因此命名为 Nothing)。顺便提一下,双重指定类型(Nothingsyn::parse2::<Nothing>)是冗余的,使用其中一个即可。

expand_no_panic 包含了我们之前讨论的大部分内容。它向函数主体添加了伪链接的 C 函数,并尝试使用 forget 忘记它。作为额外的功能,伪 C 函数的 link_name 被用作报告检测到的 panic 的消息,当 Drop 没有被优化掉时。实现部分包含在 function.block.stmts 中(stmts 代表“语句”),它被添加到我们的新实现中,覆盖了原有的实现。通过这段新代码,最终的表达式(quote!(#function))返回部分覆盖过的函数作为 TokenStream

总结

  • 与派生宏类似,属性宏可以用于枚举、结构体和联合体。此外,它们也可以添加到特征和函数中。
  • 与派生宏不同,属性宏会覆盖其输入,从而使得修改现有代码成为可能。
  • 属性宏的名称来源于定义一个新的自定义属性。
  • 我们可以使用 syn 解析结果(即我们的 AST)来获取各种信息,例如结构体的字段。
  • quote 允许我们将多个标记流组合成一个单一的输出。
  • 我们可以使用匹配和函数将解析和输出结合起来。
  • 对于更复杂和更大的宏,你可能会考虑创建自定义结构体,并将解析和输出的责任委派给它们。