写点什么

Rust 从 0 到 1- 高级特性 - 宏

用户头像
关注
发布于: 2 小时前
Rust从0到1-高级特性-宏

我们已经使用过宏了,譬如经常看到的 println! ,不过我们完全没有介绍过什么是宏以及它是如何工作的。宏(Macro)在 Rust 中涉及到一系列的功能:使用 macro_rules! 声明宏,以及三种过程(procedural)宏:

  • 自定义派生宏(#[derive] ),在结构体和枚举上通过 derive 属性添加特定的代码

  • 类属性(attribute-like)宏,用于自定义属性

  • 类函数(function-like)宏,看起来像函数调用,不过主要用于处理做为参数传递的“句元”(tokens,这个不知道怎么翻译好,句元?标识符?都不太好理解,在其它语言或技术中也有这个概念,譬如 java 里的 StreamTokenizer,索引分词里也有 token 的概念,我理解为输入流里的单词、数字及符号等体现其内容的信息,那么“句元”可以理解为句子里的元信息)

我们会依次讨论这些,不过首先,为什么已经有了函数还需要宏呢?

宏和函数的区别

根本上来说,宏是一种通过编写代码来生成代码的方式,被称作“元编程”(metaprogramming)。在官网的“附录 C”中详细讨论了 derive 属性,其用于生成各种 trait 的实现,我们也使用过宏 println! 和 vec! 。所有的这些宏会生成比其自身更多的代码,官网上称这为“展开”(expand)。

元编程对于减少我们需要编写和维护的代码量来说很有用,同时它也充当了函数的角色,但宏具备函数所没有的一些额外能力。

在函数的定义中必须声明其参数的类型和数量,而宏的参数数量是可变的,譬如我们可以传递一个参数调用 println!("hello") 或传递两个参数调用 println!("hello {}", name) 。并且,宏会在编译器解释代码之前“展开”,譬如为某个类型实现 trait。而函数则做不到,因为函数在运行时才会被调用,而 trait 需要在编译时就被实现。

宏相对于函数的缺点是宏定义要比函数定义要更复杂,因为其是编写生成 Rust 代码的 Rust 代码。也因为如此,宏定义通常要比函数定义更难阅读、理解和维护。

宏和函数的另外一个重要的区别是,宏必须在调用之前定义或引入作用域,而函数则可以在任何地方定义和调用(也就是说宏必须位于调用位置之前,而我们调用的函数定义可以在我们调用的位置之后)。

使用 macro_rules! 声明宏

Rust 中使用最广泛的宏是声明性宏(declarative macros)。它们有时也被称为 “宏示例”(macros by example)、“macro_rules! 宏” 或者就是 “宏”。声明性宏的编写方式类似于 Rust match 表达式,我们前面介绍过 match 表达式,它是一种控制流,将表达式的结果与指定的模式进行匹配,并执行匹配的模式对应的相关代码。宏也类似,它会将输入值和模式相比较,而模式则与生成的代码相关;其中,输入值就是在 Rust 源代码中我们传递给宏的参数,用于和模式比较,并且当匹配时会将其替换为模式相关的“代码”,即生成代码。这一切都发生于进行编译时。

我们可以使用 macro_rules! 来定义宏。下面让我们通过宏 vec! 来看看如何使用 macro_rules! 。我们首先来回顾下 vec! 宏的使用:

let v: Vec<u32> = vec![1, 2, 3];
复制代码

上例中我们通过宏用构造了一个包含三个整数的 vector,我们也可以使用 vec! 来构造包含两个整数的 vector 或五个字符串切片的 vector 。我们无法使用函数做到这一点,因为我们无法实现知道参数的数量和类型。

下面我们看看 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! 定义的结构和 match 表达式类似,它包含一个分支 ( $( $x:expr ),* ) ,以及紧跟其后的 => 和相关的代码块。如果模式匹配,该相关代码块将被执行。鉴于这是这个宏中唯一的模式,则只有这一种正确的匹配方式,其它模式都会产生错误,而复杂的宏会包含多个分支。

宏定义中的模式的语法和我们在前面介绍模式时的语法是不同的,因为宏模式匹配的是 Rust 代码结构而不是变量或常量值。下面让我们看看上例中的模式是什么意思(宏模式的全部语法请参考官方文档):首先,最外层是一对括号,模式的全部内容都包含在其中;然后是 $ 符号,它会捕获其后括号内与模式匹配的值用于之后生成代码;在 $() 内是 $x:expr ,其匹配 Rust 的任意表达式,并命名为 $x;$() 之后的逗号表明可以选择性的出现多个匹配 $()  内模式的表达式,它们以逗号分隔;而逗号后面的 * 意味着可以匹配它之前的模式 0 次或多次。当我们像 vec![1, 2, 3]; 这样调用宏时,$x 对应的模式会匹配三次,即表达式 1,2 和 3。(看上去是不是有点像正则表达式)

下面让我们看看在这个分支内生成代码的部分:在 $()* 中会为每个匹配的模式生成 temp_vec.push() 代码,其次数取决于模式匹配的次数。$x 会被与模式相匹配的表达式所替换。当我们像 vec![1, 2, 3]; 这样调用该宏时,生成的代码类似下面这样:

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

我们定义了一个宏,它可以接收任意数量和类型的参数,并可以生成创建包含参数中指定元素的 vector 的代码。

macro_rules! 还有一些奇怪的问题(edge case)。将来,Rust 会提供第二种声明性宏,其工作方式类似但会修复这些问题。在那之后,macro_rules! 将不再被推荐使用(deprecated)。出于这种考虑,并且鉴于大多数 Rust 程序员更多是使用宏而非编写宏,我们将不再深入探讨 macro_rules!。有兴趣的同学可以查阅在线文档或其它资源,如 “The Little Book of Rust Macros” ,了解更多的关于如何写宏的内容。

过程宏

第二类宏被称为过程宏(procedural macros),它的行为更像函数(是一种程序)。过程宏以代码作为输入,以此执行一些操作,然后产生一些代码作为输出,而不是像声明性宏那样匹配模式然后替换相应的代码。

过程宏的三种类型(自定义派生,类属性和类函数,前面有提到)工作方式都类似。当创建过程宏时,其定义必须位于它们自己的特殊的 library 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 中可以有多种过程宏。

下面让我们开始介绍不同种类的过程宏,我们从自定义派生宏(custom derive macro)开始,接着我们会解释其它形式宏的微小差异。

如何编写自定义派生宏

让我们创建一个 hello_macro crate,并在其中创建包含关联函数 hello_macro 的 HelloMacro trait。不同于让 crate 的用户为其类型实现 HelloMacro trait,我们将会提供一个过程式宏,这样用户就可以通过注解 #[derive(HelloMacro)] 得到函数 hello_macro 的默认实现,默认实现会打印 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();}
复制代码

当我们完成以后,运行这段代码将会打印 Hello, Macro! My name is Pancakes! 。第一步是新建 crate:

$ cargo new hello_macro --lib
复制代码

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

pub trait HelloMacro {    fn hello_macro();}
复制代码

我们已经定义好了 trait 及其函数。现在使用这个 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();}
复制代码

