过程宏(和自定义导出)

在本书接下来的部分,你将看到 Rust 提供了一个叫做“导出(derive)”的机制来轻松的实现 trait。例如,

#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

is a lot simpler than

struct Point {
    x: i32,
    y: i32,
}

use std::fmt;

impl fmt::Debug for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Point {{ x: {}, y: {} }}", self.x, self.y)
    }
}

Rust 包含很多可以导出的 trait,不过也允许定义你自己的 trait。我们可以通过一个叫做“过程宏”的 Rust 功能来实现这个效果。最终,过程宏将会允许 Rust 所有类型的高级元编程,不过现在只能自定义导出。

Hello World

首先需要做的就是为我们的项目新建一个 crate。

$ cargo new --bin hello-world

我们想要实现的就是能够在导出的类型上调用hello_world()。就想这样:

#[derive(HelloWorld)]
struct Pancakes;

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

再来一些给力的输出,比如“Hello, World! 我叫煎饼(←_←)。”

继续并从用户的角度编写我们的宏。在src/main.rs中:

#[macro_use]
extern crate hello_world_derive;

trait HelloWorld {
    fn hello_world();
}

#[derive(HelloWorld)]
struct FrenchToast;

#[derive(HelloWorld)]
struct Waffles;

fn main() {
    FrenchToast::hello_world();
    Waffles::hello_world();
}

好的。现在我们只需实际编写我们的过程宏。目前,过程宏需要位于它自己的 crate 中。最终这个限制会解除,不过现在是必须的。为此,有一个惯例是,对于一个叫foo的 crate,一个自定义的过程宏叫做foo-derive。让我们在hello-world项目中新建一个叫做hello-world-derive的 crate。

$ cargo new hello-world-derive

为了确保hello-world crate 能够找到这个新创建的 crate 我们把它加入到项目 toml 文件中:

[dependencies]
hello-world-derive = { path = "hello-world-derive" }

这里是一个hello-world-derive crate 源码的例子:

extern crate proc_macro;
extern crate syn;
#[macro_use]
extern crate quote;

use proc_macro::TokenStream;

#[proc_macro_derive(HelloWorld)]
pub fn hello_world(input: TokenStream) -> TokenStream {
    // Construct a string representation of the type definition
    let s = input.to_string();
    
    // Parse the string representation
    let ast = syn::parse_derive_input(&s).unwrap();

    // Build the impl
    let gen = impl_hello_world(&ast);
    
    // Return the generated impl
    gen.parse().unwrap()
}

这里有很多内容。我们引入了两个新的 crate:synquote。你可能注意到了,input: TokenSteam直接就被转换成了一个String。这个字符串是我们要导出的HelloWorldRust 代码的字符串形式。现在,能对TokenStream做的唯一的事情就是把它转换为一个字符串。将来会有更丰富的 API。

所以我们真正需要做的是能够把 Rust 代码解析成有用的东西。这正是syn出场机会。syn是一个解析 Rust 代码的 crate。我们引入的另外一个 crate 是quote。它本质上与syn是成双成对的,因为它可以轻松的生成 Rust 代码。也可以自己编写这些功能,不过使用这些库会更加轻松。编写一个完整 Rust 代码解析器可不是一个简单的工作。

这些代码注释提供了我们总体策略的很好的解释。我们将为导出的类型提供一个String类型的 Rust 代码,用syn解析它,(使用quote)构建hello_world的实现,接着把它传递回给 Rust 编译器。

最后一个要点:这里有一些unwrap(),如果你要为过程宏提供一个错误,那么你需要panic!并提供错误信息。这里,我们从简实现。

好的,让我们编写impl_hello_world(&ast)

fn impl_hello_world(ast: &syn::MacroInput) -> quote::Tokens {
    let name = &ast.ident;
    quote! {
        impl HelloWorld for #name {
            fn hello_world() {
                println!("Hello, World! My name is {}", stringify!(#name));
            }
        }
    }
}

这里就是quote出场的地方。ast参数是一个代表我们类型(可以是一个structenum)的结构体。查看文档。这里有一些有用的信息。我们可以通过ast.ident获取类型的信息。quote!宏允许我们编写想要返回的 Rust 代码并把它转换为Tokensquote!让我们可以使用一些炫酷的模板机制;简单的使用#namequote!就会把它替换为叫做name的变量。你甚至可以类似常规宏那样进行一些重复。请查看这些文档,这里有一些好的介绍。

应该就这些了。噢,对了,我们需要在hello-world-derive crate 的cargo.toml中添加synquote的依赖。

[dependencies]
syn = "0.10.5"
quote = "0.3.10"

这样就 OK 了。尝试编译hello-world

error: the `#[proc_macro_derive]` attribute is only usable with crates of the `proc-macro` crate type
 --> hello-world-derive/src/lib.rs:8:3
  |
8 | #[proc_macro_derive(HelloWorld)]
  |   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

好吧,看来我们需要把hello-world-derive crate 声明为proc-macro类型。怎么做呢?像这样:

[lib]
proc-macro = true

现在好了,编译hello-world。现在执行cargo run将会输出:

Hello, World! My name is FrenchToast
Hello, World! My name is Waffles

我们成功了!

自定义 attribute(Custom Attributes)

在一些情况下允许用户进行一些配置是合理的。例如,用户可能想要重载hello_world()方法打印出的名字的值。

这可以通过自定义 attribute 来实现:

#[derive(HelloWorld)]
#[HelloWorldName = "the best Pancakes"]
struct Pancakes;

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

但是如果我们尝试编译它,编译器会返回一个错误:

error: The attribute `HelloWorldName` is currently unknown to the compiler and may have meaning added to it in the future (see issue #29642)

编译器需要知道我们处理了这个 attribute 才能不返回错误。这可以通过在hello-world-derive crate 中对proc_macro_derive attribute 增加attributes来实现:

#[proc_macro_derive(HelloWorld, attributes(HelloWorldName))]
pub fn hello_world(input: TokenStream) -> TokenStream 

可以一同样的方式指定多个 attribute。

引发错误

让我们假设我们并不希望在我们的自定义导出方法中接受枚举作为输入。

这个条件可以通过syn轻松的进行检查。不过我们如何告诉用户,我们并不接受枚举呢?在过程宏中报告错误的传统做法是 panic:

fn impl_hello_world(ast: &syn::MacroInput) -> quote::Tokens {
    let name = &ast.ident;
    // Check if derive(HelloWorld) was specified for a struct
    if let syn::Body::Struct(_) = ast.body {
        // Yes, this is a struct
        quote! {
            impl HelloWorld for #name {
                fn hello_world() {
                    println!("Hello, World! My name is {}", stringify!(#name));
                }
            }
        }
    } else {
        //Nope. This is an Enum. We cannot handle these!
       panic!("#[derive(HelloWorld)] is only defined for structs, not for enums!");
    }
}

如果用户尝试从一个枚举导出HelloWorld,他们会收到如下希望有帮助的错误信息:

error: custom derive attribute panicked
  --> src/main.rs
   |
   | #[derive(HelloWorld)]
   |          ^^^^^^^^^^
   |
   = help: message: #[derive(HelloWorld)] is only defined for structs, not for enums!