关于宏

Macros

贯穿这本书,咱们业已用到像是 println! 这样的宏,但咱们并未完整地探讨过何为宏,以及其工作原理。 宏,macro 这个术语,指的是 Rust 中的一个特性家族:有着 macro_rules!声明式,declarative 宏,与如下三种 程序性,procedural 宏:

  • 指明一些在结构体及枚举上以 derive 属性添加代码的 定制 #[derive] 的宏,custome #[derive] macros that specify code added with the derive attribute used on structs and enums;
  • 定义出一些可在任何项目上使用的一些定制属性的 类属性宏,attribute-like macros that define custom attributes usable on any item;
  • 看起来像函数调用,但是在一些指定为其参数的令牌上操作的 类函数宏,function-like macros that look like function calls but operate on the tokens specified as their argument。

咱们将逐个讲到这每个的宏,但首先来看看,为何在已有函数的情况下,咱们还需要宏?

:宏似乎与 Java 及 Python 等语言中的装饰器类似?

宏与函数的区别

The Difference Between Macros and Functions

根本上讲,宏是一种编写其他代码的代码编写方式,这种方式被称作 元编程,metaprogramming。在附录 C 中,咱们会讨论那个 derive 属性,其会为咱们生成各种特质的实现。遍布这本书,咱们也已用到了 println!vec! 两个宏。全部这些宏,都会 展开,expand 来产生相比于咱们手写代码更多的代码。

对于降低咱们所必须编写与维护代码量,元编程是有用的,这也是函数的角色之一。但是,宏有着函数所没有的一些额外能力。

函数签名必须要声明该函数所有的参数个数与类型。而另一方面的宏,则可以取数目不定的参数:咱们可以一个参数调用 println! ("你好"),或以两个参数调用 println! ("你好 {}", name)。同时,宏是在编译器对代码的意义加以解译之前展开的,因此宏就可以,比如在给到他的类型上实现某个特质。由于函数是在运行时被调用的,而特质需要在编译时被实现,故函数没办法做到这点。

实现宏而非函数的缺点,就是因为咱们是在编写那些编写出 Rust 代码的代码,所以宏定义要比函数定义更为复杂。由于这种间接性,相比于函数定义,宏定义一般都更难阅读、理解及维护。

宏与函数的另一重要区别,便是咱们必须于某个文件中调用宏 之前,定义好他们或将他们带入到作用域中,这一点与可在任何地方定义并在任何地方调用的函数相反。

用于通用元编程的带有 macro_rules! 的声明式宏

Declarative Macros with macro_rules! for General Metaprogramming

Rust 中使用最广泛的宏形式,就是 声明式宏,declarative macro。这些宏有时也被指为 “示例性宏,macros by example”,“macro_rules! 宏”,或仅被指为 “宏,macros”。声明式宏的核心,便是实现编写出类似于 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! 宏,构造出两个整数的矢量值,或是五个字符串的矢量值。由于咱们预先不会知道值数目和类型,因此是无法使用函数完成这同样事情的。

下面清单 19-28 给出了稍微简化后的 vec! 宏的定义。

文件名:src/lib.rs

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

清单 19-28:vec! 宏定义的简化版本

注意:标准库中 vec! 宏的具体定义,包含了预先分配正确数量内存的代码。在这里咱们为了令到这个示例更为简单,而并未包含那些属于优化的代码。

其中的 #[macro_export] 注解,表明当这个宏被定义的代码箱,被带入到作用域的时候,这个宏就应成为可用。若没有这个注解,那么该宏就无法被带入到作用域。

随后咱们以 macro_rules!不带 感叹号的咱们正定义宏的名字,开始该宏的定义。在此示例总,名字即为 vec,其后跟着表示宏定义代码体,the body of the macro definition, 的一对花括号。

vec! 宏代码体中的结构,与 match 表达式的结构类似。在这里咱们有着一个带有模式 ( $( $x:expr ),* ),跟着 => 及与这个模式关联代码块的支臂。在该模式匹配是,那个关联代码块将被运行,be emitted。鉴于这是这个宏中的唯一支臂,那么就只有一种要匹配有效方式;任何其他模式都将导致报错。那些更为复杂的宏,则将有着多于一个的支臂。

