编写强大的 Rust 宏——带有属性的构建器

本章内容:

  • 使用字段级自定义属性重命名方法
  • 使用根级自定义属性决定错误处理方式
  • 使用类型状态使构建器更易于使用
  • 探索 derive 和属性宏的区别
  • 在函数式宏中解析(文档)属性

到目前为止,我们创建的每个宏的行为都是固定的。没有任何自定义的空间。但是,有时候你可能希望拥有可覆盖的行为,因为这可以大大增加灵活性,或者是为了满足某些要求。本章我们将重点讨论通过属性实现的定制化,这些属性适用于 derive 和属性宏。我们将看到如何向宏属性和代码的其他部分(如结构体的单个字段)添加信息。

和往常一样,我们需要一个项目来说明这些可能性。我们不打算引入新的示例,而是继续扩展之前创建的构建器示例。我们如何让它变得更通用呢?或许我们可以从允许用户重命名将出现在构建器中的 setter 方法开始。这是一个非常典型的功能(例如,Serde 允许重命名),因为有时候你的数据形式和需要遵循的标准或要求之间会存在不匹配。即使默认设置能适用于大多数情况,允许这种定制化可能会是一个值得考虑的选项。

8.1 重命名属性

本章的设置非常简单,因为我们已经有了一个准备好的宏。如果你在跟随本章的内容,建议暂时禁用现有的测试,因为我们不会努力保持它们的运行。不过,我们仍然会为我们新增的代码编写测试场景,这些代码将添加到 usage 文件夹中的 main.rs

8.1.1 测试新属性

我们需要的测试是验证是否能够重命名一个属性,并且这也应该更改相关的 setter 方法名。这两个断言会在以下测试中结合在一起。

列出 8.1:验证期望的行为的测试

rust 复制代码
#[test]
fn should_generate_builder_for_struct_with_one_renamed_property() {
    #[derive(Builder)]
    struct Gleipnir {
        #[rename("tops_of")]       #1
        roots_of: String,
    }

    let gleipnir = Gleipnir::builder()
        .tops_of("mountains".to_string())      #2
        .build();

    assert_eq!(
        gleipnir.roots_of, #3
        "mountains".to_string()
    );                      
}
  • #1 这是新添加的属性,指定了一个自定义名称。
  • #2 因为有了这个属性,构建器方法现在被称为 tops_of,而不是 roots_of
  • #3 实际的属性名称保持不变。

这个测试中新添加的部分是 #[rename("tops_of")] 属性,我们在标注了 #[derive(Builder)] 的结构体中使用它。尝试运行这个测试,你会遇到错误:cannot find attribute rename in this scope。这是因为我们还没有告诉 Rust 我们的宏包含属性。我们需要修改 builder-macro 中的 lib.rs 文件。

列出 8.2:添加重命名属性

scss 复制代码
#[proc_macro_derive(Builder, attributes(rename))]     #1
pub fn builder(item: TokenStream) -> TokenStream {
    create_builder(item.into()).into()
}
  • #1 attributes(rename) 告诉 derive 宏它有一个名为 rename 的属性。

derive 宏的属性被称为"derive 宏助手属性"。它们是惰性加载的,意味着它们在属性处理期间不会被移除。你可以通过运行 cargo expand 来查看这一点------即使是带有属性的 derive 宏,输出仍然包含这些属性。这是合理的,因为 derive 宏不能改变它们的输入。这些属性的唯一目的是将其传递给 derive 宏,供宏使用来改变行为和输出。

所有的属性,包括我们的 rename,都应在宏声明的 attributes 部分指定(如前所示)。现在,rename 是一个已知的属性,但它仍然没有实现,这导致测试失败,错误信息为"找不到名为 tops_of 的方法"。我们准备开始修复这个问题。

8.1.2 实现属性的行为

我们的计划如下:

  • 像以前一样遍历我们的字段。
  • 检查是否有重命名属性。
  • 如果有,改变方法名称。
  • 如果没有,回退到默认值。

在继续之前,快速回顾一下:以下是 syn::Field 的所有属性。你已经学会了如何处理 visidenttycolon_token 仅指示字段定义中是否有 :(例如:String,即该 Option 将是 Some)。mutabilitysyn 版本 2 中的新字段,且在当前版本中始终为 None。这意味着 attrs,即放置在字段上的属性,是我们尚未使用的唯一相关属性。现在我们将开始使用它:

rust 复制代码
pub struct Field {
    pub vis: Visibility,
    pub ident: Option<Ident>,
    pub ty: Type,
    pub colon_token: Option<Token![:]>,
    pub mutability: FieldMutability,
    pub attrs: Vec<Attribute>,
}

因为我们要修改方法生成,所以扩展 builder_methods 是合适的。相关代码如下:

列出 8.3:原始 builder_methods 代码

rust 复制代码
pub fn builder_methods(fields: &Punctuated<Field, Comma>)
    -> impl Iterator<Item = TokenStream2> + '_ {
    fields.iter().map(|f| {
        let (field_name, field_type) = get_name_and_type(f);
        quote! {
            pub fn #field_name(&mut self, input: #field_type) -> &mut Self {
                self.#field_name = Some(input);
                self
            }
        }
    })
}

为了使用属性,我们需要做一些更改,幸运的是,这些更改都仅限于 fields.rs 文件。lib.rs 将保持不变。我们将从简单的步骤开始,编写一个使用 find 来查找字段属性中是否存在匹配名称的助手方法。

列出 8.4:在 fields.rs 中查找具有给定名称的字段属性的助手方法

rust 复制代码
fn extract_attribute_from_field<'a>(f: &'a Field, name: &'a str)
    -> Option<&'a syn::Attribute> {
        f.attrs.iter().find(|&attr| attr.path().is_ident(name))
}

path 方法返回属性的路径,即 #[ 后面的部分(例如:"rename")。因为这是一个标识符,我们应当使用 is_ident 来与我们的字符串进行比较。另一种方法是做更多的工作,比较路径段:attr.path().segments.len() == 1 && attr.path().segments[0].ident == *name。这之所以有效,是因为 Ident 实现了 PartialEq<T>,对于任何 T: AsRef<str> 类型都可以进行比较。find 方法会返回一个 Option,如果找不到,默认为 None

现在我们继续修改 builder_methods 中的核心代码。我们依旧遍历字段并获取字段名称和类型,但现在我们有了额外的逻辑来提取属性、分析它并根据分析结果决定输出。

列出 8.5:在 fields.rs 中的新 builder_methods 代码

rust 复制代码
use syn::{Meta, LitStr};

pub fn builder_methods(fields: &Punctuated<Field, Comma>)
    -> Vec<TokenStream> {
    fields.iter()
        .map(|f| {
            let (field_name, field_type) = get_name_and_type(f);
            let attr = extract_attribute_from_field(f, "rename")  #1
                .map(|a| &a.meta)
                .map(|m| {
                    match m {
                        Meta::List(nested) => {
                            let a: LitStr = nested
                                .parse_args()
                                .unwrap();
                            Ident::new(&a.value(), a.span())
                        }
                        Meta::Path(_) => {
                            panic!(
                                "expected brackets with name of prop"
                            )
                        },
                        Meta::NameValue(_) => {
                            panic!(
                                "did not expect name + value"
                            )
                        }
                    }
                });            #2

            if let Some(attr) = attr {
                quote! { #3
                    pub fn #attr(mut self, input: #field_type) -> Self {
                        self.#field_name = Some(input);
                        self
                    }
                }              
            } else {
                quote! { #4
                  pub fn #field_name(mut self, input: #field_type) -> Self {
                      self.#field_name = Some(input);
                      self
                  }
                }                
            }
        }).collect()
}
  • #1 调用助手方法。
  • #2 尝试从 rename 属性中获取重命名值。
  • #3 如果有重命名,生成一个带有该值的 setter 方法名。
  • #4 如果没有重命名,则回退到默认的 setter 方法。

我们使用助手方法来提取属性,如果属性存在则获取它。这样做还不够,我们需要做一些额外的映射操作,以获取我们真正关心的:期望的方法名称(参见图 8.1)。一个属性的有趣信息大部分都位于 meta 中,这是一个包含三种变体的枚举:ListPathNamedValue

