Rust中的宏

发布时间:2022-03-18 15:31
最后更新:2024-06-20 22:39
所属分类:
Rust

宏是元编程的一种手段,在Rust中,宏无处不在。宏在C/C++中就是一个特别广泛的存在,但是C/C++中的宏来的远远比Rust中的宏危险。C/C++中的宏主要采用的是文本替换的形式混入代码中,而Rust中的宏则是会采用展开成为源代码的方式混入代码,然后再与代码的其余部分一起进行编译。

宏的作用

宏在Rust中主要可以实现以下是亏三种目标功能的应用。

  1. 避免书写重复代码。在很多需要针对不同类型实现相似功能的位置,可以使用宏来避免书写重复的代码。
  2. 构建领域专用语言(DSL)。DSL是专用于服务特定目标的,是超脱基本编程语言的存在,是对业务实现的高级抽象。
  3. 构建可变接口。可变接口能够支持传入不定数量的参数,使代码更加灵活。

定义宏

宏是通过macro_rules!创建的。以下示例就创建了一个功能非常简单的宏。

1
2
3
4
5
macro_rules! simple_greeting {
  () => {
    println!("Greeting");
  }
}

这个宏在使用的时候只需要像函数一样调用即可。

1
2
3
fn main() {
  simple_greeting!();
}

宏在使用的时候看起来与函数十分的相像,但是宏在调用的时候末尾都有一个!。宏的调用使用()还是使用[]亦或者{},都是可以的,指示Rust对于一些内置的宏都有约定俗成的符号,例如vec!使用[]assert_eq!使用()

宏的定义格式是:macro_rules! 宏名称 { 宏内容体 },指示在定义的时候需要注意宏在定义的时候,宏名称是不带!的,!只在宏调用的时候需要。

模式

定义宏的具体内容的宏内容体跟match的模式匹配十分相像,都是模式 => 模板的格式。宏定义中的模式和模板都是使用配对的括号括起来的,对于括号的种类依旧没有限制,但是习惯上还是用()包裹模式,{}包裹模板,跟普通的函数定义一样。

使用{}包裹模板的好处是可以省略模板结尾的分号。宏定义中的每一个模式匹配都需要使用分号结尾,但是在使用{}包裹模板的时候,结尾的分号就可以省略了。

宏的模式本质上是用于匹配代码的正则表达式,但正则表达式操作的是字符,而宏模式操作的是记号,例如Rust中的数字、名称、标点等。在Rust中,注释和空白不是记号,所以代码中的注释和空白不会影响匹配。一个宏在被调用的时候,Rust会在宏中声明的模式中寻找匹配,一旦找到匹配,就会将模板在调用宏的位置展开,所以定义模式是一个宏可以被怎样使用的关键。

模式实际上是一系列的参数组成的参数列表,也可以叫做捕获列表,模式中的每个参数都是使用一个$符号作为前缀,然后是由一个指示符来指明其类型。例如$left:expr表示这个参数匹配一个表达式,这个匹配的表达式在模板中使用$left代替,又如$func_name:ident表示匹配一个变量名或者函数名,匹配的变量名或者函数名在模板中使用$func_name代替。