由于宏的那些模式,始于 Rust 代码结构而非一些值相匹配的,因此宏定义中有效的模式语法,不同于第 18 章中所涉及的模式语法。咱们来看看,清单 19-28 中各个模式片段,分别表示什么;对于宏的完整模式语法,请参见 Rust 参考手册

首选,咱们使用了一对圆括号,把整个模式包括起来。咱们使用一个美元符号($),来声明出在宏系统中的,一个将要包含与这个模式匹配的 Rust 代码的变量,we use a dollar sign($) to declare a variable in the macro system that will contain the Rust code matching the pattern。这个美元符号明确了这是个宏变量,而非一个常规 Rust 变量。接下来是捕获用于替换代码中的,与圆括号中模式匹配的那些值的一对圆括号,next comes a set of parentheses that captures values that match the pattern within the parentheses for use in the replacement code。在 $() 里的,为 $x:expr,这会与任意 Rust 表达式匹配,并把那个表达式命名为 $x

$() 之后的逗号,表明在匹配 $() 中代码的代码之后,可选择性地出现一个字面的逗号分隔符。那个 * 指出了该模式会与零个或更多的 * 之前的东西匹配。

当咱们以 vec! [1, 2, 3]; 调用这个宏时,$x 就会分别与表达式 123 匹配三次。

现在来看看与这个支臂关联的代码体中的模式:对于匹配了模式中 $() 的各个部分,根据该模式匹配的次数,$()* 里的 temp_vec.push() 会被零次或更多次生成。其中的 $x 会被各个匹配的表达式替换。当咱们以 vec! [1, 2, 3]; 调用这个宏时,所生成的替换这个宏的代码,将是下面这样:

#![allow(unused)]
fn main() {
{
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec
}
}

咱们就已定义了可取任意数目、任意类型参数,并能生成创建出包含这些特定元素矢量的一个宏了。

要了解更多有关如何编写宏的知识,请参考在线文档或其他资源,比如由 Daniel Keep 起头,Lukas Wirth 续写的 “Rust 宏小册子”

用于从属性生成代码的程序性宏

Procedural Macros for Generating Code from Attributes

宏的第二种形式,便是 程序性宏,procedural macro,其行事更像函数(而是程序的一种类型,a type of procedure)。程序性宏接收一些代码作为输入,在那些代码上加以操作,并产生作为输出的一些代码,而如同非声明式宏所做的那样,与一些模式匹配并以别的代码替换那些代码。程序性宏的三种类别分别是定制派生宏,custom derive、类属性宏,attribute-like 及类函数宏,function-like,且这三种类别的程序性宏,都以类似方式运作。

在创建程序性宏时,那些定义务必要位处有着特别代码箱名字的他们自己的代码箱中。这是由于咱们(Rust 开发团队)希望在今后消除的一些复杂技术原因。在下面清单 19-29 中,咱们给出了如何定义一个程序性宏的方式,其中 some_attribute 是为使用某个特定宏变种的一个占位符。

文件名:src/lib.rs