然而,这将需要他们为每一个想使用 hello_macro 的类型编写实现。我们希望能将他们从这种重复的工作中解放出来。

此外,我们目前还无法为 hello_macro 函数提供一个默认实现,能够打印实现了该 trait 的类型的名字:Rust 没有反射的功能,因此无法在运行时获取类型名。因此,我们需要使用在编译时生成代码的宏。

下一步是定义过程式宏。目前,过程宏还必须在其自己的 crate 内,将来这个限制可能会被移除。构造 crate 和 宏的 crate 的惯例如下:对于名字为 foo 的 crate 说,自定义的派生过程宏的 crate 名字为 foo_derive 。让我们在 hello_macro 项目中创建名为 hello_macro_derive 的 crate:

$ cargo new hello_macro_derive --lib
复制代码

由于这两个 crate 密切相关,因此我们在 hello_macro crate 的目录下创建过程宏的 crate。如果我们改变了 hello_macro 中 trait 的定义,同时也必须改变 hello_macro_derive 中过程宏的实现。这两个 crate 将会分别发布,使用这些包需要同时添加这两个依赖并引入作用域。我们也可以把 hello_macro_derive 添加为 hello_macro 的依赖,并在 hello_macro 中重导出过程宏。不管怎样,这样的项目组织方式使编程人员可以根据需要更灵活的使用 hello_macro ,即使不需要 derive 的功能。

我们需声明 hello_macro_derive crate 为过程宏 crate。同时也需要 syn crate 和 quote crate 中的功能,我们需要将他们添加为依赖。 因此,hello_macro_derive 的 Cargo.toml 文件如下:

[lib]proc-macro = true
[dependencies]syn = "1.0"quote = "1.0"
复制代码

下面我将开始定义一个过程宏,将以下代码拷贝到 hello_macro_derive crate 的 src/lib.rs 中(注意这段代码在我们添加 impl_hello_macro 函数之前是无法编译的):

extern crate proc_macro;
use proc_macro::TokenStream;use quote::quote;use syn;
#[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_derive 函数负责解析 TokenStream,而 impl_hello_macro 函数则负责语法树(源代码语法结构的一种抽象表示,Abstract Syntax Tree,AST)的转换,这让编写过程式宏更为方便。外层函数中的代码(例子中的 hello_macro_derive)在所有我们看到或创建的过程宏中几乎都一样。内层函数(例子中的 impl_hello_macro)中的代码实现则根据过程宏的功能而各不相同。

我们前面提到了三个新的 crate:proc_macro 、 syn 和 quote 。Rust 自带了 proc_macro crate,因此无需将其在 Cargo.toml 文件添加为依赖。proc_macro crate 是编译器的 API,用来读取和操作 Rust 代码。syn crate 将 Rust 代码从 String 解析为可以操作的数据结构。quote 则反过来解析出来的数据结构转回 Rust 代码。这些 crate 让我们想解析任何 Rust 代码变得简单(为 Rust 编写支持全部代码的解析器并不是一件简单的工作)。

当我们 library crate 的用户在某个类型上增加 #[derive(HelloMacro)] 注解后,hello_macro_derive 函数将会被在编译时调用。这是因为我们使用 proc_macro_derive 对 hello_macro_derive 函数进行了注解:指定其名字为 HelloMacro ,与 trait 的名字一样,这是过程宏通常遵循的惯例。

函数 hello_macro_derive  首先将 TokenStream 类型的 input 参数转换为我们可以诠释和操作的数据结构。这正是 syn 发挥作用的地方,syn 的 parse_derive_input 函数将 TokenStream 解析为代表各种 Rust 代码的 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 代码是一个单元结构体(unit struct,不包含任何字段的结构体),其 ident( identifier,标识符,也就是结构体的名字)为 Pancakes。还有更多用于描述各种 Rust 代码的字段,有兴趣的同学可以进一步查阅 syn 文档中 DeriveInput 的部分。

马上我们将开始定义函数 impl_hello_macro,它将构建我们希望生成的新的 Rust 代码。在那之前,需要注意我们的派生宏的输出也是 TokenStream。宏返回的 TokenStream 会被加到使用该宏的用户所写的代码中,这样在他们编译他们的 crate 时,就会通过修改后的 TokenStream 得到我们所提供的额外功能。

你可能也注意到了,如果 syn::parse 执行失败,我们调用了 unwrap 函数来让 impl_hello_macro 函数 panic。在错误时让过程宏 panic 是必要的,因为 proc_macro_derive 函数必须返回 TokenStream 而不是 Result,以符合过程宏的 API。在例子里中我们使用 unwrap 进行了简化;在正式的代码中,应该通过 panic! 或 expect 来提供更多关于错误的具体信息。