NamedValue 是指在属性后面放置带有键值对的括号(例如,#[rename(name=tops_of)])。
Path 是指属性信息没有括号(即路径)。
List 是指在括号中包含多个值:#[rename("first", "second")]

在我们的情况下,最后一种情况------即仅有一个元素的列表------是正确的。我们将保持简洁,如果接收到错误的属性格式,则会触发 panic。但我必须指出,meta 提供了一些非常有用的方法来处理这种情况。require_list 方法会返回嵌套的 MetaList(如果存在),如果收到的是其他枚举变体,则会返回 Err。对于另外两种情况,还有类似的方法:require_path_onlyrequire_name_value。因此,require_list 是进行简单 panic 的一个很好的替代方案。

List 包含 tokens,这是一个 TokenStream,其中包含我们想要的信息。从这个流中获取有用的类型的最简单方法是使用 parse_args 方法,指定预期的返回类型。对于我们来说,LitStr 是一个方便的返回类型,因为我们字面上------以一种微弱的双关语------期望一个字符串!

现在剩下的就是将这个文字常量转换成我们可以用来构造方法名称的标识符。为此,我们使用 value 辅助方法和现有文字的 span,并将它们传递给新的方法。看起来,获取 token、将其转换为字符串,并取引用(即 &nested_lit.token().to_string())似乎也能工作。不幸的是,那段代码会出错:""tops_of" 不是有效的标识符"。这些对文字的转义符并不是我们想要的标识符,而 value 会帮你去掉它们。

一旦我们完成了将属性转换为自定义方法名称,我们使用 if letOption 中获取值(如果存在)。在其他情况下,我们会回退到之前的实现,即字段名和方法名相同。

8.1.3 解析变体

和往常一样,这个宏及其实现有许多可能的变体。例如,也许你认为在调用宏时,"tops_of" 周围的引号是多余的分心。也许你更喜欢没有引号的形式,比如 #[rename(tops_of)]。在这种情况下,我们就没有 LitStr 包装,括号中的 token 就是我们需要的内容。因此,在 match 语句中的替代代码看起来如下所示。

Listing 8.6 替代代码

css 复制代码
Meta::List(nested) => {
    &nested.tokens
}

如果是标识符而不是字符串,我们也可以使用 parse_nested_meta,这是 Attribute 上的方法。在这种情况下,我们就不需要两个嵌套的 mapmap(|a| &a.meta) 加上进行匹配的 map),而是可以得到如下所示的代码。

Listing 8.7 使用 parse_nested_meta 的替代方法

scss 复制代码
let mut content = None;

a.parse_nested_meta(|m| {       #1
    let i = &m.path.segments.first().unwrap().ident;             #2
    content = Some(Ident::new(&i.to_string(), i.span()));        #3
    Ok(())
}).unwrap();           #4

content.unwrap()        #5

注释解释:

  • #1 调用 parse_nested_meta 获取属性的内容。
  • #2 我们知道 rename 应该包含一个标识符,所以我们提取它。
  • #3 这返回一个 unit 结果,我们需要处理它(在这里使用 unwrap)。
  • #4 content 现在应该是一个 Option<Ident>,我们解包它并返回标识符。

syn 的 1.0 版本中,曾有一个有用的 parse_meta 方法,在 parsing 功能下,但它似乎已被移除。

为了确保一切正常工作,你可以向 main.rs 添加更多测试,例如一个包含多个属性,但只有一个具有自定义名称的测试,或者多个属性都有自定义名称的测试。

Listing 8.8 额外的测试(示例)

rust 复制代码
#[test]
fn should_generate_builder_for_struct_with_two_props_one_custom_name() {
    #[derive(Builder)]
    struct Gleipnir {
        #[rename("tops_of")]
        roots_of: String,
        breath_of_a_fish: u8,
    }

    let gleipnir = Gleipnir::builder()
        .tops_of("mountains".to_string())
        .breath_of_a_fish(1)
        .build();

    assert_eq!(gleipnir.roots_of, "mountains".to_string());
    assert_eq!(gleipnir.breath_of_a_fish, 1);
}

这些测试以及现有的测试应该可以编译并成功运行。

8.2 属性的替代命名方式

在继续之前,我们将探讨使用键值对为 rename 属性命名的替代方法。许多库采用了另一种方法,通过创建一个单一的库属性,并将特定命令添加在括号中------例如,#[serde(rename = "name")]。但我们将把这个作为练习留给大家。

首先,编写一个单元测试来验证所需的行为。(你应该禁用其他测试。我们将替换现有的属性行为。)

Listing 8.9 测试替代的属性命名策略

rust 复制代码
#[cfg(test)]
mod tests {
    #[test]
    fn should_generate_builder_for_struct_with_one_renamed_prop() {
        #[derive(Builder)]
        struct Gleipnir {
            #[rename = "tops_of"]          #1
            roots_of: String,
        }

        let gleipnir = Gleipnir::builder()
            .tops_of("mountains".to_string())
            .build();

        assert_eq!(gleipnir.roots_of, "mountains".to_string());
    }
}

注释解释:

  • #1 我们使用等号(=)来分隔"命令"和它的值,而不是括号。

接下来是实现部分。同样,我们只需关注 fields.rsbuilder_methods。虽然只有一些必要的更改,但该解决方案采用了更"流式"的映射方法。我们不再使用 if let,而是一直映射直到获得输出,使用 unwrap_or_else 获取默认值。unwrap_or 是一种替代方法,但 unwrap_or_else 接受一个闭包,并且是惰性求值,这在重命名较多时可能会带来轻微的性能提升。

Listing 8.10 替代方法的实现

rust 复制代码
// 其他代码和导入

pub fn builder_methods(fields: &Punctuated<Field, Comma>) -> Vec<TokenStream> {
    fields.iter()
        .map(|f| {
            let (field_name, field_type) = get_name_and_type(f);

            extract_attribute_from_field(f, "rename")
                .map(|a| &a.meta)
                .map(|m| {
                    match m {
                        Meta::NameValue( #1
                            MetaNameValue { value: Expr::Lit(ExprLit {
                                lit: Lit::Str(literal_string), .. }), ..
                        }) => {
                            Ident::new(
                                &literal_string.value(),
                                literal_string.span()
                            )
                        }          
                       _ => panic!(
                            "expected key and value for rename attribute"
                        ),
                    }
                })
                .map(|attr| { #2
                    quote! {
                        pub fn #attr(mut self, input: #field_type) -> Self {
                            self.#field_name = Some(input);
                            self
                        }
                    }
                })              
                .unwrap_or_else(|| { #3
                    quote! {
                        pub fn #field_name(mut self, input: #field_type) 
        -> Self {
                            self.#field_name = Some(input);
                            self
                        }
                    }
                })                
        }).collect()
}

注释解释:

  • #1 我们在 Meta::NameValue 中查找字符串值。
  • #2 继续映射并创建 TokenStream
  • #3 使用 unwrap_or_else 默认返回正常输出。

除了额外的映射外,与前面代码的唯一区别是我们现在必须在元数据中查找 NameValue(见图 8.2)。正如前面提到的,这是我们为键值对属性得到的变体。match 的写法较长(Meta::NameValue(MetaNameValue { value: Expr::Lit(ExprLit { lit: Lit::Str (literal_string), .. }), .. })),如果你觉得它太复杂,可以将其拆分为多个嵌套的 match。就个人而言,我喜欢通过单个可读的模式匹配直接获取所需的值。

惰性和急切评估

惰性评估和急切评估是两种编写代码的方法。急切评估意味着你编写的代码会在遇到时立即执行。而惰性评估意味着评估会被推迟,直到确实需要时才会执行。在Listing 8.10 中,unwrap_or_else 是惰性评估的,因为创建默认 TokenStream 的闭包只有在我们进入 unwrap_or_else 调用时才会被执行。如果每个字段都有 rename 属性,并且我们从未进入 unwrap_or_else,那么我们就不需要为创建默认流付出代价。而 unwrap_or 则是急切评估的,意味着如果我们使用了这个方法,无论是否需要,默认的 TokenStream 都会在每次调用时被构建。

急切评估的一个优点是更容易推理。我们知道每件事物都会随时准备好使用,这使得它更简洁。除了可能带来性能优势外,惰性评估是一些编程构造(如无限数据结构)唯一适用的方法。如果你有一个会持续不断地产生数据的流,急切评估会永远调用它,而惰性方法则只有在你真的需要数据时才会调用该流。编程语言通常会内建对某种评估方法的偏好,尽管这并不常常是唯一的。例如,JavaScript 通常是急切评估的,而 Haskell 是惰性评估的。事实上,我们已经在本书中遇到过惰性评估。lazy_static crate 就有一个宏用于"声明惰性评估的静态变量"。

8.3 合理的默认值

还记得我们在之前的章节中谈到的错误处理吗?如何避免在每个环节都抛出 panic 或异常?那么,为什么当用户忘记填写一个字段时,我们就会让程序 panic 呢?

