5分钟速读之Rust权威指南(四十三)宏

931 阅读8分钟

你有没有注意到rust中的函数参数都是固定数量的,而print!宏和vec!宏的参数数量却是任意的?这一节我们就看一下宏的用法。宏(macro)是rust中的是某一组相关功能的集合名字,它包含声明宏(declarative macro)过程宏(procedural macro),别急,我们先来看一些概念性的东西,然后分别上手尝试下

声明宏是什么:

  • 声明宏使用macro_rules!宏来定义,比如println!vec!都可以使用macro_rules!宏来定义

过程宏是什么:

  • 还记得我们为结构体和枚举使用属性#[derive(Debug)]来为结构体实现Debug trait吗?这就是过程宏的能力了,这种称之为自定义derive宏
  • 除了为结构体和枚举添加自定义derive宏之外,过程宏还有一种能力,可以任意的条目(比如函数)添加自定义属性的属性宏
  • 还有一种函数宏,它把编译器产出的词法token作为参数,然后构建出新代码

宏与函数有什么差别吗?

  • 宏是一种用于编写其他代码的代码编写方式,也就是所谓的元编程范式(metaprogramming)
  • 函数在定义签名时必须声明自己参数的个数与类型,而宏则能够处理可变数量的参数
  • 宏的定义要比函数定义复杂得多,宏定义通常要比函数定义更加难以阅读、理解及维护
  • 当在某个文件中调用宏时,必须在调用前定义宏或将宏引入当前作用域中,而函数则可以在任意位置定义并在任意位置使用

声明宏macro_rules!

声明宏也被称作模板宏,它会将输入的值与带有相关执行代码的模式进行比较:此处的值是传递给宏的字面rust源代码,而此处的模式则是可以用来匹配这些源代码的结构。在编译时,当某个模式匹配成功时,该分支下的代码就会被用来替换传入宏的代码,巴拉巴拉一大堆书中的定义,下面我们先回一下vec!宏的使用:

let v: Vec<_> = vec![1, 2, 3];
println!("{:?}", v);
// [1, 2, 3]

我们来尝试实现一个简化版vec!的定义:

macro_rules! vec2 {
  // $()中表示模式,想想一下正则中的分组功能。
  // 其中expr表示匹配表达式,$item是将匹配的表达式进行命名。
  // 括号后面的逗号意味着可能有多个参数,
  // *号表示前面的所有字符(包括$()和,)出现0到多次(这里和一般的正则不同哦)
  ( $( $item:expr ),* ) => {
    {
      let mut temp_vec = Vec::new();
      $(
        temp_vec.push($item);
      )* // 匹配了多少次参数,就编译出多少次这个语句
      temp_vec
    }
  }
}

在编译阶段宏会被展开成下面的样子:

let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec

来使用一下:

let v2 = vec2![1,2,3];
println!("{:?}", v2);
// [1, 2, 3]

声明宏是根据模式匹配来替换代码的行为,而过程宏主要是操作输入的rust代码,并生成另外一些rust代码作为结果。当前由于技术原因,当创建过程宏时,宏的定义必须单独放在它们自己的包中,并标注的包类型。

过程宏之自定义derive宏

仔细阅读这段话哦,这次我们创建一个hello_macro的包,并在其中定义一个拥有关联函数hello_macroHelloMacro trait。提供一个能够"自动实现trait的过程宏"。然后呢,开发者只需要在他们类型上标注#[derive(HelloMacro)],就可以得到hello_macro函数的默认实现,调用hello_macro函数后,会打印文本:"Hello Macro! MyName is TypeName",其中的TypeName替换为开发者的类型的名称,就像下面这样:

// hello_macro/src/main.rs
use hello_macro::HelloMacro; // 引入trait
use hello_macro_derive::HelloMacro; // 引入自定义derive宏

#[derive(HelloMacro)] // 使用自定义derive宏实现HelloMacro trait
struct Pancakes;

Pancakes::hello_macro();
// Hello Macro! MyName is Pancakes!

首先我们定义 HelloMacro trait 以及其关联函数:

// hello_macro/src/lib.rs
pub trait HelloMacro {
  fn hello_macro();
}

然后定义过程宏过程宏需要被单独放置到它们自己的包内(未来可能会取消这个限制),实现一个自定义派生过程宏的包,命名习惯一般是:包名称_derive,在hello_macro项目同一级别文件夹中创建一个名为hello_macro_derive的包:

cargo new hello_macro_derive --lib

当前目录结构如下:

├── hello_macro
│   ├── Cargo.toml
│   └── src
│       ├── lib.rs
│       └── main.rs
└── hello_macro_derive
    ├── Cargo.toml
    └── src
        └── lib.rs