现在我们已经将 Rust 代码从 TokenStream 转换为 DeriveInput 实例,下面让我们编写为使用了该宏的结构体生成 HelloMacro trait 实现的代码。参考下面的例子:

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {    let name = &ast.ident;    let gen = quote! {        impl HelloMacro for #name {            fn hello_macro() {                println!("Hello, Macro! My name is {}!", stringify!(#name));            }        }    };    gen.into()}
复制代码

在上例中,我们通过 ast.ident 得到包含使用了我们的宏的注解的类型名字(identifier,标识符)的 Ident 结构体实例 ,在例子中其 ident 字段的值是 Pancakes(参考前面的 DeriveInput 结构体示例)。因此,变量 name 为 Ident 结构体的实例,其在当打印时,会输出字符串 Pancakes。

宏 quote! 让我们可以更方便的编写希望返回的 Rust 代码。因为 quote! 返回的结果并不是编译器所期望的类型,所以我们需要转换为 TokenStream。我们通过调用 into 方法来进行转换,它会返回所需的 TokenStream 类型值。

宏 quote! 还提供了一些非常酷的模板功能:我们可以在代码中写如 #name 形式的代码 ,而 quote! 会将它替换为变量 name 的值。它甚至支持类似宏那样的“重复”表达(譬如前面 macro_rules! 宏中介绍的 *,出现 0 或多次)。感兴趣的同学可以进一步查阅 quote crate 的官方文档。

我们希望我们的过程宏能够为添加了注解的类型生成 HelloMacro trait 的实现,我们会通过 #name 获取类型的名字。该 trait 的实现只有一个函数, hello_macro,它提供了了我们期望的功能:打印 Hello, Macro! My name is {TypeName}!。

例子中使用的 stringify! 是 Rust 内置的宏。他将 Rust 表达式,如 1 + 2 , 在编译时转换为一个字符串常量 "1 + 2" 。这和 format! 或 println! 不同,它们会计算表达式并将结果转换为 String 。因为 #name 可能是一个表达式,因此我们用 stringify! 。同时 stringify! 将 #name 转换为字符串的方式也减少了编译时的内存分配。

现在,cargo build 应该可以成功构建 hello_macro 和 hello_macro_derive 。让我们通过本节最开始的例子来实际体验下我们的过程宏!在我们的项目目录(与 hello_marco 同级)下使用 cargo new pancakes 命令新建一个 binary crate,并在 pancakes crate 的 Cargo.toml 文件中将 hello_macro 和 hello_macro_derive 添加为依赖。如果 hello_macro 和 hello_macro_derive 已发布到 crates.io 上,可以使用常规的方式添加;如果不是,则可以像下面这样指定为路径依赖:

[dependencies]hello_macro = { path = "../hello_macro" }hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }
复制代码

把本节最开始的示例中的代码拷贝到 src/main.rs,然后执行 cargo run:它应该打印出 Hello, Macro! My name is Pancakes!。过程宏已经为其实现了 HelloMacro trait,而无需我们在 pancakes crate 再实现:#[derive(HelloMacro)] 为其增加了 HelloMacro trait 的实现。

下面,让我们看看其它类型的过程宏与自定义派生宏的区别。

类属性宏

类属性宏与自定义派生宏相似,不同的是它允许我们创建新的属性,而不是通过 derive 属性生成代码,同时更为灵活:derive 只能用于结构体和枚举;而属性还可以用于其它的类别,譬如函数。下面我们看一个使用类属性宏的例子:在使用 web 应用框架时,我们可以使用 route 属性对函数进行注解:

#[route(GET, "/")]fn index() {
复制代码

#[route] 属性是框架定义的一个过程宏。它的定义类似下面这样:

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

route 有两个 TokenStream 类型的参数。第一个是属性的内容:GET, "/" 。第二个是属性对应的 Rust 代码:在上例中,就是函数 index 的代码。

除此之外,类属性宏与自定义派生宏一样:创建特定的依赖 proc-macro crate 的 crate, 实现我们生成代码的函数!

类函数宏

类函数宏定义看上去类似函数调用。和 macro_rules! 宏类似,它们比函数更灵活,譬如不固定的参数数量。不过 macro_rules! 宏只能使用我们之前介绍过的类似 match 的语法,而类函数宏以 TokenStream 做为参数,并可以像其它两种过程宏一样对其进行处理。宏 sql! 就是一个类函数宏的例子,它可以像下面这样被调用:

let sql = sql!(SELECT * FROM posts WHERE id=1);
复制代码

宏 sql!  会解析其中的 SQL 语句并检查其句法的正确性,可以做到比 macro_rules! 宏更为复杂的处理。宏 sql! 的定义看起来类似下面这样:

#[proc_macro]pub fn sql(input: TokenStream) -> TokenStream {
复制代码

其定义类似于自定义派生宏:获取括号中的“句元”,并返回我们想生成的代码。

总结

我们学习了 Rust 中并不常用的一些功能,它们在特定的场景下会很有用。我们讨论了一些复杂的话题,这样我们在错误信息提示或其他人的代码中遇到他们时,对这些概念和语法可以有所了解,并可以使用本章的内容作为解决方案的参考。

发布于: 2 小时前阅读数: 2
用户头像

关注

公众号"山 顽石"欢迎大家关注;) 2021.03.23 加入

IT老兵,关注Rust、Java、前端、架构、大数据、AI、算法等;平时喜欢美食、旅游、宠物、健身、篮球、乒乓球等。希望能和大家交流分享。

评论

发布
暂无评论
Rust从0到1-高级特性-宏