其实是有替代方案的。我们可以选择在调用 build 时输出一个 Result,如果某个字段缺失,则返回 Err。这是 Rust 中处理可能失败的函数调用的标准做法。或者,我们可以尝试让 panic 变得不可能------这是一个极端的想法,我们会留到后面讨论。现在,我们要探讨的另一个方案是使用默认值作为后备选项。我们将使用非常有用的 Default trait 来实现这一点。如果你以前没听说过这个 trait,简单来说,Default 用于为给定类型指定一个默认值。大多数内建类型已经有合理的默认值,比如数字(0)、字符串(空字符串)、布尔值(false)和 Option(None)。Default 也可以通过 derive 宏轻松实现。

在这一部分,我们将向宏中添加一个属性,用于决定是否使用默认值。以下是一个测试示例。

Listing 8.11 测试默认值

rust 复制代码
#[test]
fn should_use_defaults_when_attribute_is_present() {
    #[derive(Builder)]
    #[builder_defaults]
    struct ExampleStructTwoFields {
        string_value: String,
        int_value: i32,
    }

    let example: ExampleStructTwoFields =
   ExampleStructTwoFields::builder()
            .build();

    assert_eq!(example.string_value, String::default());
    assert_eq!(example.int_value, Default::default());
}

当你尝试运行这个测试时,它会失败,因为 Rust 并不知道 builder_defaults 这个属性。这个问题很容易修复。

Listing 8.12 添加缺失的属性

scss 复制代码
#[proc_macro_derive(Builder, attributes(rename,builder_defaults))]
pub fn builder(item: TokenStream) -> TokenStream {
    create_builder(item.into()).into()
}

现在我们的测试会 panic,因为我们还没有实现相关功能。接下来,我们需要获取这个属性。如果属性存在,我们就使用默认值。我们在 lib.rs 中的代码更改非常简单:检查是否需要使用默认值,并将布尔值传递给需要它的方法。

Listing 8.13 lib.rs 代码更改:检查 builder_defaults 属性

ini 复制代码
use syn::Attribute;

const DEFAULTS_ATTRIBUTE_NAME: &str = "builder_defaults";

pub fn create_builder(item: TokenStream) -> TokenStream {
    let ast: DeriveInput = parse2(item).unwrap();
    let name = ast.ident;
    let builder = format_ident!("{}Builder", name);
    let use_defaults = use_defaults(&ast.attrs);        #1
    // ...
    let set_fields = original_struct_setters(
        fields,
        use_defaults #2
    );                  
    // ...
}

fn use_defaults(attrs: &[Attribute]) -> bool {
    attrs
        .iter()
        .any(|attribute|
             attribute.path().is_ident(DEFAULTS_ATTRIBUTE_NAME))
}

如你所见,属性既可以出现在 DeriveInput 的根部,也可以出现在字段级别。之前,我们将属性添加到了各个字段上。这一次,我们把属性放在了结构体的顶部。因此,我们需要在根部查找它。我们的 use_defaults 辅助函数非常简单。它检查是否存在名为 builder_defaults 的属性,并将结果传递给 original_struct_setters,这是唯一需要了解它的地方。同时,在 fields.rs 中,我们还需要做一些工作,因为我们需要根据这个布尔值做出不同的处理。

Listing 8.14 fields.rs 代码更改

scss 复制代码
pub fn original_struct_setters(fields: &Punctuated<Field, Comma>,
 use_defaults: bool)
    -> Vec<TokenStream> {
    fields.iter().map(|f| {
        let field_name = &f.ident;
        let field_name_as_string = field_name
            .as_ref().unwrap().to_string();

        let handle_type = if use_defaults {          #1
            default_fallback()                      
        } else {
            panic_fallback(field_name_as_string)     #2
        };

        quote! {
            #field_name: self.#field_name.#handle_type
        }
    })
        .collect()
}