Rust定义的用于匹配的参数类型指示符有以下这些,专门负责匹配相应的内容:

  • block,用于匹配代码块。
  • expr,用于匹配一个表达式,在类型描述后面可以接=>,;
  • ident,用于匹配一个变量名或者函数名,在类型描述后面可以接。
  • item,用于匹配函数、结构、模块等。
  • pat,用于表示匹配,在类型描述后面可以接=>,|ifin
  • path,用于匹配一个路径,例如::std::mem::replace,在类型描述后面可以接=>,;=|{[:>aswhere
  • stmt,用于匹配一条语句,在类型描述后面可以接=>,;
  • tt,用于匹配一个标记树。
  • ty,用于匹配一个类型,在类型描述后面可以接=>,;=|{[:>aswhere
  • meta,用于匹配元项目,即在#[...]#![...]中的内容。
在类型描述后面不是什么符号都可以接的,例如expr类型后面只可以接=>,;,那么模式($a:expr ~ $b:expr)就是错误的,因为~不能出现在expr类型描述后面。
模式中的参数除了可以像函数的参数那样使用逗号分隔的书写,还可以加入其他的内容。例如($left:expr, and $right:expr)模式就可以匹配(a, and b)的宏调用,其中ab都是作为表达式被捕获的,而中间的and则只是一个用于匹配的特殊分隔符而已。

模板

在匹配宏的参数以后,就是将匹配到的内容填充到模式对应的模板中的时候了。例如对于以下宏示例。

1
2
3
macro_rules! custom_add {
  ($a:expr, $b:expr, $c:expr) => {$a * ($b + $c)}
}

这个示例中的宏在调用的时候,例如custom_add!(5, 6, 7),就会在编译时在代码原地扩展为5 * (6 + 7)。也就是将匹配到的内容,直接替换模板中相应的内容。如果宏的编译出现问题,那么编译会不通过而产生无效输出。

但是在把代码片段插入到代码中的时候常常与原始的代码存在一些细微的区别。例如以下宏的定义。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
macro_rules! assert_eq {
  ($left:expr, $right:expr) => ({
    match ($left, $right) => {
      (left_val, right_val) => {
        if !(left_val == right_val) {
          panic!("assertion failed!");
        }
      }
    }
  });
}

这个宏在展开执行的时候,$left$right会把调用时提供的值从变量中转移出来。所以上面这个宏如果想要达到不转移值,就必须使用引用,也就是改成以下定义格式。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
macro_rules! assert_eq {
  ($left:expr, $right:expr) => ({
    // 注意,关键在这里,match表达式借用了$left和$right表达式。
    match (&$left, &$right) => {
      (left_val, right_val) => {
        if !(*left_val == *right_val) {
          panic!("assertion failed!");
        }
      }
    }
  });
}

重复

如果只使用前面的匹配捕获,那么如果想要宏接收任意数量的参数的时候,就会变得非常麻烦。但是宏的特点就是化繁琐为简单。例如对于vec!宏,其定义是以下这个样子。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
macro_rules! vec {
  ($elem:expr; $n:expr) => {
    ::std::vec::from_elem($elem, $n)
  };
  ( $( $x:expr ),* ) => {
    <[_]>::into_vec(Box::new([ $( $x ),* ]))
  };
  ( $( $x:expr ),+ , ) => {
    vec![ $( $x ),* ]
  };
}

这个宏的定义看似复杂,但是通过逐条的解释,可以很清楚的读懂其中的每条匹配规则。vec!定义中的第一条规则匹配模式是($elem:expr; $n:expr),这表示其匹配两个参数之间使用分号隔开的调用格式,也就是类似于vec!['e', 1000]这样的调用。

第二条规则实际上是vec!宏的核心,这条匹配规则的模式中使用了一个$( ... ),*格式的语法,这个语法表示其中的匹配将匹配0个或者多个表达式,每个表达式之间采用逗号分隔开。所以这条规则就可以匹配类似于vec!["a", "b", "c"]之类的向量定义。这种语法被称为重复匹配,Rust提供的用于重复匹配的模式主要有以下这些。

模式 匹配方式
$( PATTERN )* 用于定义没有分隔符的0次或多次匹配。
$( PATTERN ),* 用于定义使用逗号分隔的0次或多次匹配。
$( PATTERN );* 用于定义使用分号分隔的0词或多次匹配。
$( PATTERN )+ 用于定义没有分隔符的1次或多次匹配。
$( PATTERN ),+ 用于定义使用逗号分隔的1次或多次匹配。
$( PATTERN );+ 用于定义使用分号分隔的1次或多次匹配。

所以在匹配规则模式$( $x:expr ),*中,匹配的就是使用逗号分隔的0次或者多次匹配。而$x匹配到的内容实际上是一组表达式。在模板中,这一组匹配到的表达式也是用$( PATTERN ),*展开的,展开的表达式将会以逗号分隔全部插入到模板中。

示例模板中前部的<[_]>表示构建一个没有明确类型的切片,其中元素的类型由Rust自行推断。不止[_],还包括fn()&str等类型都必须包裹在<>表示,仅有名字是纯标识符的才可以直接在表达式中使用而不使用<>包裹。

除了可以直接使用$( PATTERN ),*直接展开$x匹配到的一组表达式以外,还可以在$( ... )中编写表达式来把表达式应用到匹配到的所有内容上,再展开经过表达式处理的所有值。例如如果有一个向量v,那么可以通过$( v.push($x); )*将匹配到的所有表达式展开成逐一推入向量的方法调用。

第三条规则其实是用于匹配宏调用的时候末尾存在一个逗号的情况。因为$( PATEERN ),*不能自动支持末尾的逗号或者分号,所以才需要特别定义一个模式进行匹配。

在一个匹配模式中,只能存在一个重复。

捕获Token

Rust编译的一个阶段就是符号化,在这个阶段将会将源代码转换为一系列的符号。每种符号都是独立的一个语法单元,在Rust常见的符号有标识符、整数、关键字、生命期、字符串字面量、操作符等等。被符号化以后的Token流将进入抽象语法树(Abstract Syntax Tree),在内存中建立句法结构体。在构建AST和执行宏的过程中间,实际上还会构建一个Token Tree。这个Token Tree就是匹配参数类型tt所匹配的。

Token Tree中的基础Token是()[]{},这三个Token定义了Token Tree中的内部节点,划分了Token Tree的内部结构。例如表达式a + b + (c + d[1]) + e中,c + d[]将会形成一个独立的Token Tree结构,用于表示数组中索引的1将会形成一个更深层的Token Tree结构。具体可见以下图示。

Rust中的Token Tree示例
Rust中的Token Tree示例

所以如果模式的参数类型使用了tt,那么模式参数中捕获的将是Token Tree中的Token。例如以下示例。

1
2
3
macro_rules! match_tokens {
  ($a:tt + $b:tt) => { $a - $b }
}

这个宏非常搞怪,例如执行match_tokens!(10 + 6),那么所匹配到的$a10$b6,宏展开以后就是表达式10 - 6

类型描述tt可以用来匹配捕获Token Tree中的任何内容,也是用来定义DSL的基础之一。

宏的导入与导出

宏通常也与其他的代码一样,都是分布在不同的包中的,如果要使用定义在其他包中的宏,需要显式的将其导入。

在程序只有一个包的情况下,在一个模块中可见的宏将会自动在其子模块中保持可见。如果要从一个模块将宏导出到其父模块中,需要使用#[macro_use]属性标记。例如:

1
2
3
4
#[macro_use]
mod macros;

mod client;

这样,在macros包中定义的宏,不仅在当前的模块中可见,在client模块中也将是可见的。

如果程序需要从另一个包中导入宏,那么就必须在extern crate声明上使用属性标记#[macro_use]。而如果当前的程序是一个库,需要导出宏给其他的程序使用,那么就需要在要导出的宏上使用属性标记#[macro_export],同时需要注意导出的宏不应该包含依赖于作用域中存在的内容,而且宏中应该使用绝对路径指向所需要使用的任何名称。

macro_rules!中,可以使用$crate来代表宏定义所在包的绝对路径,以简化需要导出的宏定义中书写绝对路径的繁琐程度。

内置宏

有一些宏是被硬编码在Rust中的,不是能够通过macro_rules!定义的。通过这些内置宏可以支持很多普通语句不能达到的效果。

  • file!()将会扩展为一个字符串字面量,其中内容为当前文件名。
  • line!()将会扩展为一个u32字面量,用以表示当前行行号。
  • column!()将会扩展为一个u32字面量,用于表示当前行中的列号。
如果宏被嵌套调用,而且宏的调用都不在同一个文件中,那么宏file!()line!()column!()都会被扩展为第一次宏调用的位置的信息。
  • stringify!(...tokens...)将会扩展为一个字符串字面量,其中内容为匹配到的记号。在stringify!()中调用的宏,将不会被展开,而是会原样输出成字符串字面量。
  • concat!(str0, str1, ...)将会扩展成一个字面量,这个字面量是宏调用中所有字符串拼接的结果。
  • cfg!(...)将会扩展成一个布尔值常量,如果当前构建配置与括号中的配置条件匹配,那么将返回true
  • env!("VAR_NAME")将会扩展为一个字符串,其中内容为指定环境变量在编译时的值。如果指定的环境变量不存在,那么将会产生一个编译错误。
  • option_env!("VAR_NAME")env!()相同,但是将会返回一个Option<&'static str>类型的值,如果指定的环境变量不存在,那么将会返回None
  • include!("file.rs")将会扩展为指定Rust代码文件的内容,所只开的文件的内容必须是有效的Rust代码。
  • include_str!("file.txt")将会扩展为一个&'static str,其中内容为指定文件的文本内容。常用来将文件的内容赋予一个&str类型的常量。
  • include_bytes!("file.dat")将会扩展为一个&'static [u8],将会把指定文件的内容作为二进制数据对待。
如果在使用include!()include_str!()include_bytes!()的时候使用的是相对路径,那么Rust编译器将会到相对于调用这些宏的文件的目录中寻找。如果指定的文件不能被找到,那么将会产生编译失败。

定义DSL

利用Rust中的宏定义,可以非常方便的构建一套用于专用领域的语言,宏在扩展的时候会将自定义的DSL转换为合法的Token Tree。DSL在构建的时候只需要注意前面所列举的各个不同的参数类型的使用限制。

以下是一个非常简单的DSL实现。

1
2
3
4
5
6
macro_rules! calculate {
  // 注意,expr类型描述后面可以接的内容限制
  (eval $e:expr, store to $a:ident) => {{
    let $a = $e;
  }}
}

这个简单的宏在调用的时候可以是以下格式。

1
2
3
4
5
6
fn main() {
  let variable: usize = 0;
  calculate! {
    eval (100 + 50) / (29 * 16), store to variable
  }
}

这个宏的调用方式看起来就很像一门独立的语言了。如果需要定义更加复杂的DSL,那么可以结合宏递归、特征以及宏的作用域与自净等特性来完成。


索引标签
Rust
macro