#![allow(unused)]
fn main() {
use proc_macro;

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

清单 19-29: 定义某个程序性宏的示例

这个定义了某个宏的函数,会取一个 TokenStream 值作为输入,并产生出一个 TokenStream 作为输出。TokenStream 类型是由 Rust 所包含的 proc_macro 代码箱定义,且表示的是一个令牌序列,a sequence of tokens。这个宏的核心如此:该宏在其上操作的源代码,构成了那个输入的 TokenStream,而该宏产生的代码,便是那个输出的 TokenStream。该函数还有一个附加给他的属性,指出咱们正在创建的是何种的程序性宏。在同一代码箱中,咱们可以有着多种类别的程序性宏。

下面就来看看各种不同类别的程序性宏。咱们将以一个定制的派生宏开始,并于随后探讨令到其他那些宏形式有所区别的一些小差异。

怎样编写出定制的 derive

How to Write a Custom derive Macro

咱们就来创建一个名为 hello_macro 的宏,这个宏定义了一个名为 HelloMacro,有着名为 hello_macro 的关联函数的特质。与让咱们的用户为他们的各个类型实现这个 HelloMacro 特质不同,咱们将提供一个程序性宏,如此用户就可以 [derive(HelloMacro)] 注解他们的类型,从而得到那个 hello_macro 函数的默认实现。默认实现将打印出 你好,宏!我的名字是 TypeName!,其中的 TypeName 是这个特质被定义所在类型的名字。也就是说,咱们将编写一些实现其他编程者编写如下清单 19-30 中用到咱们代码箱的代码。

文件名:src/main.rs

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

#[derive(HelloMacro)]
struct Pancakes;

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

当我们完成编写时,此代码将打印 你好,宏!我的名字叫 Pancakes!。第一步是要构造一个新的库代码箱,像下面这样:

$ cargo new hello_macro --lib --vcs none

接下来,咱们将定义那个 HelloMacro 特质及其关联函数:

文件名:src/lib.rs

#![allow(unused)]
fn main() {
pub trait HelloMacro {
    fn hello_macro();
}
}

咱们就有了一个特质及其函数。到这里,咱们代码箱的用户就可以实现这个特质来达成所需功能,像下面这样:

use hello_macro::HelloMacro;

struct Pancakes;

impl HelloMacro for Pancakes {
    fn hello_macro() {
        println! ("你好,宏!我的名字叫 Pancakes!");
    }
}

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

不过,用户们将需要为各种打算使用 hello_macro 特质的类型,编写那个实现的代码块;而咱们原本是要他们免于必须完成这项工作的。

此外,咱们尚不能提供,有着将打印特质被实现在其上类型名字的hello_macro 函数默认实现:Rust 没有反射能力,reflection capabilities,因此他无法在运行时查找处那个类型的名字。咱们需要一个宏,从而在编译时生成代码。

下一步就是要定义这个程序性宏。在编写这个小节的时候,程序性宏是需要在他们自己的代码箱中的。最终这个限制可能会被消除。代码箱的结构组织与宏代码箱方面的约定如下:对于名为 foo 的代码箱,那么定制派生程序性宏代码箱就会叫做 foo_derive。下面就在咱们的 hello_macro 项目内,开启一个名为 hello_macro_derive 的新代码箱:

$ cargo new hello_macro_derive --lib --vcs none

咱们的这两个代码箱是密切相关的,因此咱们是在咱们的 hello_macro 代码箱目录下,创建的这个程序性宏代码箱。而若咱们修改了 hello_macro 中的特质定义,咱们就将不得不也要修改 hello_macro_derive 中那个程序性宏。两个代码箱将需要单独发布,且使用这两个代码箱的程序员,将需要将二者都添加为依赖,并同时把他们都带入到作用域。相反,咱们可以让 hello_macro 代码箱,将 hello_macro_derive 作为依赖使用,并重导出这些程序性宏的代码。然而,咱们阻止结构该项目的这种方式,会让那些不想要 derive 功能的程序员,也可以使用 hello_macro

咱们需要将 hello_macro_derive 代码箱,声明为程序性宏的代码箱。如同马上就会看到的那样,咱们还需要来自 synquote 代码箱的功能,,因此咱们就需要将他们添加为依赖。请将下面的配置,添加到 hello_macro_deriveCargo.toml 文件:

[lib]
proc-macro = true

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

要开始定义这个程序性宏,就要将下面清单 19-31 中的代码,放置于 hello_macro_derive 代码箱的 src/lib.rs 文件中。请注意在咱们添加了 impl_hello_macro 函数定义前,此代码不会编译。

文件名:hello_macro_derive/src/lib.rs

#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use quote::quote;
use syn;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // 以语法树形式,构建出咱们可操作 Rust 代码的表示
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate
    let ast = syn::parse(input).unwrap();

    // 构造出这个特质实现
    impl_hello_macro(&ast)
}
}

清单 19-31:多数程序性宏为处理 Rust 代码而都需要的代码

请注意咱们已经代码分解到 hello_macro_derive 函数中,由其负责解析那个 TokenStream,而其中的 impl_hello_macro 函数,则负责转换那个语法树:这样做令到编写程序性宏更为方便。对于几乎每个咱们所见到的或创建的程序性宏,外层函数(此示例中的 hello_macro_derive)中的代码将是一致的。而咱们在那个内层函数(此示例中的 impl_hello_macro)中指定的代码,将依据咱们程序性宏目的而有所不同。

咱们引入了三个新的代码箱:proc_macrosynquoteproc_macro 代码箱是 Rust 自带的,因此咱们无需将其添加到 Cargo.toml 的依赖。proc_macro 代码箱,是实现从咱们的代码读取及操作 Rust 代码的编译器 API。