fn panic_fallback(field_name_as_string: String) -> TokenStream {
    quote! {
        .expect(concat!("field not set: ", #field_name_as_string))
    }
}

fn default_fallback() -> TokenStream {
    quote! {
        unwrap_or_default()
    }
}

我们添加了一些代码并进行了一些重构。我们将"panic"生成和后备操作移到一个单独的方法中:

scss 复制代码
fn panic_fallback(field_name_as_string: String) -> TokenStream {
    quote! {
        expect(concat!("Field not set: ", #field_name_as_string))
    }
}

fn default_fallback() -> TokenStream {
    quote! {
        unwrap_or_default()
    }
}

正如我们之前提到的,流可以是不能单独执行的代码片段,正如这里所见。这些 expect 并不是有效的独立 Rust 代码,但我们会将它们与其他代码片段组合,直到它们能够被解析为有效的代码。我们还需要为默认值的后备生成代码。幸运的是,Option 提供了一个 unwrap_or_default() 方法,专门用于这种情况:

scss 复制代码
fn default_fallback() -> TokenStream {
    quote! {
        unwrap_or_default()
    }
}

现在我们需要使用"defaults"布尔值来确定行为。我们使用一个简单的 if-else,将结果与字段填充的代码结合起来。通过组合所有这些片段,我们就离有效的 Rust 代码更近了一步,尽管在一切就绪之前,我们仍需要在 lib.rs 中进行一些转换。

Listing 8.15 不返回 Vec 的替代方法

rust 复制代码
pub fn original_struct_setters<'a>(fields: &'a Punctuated<Field, Comma>,
 use_defaults: bool)
        -> Map<Iter<'a, Field>, Box<dyn Fn(&Field) -> TokenStream>>  {    #1
    fields.iter().map(Box::new(move |f| {      #2
        // same as before
    }))                                    #3
}

为了保持 Map,我们需要使用 FnBox,以便 Rust 知道在编译时如何处理大小。我们需要将闭包包装为 Box::new(move |f| ...)move 是必要的,因为我们需要获取 use_defaults 的所有权。这是一项复杂的工作,签名也变得复杂,可能由于包装而影响性能。

另一方面,impl Iterator<Item = TokenStream> + '_ 仍然是一个可接受的替代方案。它的签名保持不变,我们只需要在映射中添加 move,原因如前所述。

通过使用这些解决方案之一,之前编写的测试应该可以通过编译并成功运行。如果你想确保没有破坏之前的代码,可以添加一个测试,检查缺少属性时仍然会发生 panic。

8.4 更好的默认值错误信息

当我们遇到没有实现 Default 的属性时,会发生什么?将 trybuild 添加到项目中,复制之前使用的 compilation_tests.rs,并将其放在 tests/fails 目录下。

Listing 8.16 测试属性没有实现 Default 时的行为

csharp 复制代码
use builder_macro::Builder;

struct DoesNotImplementDefault;      #1

#[derive(Builder)]
#[builder_defaults]
struct ExampleStruct {
    not: DoesNotImplementDefault     #2
}

fn main() {}

#1 这个结构体没有实现 Default。 #2 但是我们在一个使用默认值回退的结构体中使用了它。

Rust 会抛出错误:

rust 复制代码
6 | #[derive(Builder)]
  |          ^^^^^^^ the trait `Default` is not implemented for
 `DoesNotImplementDefault`
  |
note: required by a bound in `Option::<T>::unwrap_or_default`
 --> $RUST/core/src/option.rs
  |
  |         T: ~const Default,
  |            ^^^^^^^^^^^^^^ required by this bound in
 `Option::<T>::unwrap_or_default`
...

这个错误信息足够清楚,用户能够知道问题出在哪里。但它指出的是宏而不是错误属性,信息的精确度不足,令人不悦。

在第 7 章中,我们学习了如何使用自定义错误返回更清晰的错误信息,并且通过 span 可以定位源代码中的具体位置。我们可以尝试利用这一点。受 syn 库示例的启发,一种方法是生成一个空结构体,并给它加上一个 where 子句,要求字段的类型实现 Default。为了生成这段代码,我们使用 quote_spanned,因为它允许我们传递一个 span,这个 span 会应用到生成的代码中。

Listing 8.17 使用 quote_spanned 的例子

rust 复制代码
quote_spanned! {ty.span()=>
    struct ExampleStruct where SomeType: core::default::Default;
}

我们还传递了 Default 的完整路径 core::default::Default,这样可以避免与其他同名 trait(可能来自其他 crates 或用户代码)的混淆。这是一个最佳实践,直到现在我们为了简便一直避开它,以后会在更多章节中详细讨论。

现在我们要做的是遍历字段,通过 itermap,为每个字段添加带有 where 子句的结构体,声明字段的类型实现 Default。如果没有实现,编译器会指向错误的类型,并利用我们传递的 span 提示用户错误。结构体的名称以两个下划线开头,这是避免与用户代码发生冲突的另一个最佳实践。

Listing 8.18 fields.rs 中的附加代码

rust 复制代码
use quote::{format_ident, quote, quote_spanned};
use syn::spanned::Spanned;          #1

pub fn optional_default_asserts(fields: &Punctuated<Field, Comma>)
    -> Vec<TokenStream> {
        fields.iter()
            .map(|f| {
                let name = &f.ident.as_ref().unwrap();
                let ty = &f.ty;
                let assertion_ident = format_ident!( #2
                    "__{}DefaultAssertion",
                    name
                );                

                quote_spanned! {ty.span()=> #3
                  struct #assertion_ident where #ty: core::default::Default;
                }                 
            })
            .collect()
}

#1 我们需要这个 trait 来调用 span()。 #2 为空结构体创建标识符。 #3 告诉 Rust 该类型实现了 Default,并传递字段类型的 span

现在我们只需要将这个 Vec 添加到最终输出中,当属性 default 存在时,使用 # 样式处理多个项。否则,我们只需添加一个空的 token 向量,这样什么也不生成。

Listing 8.19 lib.rs 中的代码

rust 复制代码
pub fn create_builder(item: TokenStream) -> TokenStream {
    // ...
    let default_assertions = if use_defaults {
        optional_default_asserts(fields) #1
    } else {
        vec![]
    };            

    quote! {
        // generate the struct, builder, etc.
        #(#optional_default_assertions)*        #2
    }
}

#1 如果使用默认值,我们就添加断言。如果没有,我们只需要一个空的向量。 #2 添加默认值断言结构体。

现在,如果你尝试传递一个嵌套结构体且没有实现 Default,你会得到更具信息性的错误,指向问题的具体位置:

vbnet 复制代码
error[E0277]: the trait bound `DoesNotImplementDefault: Default`
 is not satisfied
 --> tests/fails/no_default.rs:9:10
  |
9 |     not: DoesNotImplementDefault
  |          ^^^^^^^^^^^^^^^^^^^^^^^ the trait `Default` is not implemented
 for `DoesNotImplementDefault`
  |
  = help: see issue #48214
help: ...

附注: 在回答 Stack Overflow 问题时,我发现了这种方法的变体。一个用户在使用宏生成需要 trait 实现的函数时,遇到的问题是,当类型没有实现 trait 时,错误信息不够有用,它指向了宏调用。解决方案是生成一个标识符,带上没有实现该 trait 的类型的 span。使用这个标识符放入 where 子句中,如果出现问题,Rust 会指向错误的类型和具体位置。

css 复制代码
let trait_ident = Ident::new("MyTrait", problematic_type.span());

如果你在 where 子句中使用 trait_ident,并且出了问题,Rust 会指向错误的位置和类型。

8.5 改进构建

在继续讨论属性之前,如何来个小小的绕道?我保证它还是有一定相关性的------即使不相关,这个技术也很有趣。

8.5.1 避免非法状态与类型状态模式

可能让你困扰的是,我们似乎只有三种选择来处理缺失的属性:默认值、恐慌和返回 Result。我们已经实现了前两种方法,而第三种方法听起来既简洁又符合 Rust 风格。但是,它并不是完美的解决方案,因为在构建我们的示例结构体时,不应允许出现错误。要么你具备所有必需的属性,要么你没有。在前者的情况下,操作总是成功的;在后者的情况下,操作永远不可能成功。这意味着,我们没有理由允许在回退为恐慌时跳过属性。而"默认值变体"要求开发者实现一个 trait,并且还带来一个风险:用户真的是想要默认值吗?还是他们忘记了设置属性?(见图 8.3)

最好的解决方案是将我们的检查转移到类型系统中,使非法状态无法表示。我所说的意思是,最理想的应用程序应该不允许程序员创建运行时问题。相反,我们可以尝试通过调整类型系统,迫使编译器在用户犯错时阻止他们。任何类型系统都能以基本的形式为用户提供这种功能。举个例子,它可以确保像年龄这样的数字不能是字符串、负数或非常大的值。

这听起来可能很熟悉,因为我们在讨论新类型(newtypes)时也提到了这个想法,新类型是一种扩展类型系统功能的方式,使其更具针对性,贴近特定的领域。具体来说,在我们的构建器案例中,我们希望强制用户填写每个必需的属性,并且只有在这些属性都被填写完毕后,才允许调用 build 方法。

我们可以使用类型状态模式来实现这一点,将系统的状态编码到类型参数中。一个简单的例子有助于说明这个概念。我们有一个交通信号灯,它的状态可以是绿色或红色。当它是红色时,再次变成红色是没有意义的(见图8.4)。

不,红灯应该只改变为绿灯,绿灯应该只改变为红灯(绿灯 → 红灯 → 绿灯 → ...;它是一个状态机)(见图8.5)。

列表8.20展示了如何通过使用标记特征来强制执行这种行为,这是一种没有方法或属性的特征,但它的作用是"标记"类型作为某种类型,并通过实现该标记的结构体来实现这个功能。由于Rust不允许未使用的泛型属性,我们必须向TrafficLight添加PhantomData<T>(来自标准库)作为信号:"我对这个泛型有编译时计划。"接下来,我们编写实现块,这些实现块会根据泛型类型的不同而有所不同。只有当泛型参数为Green时,我们才有turn_red方法,它返回一个带有Red结构体作为参数的交通信号灯。而对于TrafficLight<Red>的实现块只有turn_green方法,它返回TrafficLight<Green>

列表8.20 交通信号灯

rust 复制代码
trait Light {}                #1

struct Green {}             
struct Red {}                

impl Light for Green {}      
impl Light for Red {}       

struct TrafficLight<T: Light> { #2
    marker: PhantomData<T>
}                            

impl TrafficLight<Green> { #3
    fn turn_red(&self) -> TrafficLight<Red> {
        TrafficLight {
            marker: Default::default(),
        }
    }
}                             

impl TrafficLight<Red> {
    fn turn_green(&self) -> TrafficLight<Green> {
        TrafficLight {
            marker: Default::default(),
        }
    }
}                           

fn main() {
    let light = TrafficLight { marker: Default::default() };
    light.turn_red().turn_green();     #4
}

#1 我们将使用Light标记特征,以及GreenRed结构体来编码状态。 #2 我们的交通信号灯有一个实现Light(即上面提到的结构体)的泛型参数。 #3 在这里我们将结构体充分利用(例如,只有TrafficLight<Green>实现turn_red方法)。 #4 Rust推断我们从绿色开始,并且只允许这一系列的调用。

结果是?一个由编译器保证只能进行有效状态转换的交通信号灯!Graydon Hoare会为此感到骄傲。

注释:根据传闻,Rust的创造者Hoare在他的公寓电梯因为软件故障无法工作时,受到了启发去创造Rust语言。

8.5.2 将构建者模式与类型状态结合

我们可以对我们的构建器做同样的操作(参见图 8.6)。我们应该:

  1. 创建一个标记 trait。
  2. 为每个接收的字段创建一个没有属性的结构体(我们可以称它们为"字段结构体"),并让它实现标记 trait。
  3. 让我们的构建器接受一个实现了标记 trait 的泛型类型。
  4. 让结构体的构建器方法返回一个构建器,其中泛型类型设置为第一个"字段结构体"(在 Gleipnir 的例子中,这是 roots_of)。
  5. 为该特定泛型参数创建一个实现块,包含一个接受字段(roots_of)并返回下一个泛型参数(breath_of_a_fish)的构建器方法。
  6. 对所有字段重复此操作。
  7. 最终字段不会返回"下一个"泛型参数,因为没有它。相反,它返回一个带有 build 方法的构建器。

我们的设置与前面章节中的设置相同,只是去掉了与属性相关的内容(如重命名和默认值),以免分散注意力。下面的列表展示了一个测试,应该能够成功编译,因为字段是按照正确的顺序调用的。

列表 8.21 正确顺序的构建器使用测试

rust 复制代码
// 宏导入

fn main() {}

#[cfg(test)]
mod tests {
    #[test]
    fn should_work_with_correct_order() {
        #[derive(Builder)]
        struct Gleipnir {
            roots_of: String,
            breath_of_a_fish: u8,
            anything_else: bool,
        }

        let gleipnir = Gleipnir::builder()
            .roots_of("mountains".to_string())
            .breath_of_a_fish(1)
            .anything_else(true)
            .build();

        assert_eq!(gleipnir.roots_of, "mountains".to_string());
    }
}

该测试验证了至少一个字段值的编译和保存。对于错误场景,我们在 tests 文件夹下添加了一个测试,代码位于 fails/missing_prop.rs

列表 8.22 构建器使用编译测试,位于 tests 文件夹

css 复制代码
use builder_macro::Builder;

#[derive(Builder)]
struct Gleipnir {
    roots_of: String,
    breath_of_a_fish: u8,
    anything_else: bool,
}

fn main() {
    Gleipnir::builder()
        .roots_of("mountains".to_string())
        .breath_of_a_fish(1)
        // 缺少最后一个属性
        .build();
}

这应该会因编译时错误而失败,因为我们忘记添加一个属性(即 anything_else)。同时,我们的构建器宏保持不变,但构建器代码发生了很多变化。lib.rs 变得更简单,因为更多的责任已经被委托给了独立的函数。现在,所有这些函数都需要一个结构体名称的引用。我们稍后会解释为什么。

列表 8.23 lib.rs 中的构建器代码

ini 复制代码
pub fn create_builder(item: TokenStream) -> TokenStream {
    // ... 获取结构体名称及其字段
    let builder = builder_definition(&name, fields);
    let builder_method_for_struct = builder_impl_for_struct(&name, fields);
    let marker_and_structs = marker_trait_and_structs(&name, fields);
    let builder_methods = builder_methods(&name, fields);

    quote! {
        #builder
        #builder_method_for_struct
        #marker_and_structs
        #builder_methods
    }
}

在继续实现的重点部分之前,列表 8.24 显示了 util.rs,它提供了一种创建构建器结构体和字段结构体标识符的方法。通过使用这些函数,我们可以避免一些重复,并且如果我们想到更好的名称,修改构建器和附加结构体的名称会更加简便。

列表 8.24 util.rs 中的代码

rust 复制代码
use proc_macro2::Ident;
use quote::format_ident;

pub fn create_builder_ident(name: &Ident) -> Ident {
    format_ident!("{}Builder", name)
}

pub fn create_field_struct_name(builder: &Ident, field: &Ident) -> Ident {
    format_ident!("{}Of{}", field_name, builder_name)
}

由于 fields 中的代码包含大约 170 行,我们将其分为几个部分。我们将忽略 get_name_and_typepanic_fallbackoriginal_struct_setters,因为它们基本上没有变化。

首先,我们创建标记 trait 和结构体。我们为每个字段添加一个标记 trait,并生成一个结构体及其 trait 实现。为了调用 build,我们需要一个额外的结构体。为了简化,标记 trait(MarkerTraitForBuilder)和最终结构体(FinalBuilder)是硬编码的。在生产级别的宏中,最好为它们添加结构体名称和 __ 前缀,使其更加唯一。此外,由于字段名称的原因,这些结构体的名称以小写字母开头并包含下划线,这在 Rust 编译器中会发出警告。

列表 8.25 fields.rs 中的标记 trait 和结构体

ini 复制代码
pub fn marker_trait_and_structs(name: &Ident, fields: &Punctuated<Field, Comma>)
    -> TokenStream {
    let builder_name = create_builder_ident(name);

    let structs_and_impls = fields.iter().map(|f| {
        let field_name = &f.ident.clone().unwrap(); #1
        let struct_name = create_field_struct_name(
            &builder_name,
            field_name
        );                   
        quote! { #2
            pub struct #struct_name {}
            impl MarkerTraitForBuilder for #struct_name {}
        }                  
    });

    quote! {
        pub trait MarkerTraitForBuilder {}    #3

        #(#structs_and_impls)*               

        pub struct FinalBuilder {}                        #4
        impl MarkerTraitForBuilder for FinalBuilder {}   
    }
}
#1 创建正确的标识符
#2 创建结构体并实现硬编码的标记 trait
#3 添加 trait、结构体和实现
#4 添加实现标记的结构体以调用 build

构建器定义也相对简单。我们将更多的责任移到了方法中,并添加了泛型参数和 PhantomData 标记,但除此之外没什么变化。

列表 8.26 fields.rs 中的构建器定义

ini 复制代码
pub fn builder_definition(name: &Ident, fields: &Punctuated<Field, Comma>)
    -> TokenStream {
    let builder_fields = fields.iter().map(|f| {
        let (field_name, field_type) = get_name_and_type(f);
        quote! { #field_name: Option<#field_type> }
    });                #1
    let builder_name = create_builder_ident(name);

    quote! {
        pub struct #builder_name<T: MarkerTraitForBuilder> {
            marker: std::marker::PhantomData<T>, #2
            #(#builder_fields,)*
        }            
    }
}
#1 这是我们之前的代码
#2 构建器现在有一个泛型参数,必须实现我们刚才定义的 trait,并有一个标记属性

接下来是更复杂的部分。在 列表 8.27 中,我们生成了构建器方法,该方法创建空的构建器结构体。初始化部分与之前相同,我们需要结构体名称和构建器名称进行输出。但我们还需要构建器结构体的泛型。由于我们说它应该引用结构体的第一个字段,我们会提取第一个字段(并希望至少有一个;在实际使用中,我们必须检查它是否为空并相应地处理),并使用一个工具来获取正确的结构体名称。

列表 8.27 builder_impl_for_structfields.rs 中的实现

ini 复制代码
pub fn builder_impl_for_struct(name: &Ident, fields: &Punctuated<Field, Comma>) -> TokenStream {
    let builder_inits = fields.iter().map(|f| {
        let field_name = &f.ident;
        quote! { #field_name: None }
    });
    let first_field_name = fields.first().map(|f| {
        f.ident.clone().unwrap()
    }).unwrap();                    #1
    let builder_name = create_builder_ident(name);
    let generic = create_field_struct_name( #2
        &builder_name,
        &first_field_name
    );                          

    quote! {
        impl #struct_name {
            pub fn builder() -> #builder_name<#generic> {
                #builder_name {
                    marker: Default::default(), #3
                    #(#builder_inits,)*
                }
            }                       
        }
    }
}
#1 假设我们至少有一个字段。
#2 使用我们找到的字段标识符来构建正确的"字段结构体"标识符。
#3 构建器现在具有一个泛型类型参数和标记。