实现一个过程宏还需要三个陌生的包,我们只需要简单了解它们的用处就好:

  • proc_macro:这个包是rust内置,编译器用来读取和操作rust代码的API
  • syn:用来解析rust代码产生抽象语法树ast
  • quote:将syn产生的语法树重新生成rust代码

然后将这三个包添加到依赖中(是不是很像前端的babel工具系列),注意需要使用proc-macro = true来声明这是一个这个包是过程宏(proc-macro)的包:

// hello_macro_derive/Cargo.toml
[lib]
proc-macro = true

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

准备了半天,重头戏来了,开始编写过程宏

// hello_macro_derive/src/lib.rs
use proc_macro::TokenStream; // 词法token类型
use quote::quote;
use syn;

// 当开发者在一个类型上指定#[derive(HelloMacro)]时,
// hello_macro_derive函数将会被调用
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
  // 将rust语法转为抽象语法树
  let ast = syn::parse(input).unwrap();

  // 实现hello_macro
  impl_hello_macro(&ast)
}

解析之后的ast结构如下:

DeriveInput {
	// --略--
	ident: Ident {
		ident: "Pancakes", // 我们需要获取这个字段
		span: #0 bytes(95..103)
	},
	data: Struct(
		DataStruct {
			struct_token: Struct,
			fields: Unit,
			semi_token: Some(
				Semi
			)
		}
	)
}

继续实现上面的impl_hello_macro函数:

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
  // 获取"Pancakes"
  let name = &ast.ident;
  // quote! 宏可以用来编写rust代码,这里生成一段为Pancakes实现HelloMacro trait的代码
  let gen = quote! {
    // 这里使用了模板机制,#name用来引用"Pancakes"
    impl HelloMacro for #name {
      fn hello_macro() {
        // 由于要打印出类型的名称,所以这里也需要用#name来替换为"Pancakes"
        // 这里使用的stringify! 宏是内置在rust中的,它接收一个rust表达式,在编译时将这个表达式转换成字符串字面量,如下:
        // println!("{}", stringify!(1 + 2));
        // 1 + 2
        println!("Hello Macro! MyName is {}", stringify!(#name))
        // 代码中输入的#name有可能是一个表达式,因为我们希望直接打印出这个值的字面量,所以这里使用了stringify!
      }
    }
  };
  // quote!宏执行的直接结果并不是编译器所期望的并需要转换为TokenStream。
  // 为此需要调用into方法,它会消费这个中间表示(intermediate representation,IR)并返回所需的 TokenStream 类型值。
  gen.into()
}

最后还需要在hello_macro包中引用hello_macro_derive

// hello_macro/Cargo.toml

[dependencies]
hello_macro_derive = { path="../hello_macro_derive" }

完成之后在hello_macro项目中运行cargo run,便可以看到控制台输出:Hello Macro! MyName is Pancakes

过程宏之属性宏

自定义derive宏类似,属性宏允许创建新的属性,而不是为derive属性生成代码,自定义derive宏只能被用于结构体和枚举,而属性则可以同时被用于其他条目,比如函数等,比如我们编写Web应用框架接口时为函数添加接口方法和路径:

#[route(GET, "/")]
fn index() {}

使用#[proc_macro_attribute]属性将一个函数定义为一个过程宏。其宏定义的函数签名看起来像这样:

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {}

拿上边编写Web应用框架接口的例子来说,route参数中,attr指代属性部分:#[route(GET, "/")]item指代函数体:fn index() {},总体来说还是和自定义derive宏使用方式很相似的,介于篇幅原因就不多介绍了,大家可以先把基础知识打扎实,有精力的话再去深入研究

过程宏之函数宏

函数宏可以定义出类似于函数调用的宏,与macro_rules!宏类似,函数宏也能接收未知数量的参数

函数宏与macro_rules的区别是什么?

macro_rules!宏只能使用类似于match的语法来进行定义,而函数宏则可以接收一个TokenStream作为参数,与自定义derive宏属性宏一样,可以在定义中使用rust代码来操作TokenStream,例如下面这个sql!宏:

let sql = sql!(SELECT * FROM posts WHERE id=1);

sql!宏的签名是这样的:

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

函数宏自定义derive宏的签名类似,接收TokenStream作为参数,然后返回生成的代码,所以你发现了,声明宏就是用类似正则匹配的方式,而三种过程宏都是对rust代码的编辑再生成,所以声明宏肯定是没有过程宏灵活的,过程宏也没有声明宏编写起来高效,本篇只是简单介绍了宏的能力,如果想更加深入,可以去阅读宏小册

最后

本篇是rust入门系列的最后一篇,希望大家对rust有了基本的了解,后面可能会有一些小demo的文章供大家练手,本人也在学习过成功,所以希望可以和大家多多交流,共同进步吧^_^。

封面图:by 铁柱呆又呆

关注「码生笔谈」公众号,阅读更多有趣文章