syn 代码箱会从一个字符串将 Rust 代码解析为咱们可在其上执行操作的一种数据结构。而 quote 代码箱,则会将 syn 数据结构,转换回 Rust 代码。这些代码箱令到解析任何一种咱们打算处理的 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 数据结构。下面清单 19-32 给出了咱们对 struct Pancakes; 字符串进行解析而得到的 DeriveInput 数据结构的有关部分:

#![allow(unused)]
fn main() {
DeriveInput {
    // --跳过代码--

    ident: Ident {
        ident: "Pancakes",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}
}

清单 19-32:在对清单 19-30 中有着该宏属性的代码进行解析时咱们所得到的 DeriveInput 实例

这个结构体的那些字段显示,咱们所解析的 Rust 是个有着 Pancakesident(标识符,意为名字)的一个单元结构体,a unit struct。此结构体上还有一些用于描述 Rust 各个方面的其他字段;请参阅 有关 DeriveInputsyn 文档 了解更多信息。

很快咱们就将实现那个 impl_hello_macro 函数,其中咱们将构建出咱们所打算包含的新 Rust 代码。但在咱们实现之前,请注意咱们的派生宏输出,同样是个 TokenStream。这个返回的 TokenStream 会添加到咱们代码箱用户编写的代码,因此当他们编译他们的代码箱时,他们将获得咱们在这个修改的 TokenStream 中所提供的额外功能。

咱们或许已经留意到,咱们调用了 unwrap,来在这里的到 syn::parse 函数调用失败时,造成那个 hello_macro_derive 函数终止运行。由于 proc_macro_derive 函数必须返回 TokenStream,而非 Result 来顺应程序性宏的 API,因此咱们的程序性宏就要在出错时终止运行。咱们已通过使用 unwrap 简化了这个示例;在生产代码中,咱们应通过运用 panic!expect,提供有关那些东西出错的更具体的错误消息。

既然咱们有了将经注解的 Rust 代码,从一个 TokenStream 转换为一个 DeriveInput 实例的代码,那么就要生成在被注解类型上实现这个 HelloMacro 特质的代码,如下清单 19-33 中所示。

文件名:hello_macro_derive/src/lib.rs

#![allow(unused)]
fn main() {
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let gen = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println! ("你好,宏!我的名字叫 {}!", stringify! (#name));
            }
        }
    };
    gen.into()
}
}

清单 19-33:是要解析出的 Rust 代码,实现这个 HelloMacro 特质

通过使用 ast.ident,咱们得到了一个包含着受注解类型名字(标识符)的 Ident 结构体实例。清单 19-32 中的代码结构,显示当咱们在清单 19-30 中的代码上运行这个 impl_hello_macro 函数时,咱们得到的这个 ident 就将有着值为有一个 "Pancakes" 值的 ident 字段。因此,清单 19-33 中的 name 变量,就将包含一个在被打印出时,将为字符串 "Pancakes",即清单 19-30 中那个结构体名字的 Ident 结构体。

其中的 quote! 宏,允许咱们定义出咱们打算返回的 Rust 代码。编译器会期望得到不同于这个 quote! 宏直接执行结果的东西,因此咱们就要将其转换为一个 TokenStream。咱们是通过调用的那个消费这个中间表示,并返回所需的 TokenStream 类型的一个值的 into 方法,完成这一点的。

quote! 宏还提供了一些非常酷的模板机制:咱们可以敲入 #name,而 quote! 就将使用变量 name 中的值,替换掉他。咱们甚至可以与宏工作类似方式,完成一些重复操作。请参考 quote 代码箱文档 了解完整信息。

咱们是要这个程序性宏,在用户注解的类型上,生成咱们的 HelloMacro 特质实现,而咱们可通过使用 #name 做到这点。这个特质实现,有着一个名为 hello_macro 的函数,其函数体包含了咱们打算提供的功能:打印 你好,宏!我的名字叫 以及随后的那个受注解类型的名字。