最后,我们应该为字段设置生成方法。这个方法很大,所以我们将其分为三个部分。在第一部分中,我们收集一些信息。original_struct_setters 给我们提供了字段的 setter 方法,以便我们最终调用 build,而 get_assignments_for_fields 为构建器结构体设置了所有字段属性。由于在下一个代码片段中可能会变得清晰,我倾向于从最后一个字段开始,所以我们反转了字段向量。

列表 8.28 builder_methodsfields.rs 中:设置

ini 复制代码
pub fn builder_methods(name: &Ident, fields: &Punctuated<Field, Comma>) -> TokenStream {
    let builder_name = create_builder_ident(name);
    let set_fields = original_struct_setters(fields);
    let assignments_for_all_fields = get_assignments_for_fields(fields);
    let mut previous_field = None;
    let reversed_names_and_types: Vec<&Field> = fields
        .iter()
        .rev()
        .collect();
    // ...
}

在下一个片段中,我们有一个 map 和条件分支。当我们开始迭代时,previous_field 会为空,因此我们将进入 else 分支(虽然这并不是故意的,但这是更优的分支选择,因为性能上较不可能的选项放在了最后)。由于我们反转的列表中的第一个字段实际上是结构体的最后一个字段,所以我们应该为最后一个"字段结构体"创建一个实现块并为其生成 setter 方法。

因为我们在条件的两个分支中都设置了 previous_field,所以每次调用都会进入 if 分支。在这里,像之前一样,我们希望为当前的泛型生成一个 setter。但这次,返回类型应该指向下一个字段的泛型参数。由于我们反转了向量,这个参数已经存储在 previous_field 中。

列表 8.29 builder_methodsfields.rs 中:生成方法

scss 复制代码
pub fn builder_methods(struct_name: &Ident, fields: &Punctuated<Field, Comma>) -> TokenStream {
    // ...
    let methods: Vec<TokenStream> = reversed_names_and_types
        .iter()
        .map(|f| {
            if let Some(next_in_list) = previous_field {    #1
                previous_field = Some(f);
                builder_for_field(
                    &builder_name,
                    &assignments_for_all_fields,
                    f,
                    next_in_list
                )
            } else {               #2
                previous_field = Some(f);
                builder_for_final_field(
                    &builder_name,
                    &assignments_for_all_fields,
                    f
                )
            }
        }).collect();

    quote! {
        #(#methods)*

        impl #builder_name<FinalBuilder> {
            pub fn build(self) -> #struct_name {
                #struct_name {
                    #(#set_fields,)*
                }
            }
        }
    }
}
#1 我们已经有了一个字段吗?那就意味着列表中之后还有字段,所以我们返回一个指向下一个"字段结构体"的构建器,它的泛型参数指向下一个字段。
#2 没有"前一个字段"?那就意味着我们到达了最后一个字段,即我们向量中的第一个元素。我们应该指向最终的"字段结构体"。

接下来是这两个在 map 中调用的函数的实现。它们之间的区别很细微。两者都生成一个方法,该方法添加到构建器中,用于设置特定字段并返回构建器,使用"赋值"填充其所有属性。这是必要的,因为我们不能简单地返回 self,因为有泛型类型参数。

主要的区别是,第一个方法(builder_for_field)指向一个类型为下一个字段(next_field_struct_name)的构建器,而第二个方法(builder_for_final_field)生成的构建器类型是 FinalBuilder

列表 8.30 builder_methods 中使用的两个函数

less 复制代码
fn builder_for_field(
        builder_name: &Ident, field_assignments:&Vec<TokenStream>,
        current_field: &Field,
        next_field_in_list: &Field
    ) -> TokenStream {
    let (field_name, field_type) = get_name_and_type(current_field);
    let (next_field_name, _) = get_name_and_type(next_field_in_list);
    let current_field_struct_name = create_field_struct_name(
        &builder_name,
        field_name.as_ref().unwrap()
    );
    let next_field_struct_name = create_field_struct_name(
        &builder_name,
        next_field_name.as_ref().unwrap()
    );

    quote! {
        impl #builder_name<#current_field_struct_name> {
            pub fn #field_name(mut self, input: #field_type) -> #builder_name<#next_field_struct_name> {
                self.#field_name = Some(input);
                #builder_name {
                    marker: Default::default(),
                    #(#field_assignments,)*
                }
            }
        }
    }
}

fn builder_for_final_field(
        builder_name: &Ident,
        field_assignments: &Vec<TokenStream>,
        field: &Field
    ) -> TokenStream {
    let (field_name, field_type) = get_name_and_type(field);
    let field_struct_name = create_field_struct_name(
        &builder_name,
        field_name.as_ref().unwrap()
    );

    quote! {
        impl #builder_name<#field_struct_name> {
            pub fn #field_name(mut self, input: #field_type) -> #builder_name<FinalBuilder> {
                self.#field_name = Some(input);
                #builder_name {
                    marker: Default::default(),
                    #(#field_assignments,)*
                }
            }
        }
    }
}

最后,函数将所有内容聚合在一起,添加了各个实现块以及 build 方法。

列表 8.31 builder_methodsfields.rs 中:输出

php 复制代码
quote! {
    #(#methods)*

    impl #builder_name<FinalBuilder> {
        pub fn build(self) -> #struct_name {
            #struct_name {
                #(#set_fields,)*
            }
        }
    }
}

现在,builder-usage 中的测试应该会成功。而且,根据你的 IDE 如何处理宏,使用构建器时,你应该只会看到正确的方法建议:roots_ofbreath_of_a_fishanything_else,最后是 build。正如我们的编译测试所示,如果跳过了一个属性,构建器将无法编译。Rust 甚至会给出一个线索,告诉你出了什么问题,这要归功于泛型参数:

go 复制代码
error[E0599]: no method named `build` found for struct
 `GleipnirBuilder<anything_elseOfGleipnirBuilder>` in the current scope
  --> tests/fails/missing_prop.rs:16:10
   |
4  | #[derive(Builder)]
   |          ------- method `build` not found for this struct
...
16 |         .build();
   |          ^^^^^ method not found in
 `GleipnirBuilder<anything_elseOfGleipnirBuilder>`
   |
   = note: the method was found for
           - `GleipnirBuilder<FinalBuilder>`

这真的很酷。你迫使宏的使用者正确地使用它,避免了自作自受的错误。而且这一切都由宏自动完成,所有复杂性都被隐藏起来。此外,运行时的影响应该是最小的。标记 trait、空结构体、幻影标记和泛型类型参数仅在编译时发挥作用,并且可以被优化掉。大多数其他代码在我们之前更为简单的构建器中已经存在。

这个例子的一个可能扩展:如果你的结构体中有可选值(即,值被包裹在 Option 中,或者如果用户没有传递任何内容,Default 实现已经足够了),你可以强制用户填充所有必填值,但一旦填写了最后一个必填值,他们应该能够调用任何可选的 setter 方法,并最终调用 build

8.6 避免散布的条件语句

将其轻柔地与原始构建器代码联系起来,我们可以看到像这样的模式可以用于复杂的宏。例如,当前我们对错误处理(panic 或默认值)使用的 if-else 方法在目前是可以接受的,因为它仅限于一个位置。但如果它出现在很多方法中呢?我们将会有很多丑陋的条件语句,把一块逻辑(我们的后备行为)散布到应用程序的各个地方。这会使错误更容易发生,重构变得更加困难。相反,我们可以将这种行为集中起来,同时使其更易于使用。

一种思路是使用一个 Strategy trait,定义需要条件语句的方法,比如生成正确的后备逻辑。然后,你可以创建一个枚举,表示不同的策略,这些策略实现该 trait。

注意:策略模式(Strategy Pattern)是"设计模式四人帮"中的一种设计模式。它为算法创建了独立的对象,并将具体的选择隐藏在一个接口后面。这使得切换算法变得更加容易,而无需改变代码库的其他部分。在这里,我们使用了 trait 代替接口,使用一个包含两个变体的单一枚举代替多个对象。

列表 8.32 Strategy trait 和实现其方法的枚举

rust 复制代码
trait Strategy {
    fn fallback(&self, field_type: &Type, field_name_as_string: String)
        -> TokenStream;
}

enum ConcreteStrategy {
    Default,
    Panic,
}

impl Strategy for ConcreteStrategy {
    fn fallback(&self, field_type: &Type, field_name_as_string: String)
        -> TokenStream {
        match self {
            ConcreteStrategy::Default => {
                quote! {
                    unwrap_or_default()
                }
            }
            ConcreteStrategy::Panic => {
                // 类似的实现
            }
        }
    }
}

在宏中,你可以根据我们 defaults 属性的存在与否来决定使用哪种策略,获取正确的枚举变体,然后将其传递到相应的方法中。

列表 8.33 传递并使用策略的示例

scss 复制代码
fn original_struct_setters<T>(strategy: &T, fields: &Punctuated<Field, Comma>)
    -> Vec<TokenStream> where T: Strategy {
    fields.iter()
        .map(|f| {
            let (field_name, field_type) = get_name_and_type(f);
            let field_name_as_string = field_name
                .as_ref()
                .unwrap()
                .to_string();
            let handle_type = strategy.fallback(
                field_type,
                field_name_as_string
            );

            quote! {
                #field_name: self.#field_name.#handle_type
            }
        })
        .collect()
}

对于我们当前的宏来说,这种抽象已经足够了。但是在一个更复杂的设置中,如果需要更多地引导开发人员,我们可以添加类型状态,从方法中返回中间状态,只有最终状态返回输出的 TokenStream。这样,宏运行所需的所有内容都必须是齐全的。一个有用的属性是 #[must_use],它在一个必需的返回值未被使用时会发出警告。

8.7 属性令牌和属性

我们已经提到过,只有 derive 和属性宏(attribute macros)支持属性。(函数式宏非常强大,你可以模仿它们,正如我们很快会看到的那样。)但我们只讨论了 derive 宏。它们之间有什么区别吗?

答案是肯定的。虽然 derive 宏的属性是惰性处理的,但属性宏(包括任何附加的属性)是激活的,这意味着它们在属性处理过程中会被移除。这可能是最合理的做法,因为例如,一个属性宏可能不会产生任何输出(参见第9章的核心示例)。如果附加到某个项上的属性已不再存在,保留该属性在源代码中还有意义吗?另一个区别是,属性宏的属性格式更加灵活,而 derive 宏需要在特定的属性(attributes)中指定属性。

作为一个简单的属性宏示例,我们可以回到我们的"public fields"宏,在其中添加一个"exclude"属性,允许你指定不应该公开的属性。我们不会重新讨论所有(未改变的)设置,但列表 8.34列表 8.35 显示了 src 下的两个文件。如你所见,我们修改了宏调用的方式。现在,我们有了额外的信息,#[public(exclude(fourth, third))],而不是简单的 #[public]。我们还添加了一个公共的"构造函数",以便我们可以在其他模块中创建结构体。以下列出了我们期望的结果:first 成为公共的,而其他属性的可见性保持不变。

列表 8.34 example.rs,包含一个结构体

rust 复制代码
use make_public_macro::public;

#[public(exclude(fourth, third))]        #1
struct Example {
    first: String,
    pub second: u32,
    third: bool,
    fourth: String,
}