这里用到的那个 stringify! 宏,是内建于 Rust 中的。他会取一个 Rust 表达式,比如 1 + 2,并在编译时将这个表达式转换为字符串字面值,比如 "1 + 2"。这与 format!println! 这样的会执行表达式并随后将结果转换为一个 String 的宏不同。由于存在着那个 #name 输入,为一个要打印出字面值的表达式的可能,因此咱们便使用了 stringify!。使用 stringify! 还通过在编译时将 #name 转换为字符串字面值,而节省了一次内存分配。

到这里,在 hello_macrohello_macro_derive 中,cargo build 都应完全成功。让我们来将这两个代码箱,连接到清单 19-30 中的代码,来看看行动中的程序性宏!在咱们的 projects 目录下,使用 cargo new derive_macro_comsumer --vcs none 创建一个新的二进制项目。咱们需要在这个 derive_macro_comsumer 代码箱的 Cargo.toml 中,把 hello_macrohello_macro_derive 添加为依赖项。若咱们把咱们版本的 hello_macrohello_macro_derive 发布在了 crates.io,那么他们将为一些常规依赖;而在没有发布时,咱们可以像下面这样,将他们指定为 path 的依赖:

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

请将清单 19-30 中的代码,放入到 src/main.rs 中,并运行 cargo run:其应打印出 你好,宏!我的名字叫 Pancakes! 在这个 derive_macro_comsumer 代码箱无需实现那个程序性宏中的 HelloMacro 特质下,该特质的实现就已被包含了;正是 #[derive(HelloMacro)] 添加了这个特质实现。

接下来,咱们要探讨其他类别的程序性宏,与定制派生宏有怎样的不同。

类属性宏

Attribute-like macros

类属性宏与定制派生宏类似,不过与生成 derive 属性的代码不同,他们允许咱们创建出新的属性。他们还更灵活:derive 只对结构体和枚举生效;而属性则同时可应用到其他项目,比如函数等。下面就是一个使用类属性宏的示例:比方说咱们在运用某个 web 应用框架时,就有一个对函数加以注解的名为 route 的属性:

#![allow(unused)]
fn main() {
#[route(GET, "/")]
fn index() {
}

这个 #[route] 就将是由那个框架,定义的一个程序性宏。那个宏定义函数的签名,将看起来像下面这样:

#![allow(unused)]
fn main() {
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenSteam {
}

这里,咱们有两个类型 TokenStream 的参数。头一个是属性的内容:即 GET, "/" 部分。而第二个,则是该属性被附加到的那个项目的函数体:在这个示例中,便是 fn index() {} 及该函数的函数体其余部分。

除此之外,类属性宏与定制派生宏以同样方式运作:咱们要创建出一个有着 proc-macro 代码箱类型的代码箱,并实现一个生成咱们想要代码的函数!

类函数宏

Function-link macros

类函数宏定义了看起来像函数调用的宏。与 macro_rules! 宏类似,他们比函数更为灵活;比如,他们就可取未知数目的参数。然而,macro_rules! 宏只能使用咱们早先在 用于通用元编程的带有 macro_rules! 的声明式宏 小节,曾讨论过的 match-like 语法。而类函数宏,则会取一个 TokenStream 参数,而这些宏的定义,就会使用 Rust 代码,如同另外两种程序性宏所做的那样,对那个 TokenStream 加以操纵。作为类函数宏的一个例子,便是将如下面调用的一个 sql! 宏:

#![allow(unused)]
fn main() {
let sql = sql! (SELECT * FROM posts WHERE id=1);
}

这个宏会解析其内部的 SQL 语句,并就其语法方面的正确性加以检查,相比 macro_rules! 宏所能完成的处理,这就要复杂多了。这个 sql! 宏将像下面这样定义:

#![allow(unused)]
fn main() {
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
}

此定义与定制派生宏的签名类似:咱们会接收圆括号内部的那些令牌,并返回咱们所要生成的代码。

本章小结

咦!现在咱们在工具箱中,便有了大概率不会经常用到的一些 Rust 特性,不过咱们会明白,在一些极为特别的情况下他们会是可用的。咱们业已引入几个复杂的主题,因此在咱们于一些错误消息建议,或其他人的代码中遇到他们时,咱们就能识别出这些概念和语法。请将这一章,当作引导咱们得到解决办法的一个参考。

接下来,咱们将把这正本书中曾讨论过的所有内容,投入到实践中,而完成另一个项目!

Last change: 2023-12-01, commit: e3b5cc8