impl Example {
    pub fn new() -> Self { #2
        Example {
            first: "first".to_string(),
            // etc.
        }
    }                 
}
#1 我们排除了属性 `third` 和 `fourth`,使它们不会变成公共的。
#2 我们需要一个公共方法来创建该结构体,因为某些属性是私有的。

在我们的主文件中,我们将进行一个简单的测试,检查编译是否成功。

列表 8.35 main.rs,展示如何使用我们的示例结构体

rust 复制代码
use crate::example::Example;

mod example;

fn main() {
    let e = Example::new();
    println!("{}", e.first);           #1
    println!("{}", e.second);         
    // println!("{}", e.third);        #2
}
#1 这些会正常工作,因为我们将其设为公共的。
#2 这里我们预期会出现错误:`Example` 结构体的 `third` 字段是私有的。

现在我们转到库代码和一些显著的区别。使用 derive 宏时,我们的属性位于令牌流的根部或单个字段上。使用属性宏时,属性仍然可以在单个字段上找到,其他属性仍然可以在抽象语法树(AST)的根部找到。但有一点与 derive 宏非常不同:我们的注解非常灵活。derive 宏始终是 #[derive(...)],而属性宏的调用可以被灵活地调整。那么,如果属性不在 AST 的根部,它的所有信息会去哪里呢?现在是时候查看这个宏的第一个 TokenStream 参数了。如果你打印当前示例的 TokenStream,你将得到类似以下的内容:

yaml 复制代码
[Ident { ident: "exclude" }, Group { delimiter: Parenthesis, stream: TokenStream [Literal { kind: Str, symbol: "fourth, third", suffix: None }]}]

看!这是我们为宏添加的属性。

现在我们知道该从哪里查看,我们可以继续处理宏的入口点。请注意,我们现在将 attr 参数传递给第二次 parse_macro_input 调用,并将结果放入一个名为 ExcludedFields 的自定义结构体中。然后,我们使用添加到该结构体中的自定义方法来检测字段是否被排除。如果被排除,我们就返回现有的可见性,否则我们将字段设为公共的。

列表 8.36 我们的公共函数与自定义结构体解析第一个参数

ini 复制代码
// imports

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

    let fields = // 仍然获取字段
    let builder_fields = fields.iter().map(|f| {
        let name = &f.ident;
        let ty = &f.ty;
        let vis = &f.vis;

        if excluded_fields.matches_ident(name) {      #2
            quote! { #vis #name: #ty }
        } else {
            quote! { pub #name: #ty }
        }
    });

    // 返回输出
}
#1 我们将属性的 `TokenStream` 解析到一个自定义结构体中。
#2 我们使用自定义方法来确定字段是否被排除。

好的,现在很明显,这一次我们采取了一个结构体的方式来解析输入,ExcludedFields 作为一个包含字符串向量的包装器。该结构体实现了 Parse,因为否则我们不能将它传递给 parse_macro_input。正如我们所知,目前只有一个属性,它看起来是这样的:exclude(...)。因此,我们知道我们处理的是 MetaList,并将其解析为此类。Meta 作为更高一层也能正常工作,但那样会没有意义,因为我们只关心获取一个列表,而不是路径或名称值。这意味着我们可以跳过一层抽象,直接选择 MetaList(见图 8.7)。

一旦我们获得了元列表(MetaList),我们检查路径属性,以查看是否存在我们的 exclude 注解。如果存在,我们使用 parse_terminated 将其解析为一个标识符的 Punctuated,并将它们转换为字符串。

列表 8.37 ExcludedFields 实现(适用于 syn 主版本 2)

rust 复制代码
const EXCLUDE_ATTRIBUTE_NAME: &str = "exclude";

struct ExcludedFields {
    fields: Vec<String>,
}

impl ExcludedFields {
    fn matches_ident(&self, name: &Option<Ident>) -> bool {
        name.as_ref().map(|n| n.to_string())
            .map(|n| self.fields.iter().any(|f| *f == n))
            .unwrap_or_else(|| false)
    }
}

impl Parse for ExcludedFields {
    fn parse(input: ParseStream) -> Result<Self, syn::Error> {
        match input.parse::<MetaList>() {       #1
            Ok(meta_list) => {
                if meta_list.path
                    .segments
                    .iter()
                    .find(|s| s.ident == EXCLUDE_ATTRIBUTE_NAME)
                    .is_some()                  #2
                {
                    let parser = Punctuated::<Ident, Token![,]>
                        ::parse_terminated;
                    let identifiers = parser.parse(
                        meta_list.clone().tokens.into()
                    ).unwrap();
                    let fields = identifiers.iter()
                        .map(|v| v.to_string())
                        .collect();                #3
                    Ok(ExcludedFields { fields })
                } else {
                    Ok(ExcludedFields { fields: vec![] })
                }
            }
            Err(_) => Ok(
                ExcludedFields { fields: vec![] } #4
            )                
        }
    }
}
#1 将我们的令牌解析为 MetaList
#2 检查路径中是否存在该属性
#3 从令牌的属性中获取值并存储
#4 如果解析失败,假设没有需要排除的项。

如果我们使用 exclude("fourth", "third"),它将是一个由 LitStr 组成的 Punctuated,并且 map 将调用 value 方法。其他代码保持不变。

需要注意的是,这是为 syn 主版本 2 编写的代码。在版本 1 中,你会有一个有用的,但有限制的结构体叫做 AttributeArgs,它是 Vec<NestedMeta> 的别名。为了获取排除的项,你可以像以下列表所示那样做(请注意第一个匹配中的"匹配守卫")。

列表 8.38 在 syn 1 中解析 AttributeArgsMetaList

scss 复制代码
fn properties_to_exclude(args: AttributeArgs) -> Vec<String> {
    args.iter()
        .flat_map(|a| {
            match a {
                Meta(List(MetaList {
                    path: Path { segments, .. },
                    nested,
                    ..
                })) if segments.iter()
                    .find(|s| s.ident == EXCLUDE_ATTRIBUTE_NAME)
                    .is_some() => {
                        nested
                        .iter()
                        .map(|v| match v {
                            Lit(Str(l)) => l.value(),
                            _ => unimplemented!(
                              "expected at least one args between
                    brackets"
                            ),
                        })
                        .collect()
                    },
                _ => vec![],
            }
        })
        .collect()
}

这与我们当前的实现非常相似,但并不完全相同。主要区别在于,在 if 分支内,我们使用了 MetaListnested 属性(在此代码示例中,它包含了一个 LitStr 的列表)。

一种比当前方法更现代的替代方案是使用 syn::meta::parser列表 8.39 中的实现与文档中的示例非常相似。我们使用 parse_macro_input!(attr with attr_parser) 来传入自定义的属性解析器,这个解析器在前一行使用 syn::meta::parser 定义。实际的解析任务由我们的自定义结构体完成,它检查路径属性中是否存在 exclude 属性,如果收到其他内容则抛出错误。在属性内,我们通过 parse_nested_meta 深入解析,并获取标识符,将它们添加到我们的 fields 向量中。

列表 8.39 元数据解析器

rust 复制代码
#[derive(Default)]
struct AlternativeExcludedFields {
    fields: Vec<String>,
}

impl AlternativeExcludedFields {
    fn matches_ident(&self, name: &Option<Ident>) -> bool {
        // 同 ExcludedFields 中的实现
    }
}

impl AlternativeExcludedFields {
    fn parse(&mut self, meta: ParseNestedMeta) -> Result<(), syn::Error> {
        if meta.path.is_ident(EXCLUDE_ATTRIBUTE_NAME) {
            meta.parse_nested_meta(|meta| {
                let ident = &meta.path.segments.first().unwrap().ident;
                self.fields.push(ident.to_string());
                Ok(())             
            })
        } else {
            Err(meta.error("unsupported property"))
        }
    }
}

#[proc_macro_attribute]
pub fn public(attr: TokenStream, item: TokenStream) -> TokenStream {
    let ast = parse_macro_input!(item as DeriveInput);
    let mut excluded_fields = AlternativeExcludedFields::default();
    let attr_parser = syn::meta::parser(|meta| excluded_fields.parse(meta));
    parse_macro_input!(attr with attr_parser);
    // 其他代码保持不变
}

一个很好的地方是,库会为我们处理空的属性流,而早期我们需要捕获 Err 并返回一个空的 Vec。它还应该能在遇到意外输入时生成"合理的错误消息"(docs.rs/syn/latest/...)。

8.8 其他属性

本章我们没有探讨其他种类的属性。syn 文档列出了六种类型的属性:

  1. 外部属性,比如 #[repr(transparent)],位于项的外部或前面。
  2. 内部属性,例如 #![feature(proc_macro)],位于项内部。
  3. 外部和内部的单行文档注释(/////!)。
  4. 外部和内部的文档块注释(/* *//! */)。

对于这四种文档属性,并没有什么特别之处。事实上,正如文档所述,在宏执行之前,注释会被转换为普通的属性(#[doc = r"your comment text here"])。我们将使用一个简单的示例来展示解析文档的其他方式,并进一步解析属性。既然我们说过,函数式宏可以模仿属性宏(如果需要),我们将编写一个这样的宏。

以下列出了我们的"用法代码",这是一个简单的宏调用示例,作用于一个包含所有四种注释类型的结构体。

列表 8.40 main.rs,包含用于函数式宏的示例结构体

rust 复制代码
use other_attributes_macro::analyze;

analyze!(
    /// 外部注释
    /** 注释块 */
    struct Example {
        //! 内部注释
        /*! 内部注释块 */
        val: String
    }
);

fn main() {}

接下来,我们将这些输入解析成一个结构体。外部注释是我们首先遇到的,为了解析它们,我们可以将 Attribute::parse_outercall 结合使用,自动将注释转换为一个包含两个属性的向量。Attribute 中最有趣的字段是 meta,它包含了注释的内容------在这种情况下,外部注释和注释块。你还可以看到转换成 #[doc = r"..."] 的痕迹,这是在我们介入之前完成的:

css 复制代码
Meta::NameValue { path: Path { ..., segments: [PathSegment { ident: Ident { ident: "doc" } }] }, eq_token: Eq, value: Expr::Lit { attrs: [], lit: Lit::Str { token: " outer comment" } } }

为了获取内部注释,我们需要移除一些东西,比如 struct 关键字和标识符(Example),这是我们通过前两个解析调用实现的。接下来,我们使用 syn 中专门处理大括号的宏:braced,该宏只有在启用 syn 的解析功能时才可用。braced 会将输入中大括号内的整个内容放入我们传递的变量 content 中。接着,我们使用 Attribute::parse_inner 来获取内部注释,同样返回一个 Vec<Attribute>

列表 8.41 lib.rs 中解析注释的代码

css 复制代码
#[derive(Debug)]
struct StructWithComments {
    ident: Ident,
    field_name: Ident,
    field_type: Type,
    outer_attributes: Vec<Attribute>,
    inner_attributes: Vec<Attribute>,
}

impl Parse for StructWithComments {
    fn parse(input: ParseStream) -> Result<Self, syn::Error> {
        let outer_attributes = input.call(Attribute::parse_outer) #1
            .unwrap();                   
        let _: Token![struct] = input.parse().unwrap();
        let ident: Ident = input.parse().unwrap();

        let content;
        let _ = braced!(content in input);    #2
        let inner_attributes = content.call(Attribute::parse_inner)
            .unwrap();                        #3
        let field_name: Ident = content.parse().unwrap();
        let _: Colon = content.parse().unwrap();
        let field_type: Type = content.parse().unwrap();

        Ok(StructWithComments {
            ident,
            field_name,
            field_type,
            outer_attributes,
            inner_attributes,
        })
    }
}

#[proc_macro]
pub fn analyze(item: TokenStream) -> TokenStream {
    let _: StructWithComments = parse_macro_input!(item);
    quote!().into()
}
#1 使用 `Attribute::parse_outer`,结合 `call`,解析外部属性
#2 调用 `braced` 宏获取大括号内的内容(`{ }`)
#3 使用 `Attribute::parse_inner` 获取内部属性

我们通过解析字段名称和类型完成了 Parse 实现。由于我们想简要介绍如何解析注释,以及展示解析此类注释的一些附加工具,我们没有考虑诸如多个字段等复杂性,也没有打算从宏中返回任何输出。

注意 :提醒一下,当你将 TokenStream 解析为一个结构体时,syn 期望你解析它找到的所有内容。如果你只从大括号内的内容中获取内部属性,而不处理其余部分,你将会收到一个意外令牌错误,指向你未解析的第一个内容。在我们的示例中,我们解析了所有内容以避免这种情况。但当你不关心剩余内容时,可以将剩余部分放入一个被忽略的令牌流中:let _: TokenStream2 = content.parse().unwrap();。你甚至可以将令牌流保留在它的 Result 中。

8.9 来自现实世界的例子

让我们来看一些现实世界中的示例,首先是来自 Tokio 的一个简单示例。当你在测试宏 (#[test]) 内时,会进行检查,验证是否没有多次添加测试注解:

javascript 复制代码
if let Some(attr) = input.attrs.iter().find(|a| a.path.is_ident("test")) {
    let msg = "second test attribute is supplied";
    Err(syn::Error::new_spanned(attr, msg))
} else {
    // ...
}

与此同时,Yew 具有逻辑来决定是否应该在其构建器结构体中使用原始结构体的属性。像 Tokio 一样,它使用 is_ident 方法与字符串进行比较:

rust 复制代码
impl Parse for DerivePropsInput {
    fn parse(input: ParseStream) -> Result<Self> {
        let input: DeriveInput = input.parse()?;
        // ...
        let preserved_attrs = input
            .attrs
            .iter()
            .filter(|a| should_preserve_attr(a))
            .cloned()
            .collect();
        Ok(Self {
            // ...
            preserved_attrs,
        })
    }
}

fn should_preserve_attr(attr: &Attribute) -> bool {
    let path = &attr.path;
    path.is_ident("allow") || path.is_ident("deny") || path.is_ident("cfg")
}

正如所述,Serde 在每个附加属性前面都会加上 serde,如下所示,在其派生入口点中就体现了这一点:

rust 复制代码
#[proc_macro_derive(Deserialize, attributes(serde))]
pub fn derive_deserialize(input: TokenStream) -> TokenStream {
    let mut input = parse_macro_input!(input as DeriveInput);
    // ...
}

它有很多代码来处理不同种类的元数据。以下是一个非常简化的片段。它首先检查属性是否是 serde 的属性。完成此操作后,宏需要知道括号内的内容,因此它使用 parse_nested_meta(来自 syn 版本 1;在写作时,许多库仍使用版本 1)并对像 RENAME 这样的内容作出反应:

scss 复制代码
pub fn from_ast(cx: &Ctxt, item: &syn::DeriveInput) -> Self {
    // ...
    for attr in &item.attrs {
        if attr.path() != SERDE {
            continue;
        }

        if let Err(err) = attr.parse_nested_meta(|meta| {
            if meta.path == RENAME {
                let (ser, de) = get_renames(cx, RENAME, &meta)?;
                // ...
            } else if meta.path == RENAME_ALL {
                let one_name = meta.input.peek(Token![=]);
                let (ser, de) = get_renames(cx, RENAME_ALL, &meta)?;
                // ...
            } else if meta.path == DEFAULT {
                if meta.input.peek(Token![=]) {
                    // ...
                } else {
                    // ...
                }
            } else {
                let path = meta.path.to_token_stream()
                    .to_string()
                    .replace(' ', "");
                return Err(
                    meta.error(
                        format_args!(
                            "unknown serde container attribute `{}`", path
                        )
                    )
                );
            }
            Ok(())
        }) {
            cx.syn_error(err);
        }
    }
    // ...
}

你会在流行的 crate 中发现更多的属性处理代码,即使其中很少有像 Serde 那样提供如此广泛定制的代码。其代码甚至将在第一个练习的解决方案中简要出现。

相关推荐
xuanfengwuxiang7 小时前
安卓帧率获取
android·python·测试工具·adb·性能优化·pycharm
SomeB1oody10 小时前
【Rust自学】7.2. 路径(Path)Pt.1:相对路径、绝对路径与pub关键字
开发语言·后端·rust
SomeB1oody10 小时前
【Rust自学】7.3. 路径(Path)Pt.2:访问父级模块、pub关键字在结构体和枚举类型上的使用
开发语言·后端·rust
LONGYIKEJI10 小时前
镍氢电池材料合金在电池中的应用与性能优化
性能优化
HelloZheQ10 小时前
深入了解 Java 字符串:基础、操作与性能优化
java·python·性能优化
SomeB1oody12 小时前
【Rust自学】6.3. 控制流运算符-match
开发语言·前端·rust
undeflined12 小时前
vite + vue3 + tailwind 启动之后报错
开发语言·后端·rust
人类群星闪耀时14 小时前
利用AI进行系统性能优化:智能运维的新时代
运维·人工智能·性能优化
Vuhao14 小时前
性能优化:加载优化——提升用户体验的关键
前端·性能优化
SomeB1oody14 小时前
【Rust自学】6.4. 简单的控制流-if let
开发语言·前端·rust