rust 快速入门——21 宏

219 阅读23分钟

[!|center] 普若哥们儿

github.com/wu-hongbing…

gitee.com/wuhongbing/…

概述

[!note] 请查阅在线文档或其他资源,如 “The Little Book of Rust Macros”中文:Rust 宏小册 (zjp-cn.github.io))来更多地了解如何写宏,该书由 Daniel Keep 开始编写并由 Lukas Wirth 继续维护。

C/C++语言也有Macro)的概念,预编译器对对宏进行简单的展开处理,然后再由编译器对处理后的代码进行编译,得到最终的库文件或可执行文件。

Rust 的宏从原理和机制上就与 C/C++不同,Rust 编译器对源程序(其中包含宏)进行语法分析,得到的抽象语法树 AST,接着 Rust 编译器识别出其中的宏。按照宏的定义对 AST 进行再次处理,得到处理后的 AST。在处理过程中,编译器按照宏的定义,将 AST 的部分片段作为参数传递给宏定义,由宏定义处理,处理之后再放回到 AST,得到最终的 AST。

[!note] 由于宏定义的功能能够收到 AST,近似于将 Rust 源码交给了宏,宏理论上能够对源代码做任意的处理。

处理宏的时机与是否将 AST 片段作为参数传给宏定义是传统的 C/C++宏与 Rust 宏最本质的区别。

宏在编译过程中被使用,能够触及到 Rust 程序代码的 AST 树,因此具有元编程能力。

由于 Rust 宏的机制,Rust 宏具有非常强大的能力,包括类似 Java 语言的反射能力。Rust 中的注解功能也是通过宏来实现的。

宏的使用形式

宏的使用形式有 2 种。

  • 一种形式类似于 Java 语言中的注解,在 Rust 称为属性,属性用在 Rust 语法元素的内部(称为内部属性,比如 Crate)或前部(称为外部属性,比如类型定义),对所修饰的语法元素进行处理。
    • 内部属性:属性声明在一个元素中,对此元素(比如一般为 crate)整体生效。使用形式为 #![$arg] (有 ! 号),比如 #![allow(dead_code)], #![crate_name="blang"], ……
    • 外部属性:属性声明在一个元素之前,对跟在后面的这个元素生效。使用形式为 #[$arg] (没有 ! 号),比如 #[no_mangle] ,……
  • 另一种形式类似于函数,用于编写普通函数难以实现的功能,使用形式为 $name! $arg,比如 println!("Hi")

宏的分类

Rust 宏按照实现机制分为声明Declarative)宏,和过程Procedural)宏:

  • 声明宏
    • 使用 macro_rules! 进行定义,使用形式为 $name! $arg:比如 vec![1, 2, 3] 等,vec! 后使用 {}() 也可以,比如 vec!(1, 2, 3)vec!{1, 2, 3}
  • 过程宏
    • 派生宏 :在结构体和枚举上自动实现 trait。定义形式为 #[proc_macro_derive(Name)];使用形式为 #[derive],比如:#[derive(Clone)]
    • 属性宏(Attribute-like):可用于任意项的自定义属性,定义形式为 #[proc_macro_attribute];使用形式为:
      • 内部属性(Inner Attribute)是指:一个属性声明在一个元素中,对此元素(比如一般为 crate)整体生效。内部属使用形式为 #![$arg] (有 ! 号),比如 #![allow(dead_code)], #![crate_name="blang"], ……
      • 外部属性(Outer Attribute)是指:一个属性声明在一个元素之前,对跟在后面的这个元素生效。外部属性使用形式为 #[$arg] (没有 ! 号),比如 #[no_mangle] ,……
      • Rust 中,有些属性可以/只能作内部属性使用,有些属性可以/只能作外部属性使用
    • 函数宏:实现类似函数的功能。定义形式为 #[proc_macro];使用形式为 $name! $arg:比如 println!("Hi")concat!("a", "b") 等;

Rust 中的声明宏(Declarative macros)和过程宏(Procedural macros)有不同的用途和实现方式。声明宏用于生成代码替换宏调用,类似于 match 表达式。过程宏允许操作 Rust 代码的抽象语法树,可以接受任意数量的参数并使用 Rust 代码定义宏的行为。过程宏在编译时调用,可以访问更多的编译器信息,而声明宏在编译时扩展,由编译器内部处理。

宏和函数的区别

从根本上来说,宏是一种为写其他代码而写代码的方式,即所谓的 元编程metaprogramming)。之前使用过 println! 宏和 vec! 宏。

元编程对于减少大量编写和维护的代码是非常有用的,它也扮演了函数的角色,但宏有一些函数所没有的附加能力。

一个函数签名必须声明函数参数个数和类型。相比之下,宏能够接收不同数量的参数:用一个参数调用 println!("hello") 或用两个参数调用 println!("hello {}", name) 。而且,宏可以在编译器翻译代码前展开,例如,宏可以在一个给定类型上实现 trait。而函数则不行,因为函数是在运行时被调用,而 trait 需要在编译时实现。

宏定义要比函数定义更复杂,因为你正在编写生成 Rust 代码的 Rust 代码。由于这样的间接性,宏定义通常要比函数定义更难阅读、理解以及维护。

宏和函数的最后一个重要的区别是:在一个文件里调用宏 之前 必须定义它,或将其引入作用域,而函数则可以在任何地方定义和调用。

声明宏

先通过一个示例了解声明宏的概貌和基本原理,然后介绍声明宏的详细规则。

声明宏示例

声明宏使用 macro_rules! 来定义。让我们通过查看 vec! 宏定义来探索如何使用 macro_rules! 结构。

项目结构:

my_project
│  Cargo.toml
│
├─src
│      lib.rs
│      main.rs

Cargo.toml

[package]
name = "my_project"
version = "0.1.0"
edition = "2021"

[dependencies]

main.rs 中使用宏:

use my_project::my_vec;
fn main() {
    let v1 = my_vec![1, 2, 3];
    let v2 = my_vec! {4,5,6};
    let v3 = my_vec!(7, 8, 9);
}

注意 my_vec! 之后可以跟 [] / {} / ()

lib.rs 中定义宏 my_vec!

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

注意:标准库中实际定义的 vec! 包括预分配内存的代码,这部分为代码优化,为了让示例简化,此处并没有包含在内。

#[macro_export] 注解表明只要导入了定义这个宏的 crate,该宏就应该是可用的。如果没有该注解,这个宏不能被引入作用域。

接着使用 macro_rules! 和宏名称开始宏定义,且所定义的宏并 不带 感叹号。名字后跟大括号表示宏定义体,在该例中宏名称是 my_vec

my_vec! 宏的结构和 match 表达式的结构类似。此处有一个分支模式 ( $( $x:expr ),* ) ,后跟 => 以及和模式相关的代码块。如果模式匹配,该相关代码块将被执行。这里这个宏只有一个模式,那就只有一个有效匹配的模式,其他任何模式都会导致错误。更复杂的宏会有多个分支模式。

宏定义中有效模式语法和 match 模式语法是不同的,因为宏模式所匹配的是 Rust 代码结构而不是值

首先,一对括号包含了整个模式。我们使用 $ 声明一个变量来包含匹配该模式的 Rust 代码。$ 符号明确表明这是一个宏变量而不是普通 Rust 变量。之后是一对括号,其捕获了符合括号内模式的值用以在替代代码中使用。$() 内则是 $x:expr ,其匹配 Rust 的任意表达式,并将该表达式命名为 $x

$() 之后的逗号说明一个可有可无的逗号分隔符可以出现在 $() 所匹配的代码之后。紧随逗号之后的 * 说明该模式匹配零个或更多个 * 之前的任何模式。

当以 my_vec![1, 2, 3]; 调用宏时,$x 模式与三个表达式 123 进行了三次匹配。

现在让我们来看看与此分支模式相关联的代码块中的模式:匹配到模式中的 $() 的每一部分,都会在(=> 右侧)$()* 里生成 temp_vec.push($x),生成零次还是多次取决于模式匹配到多少次。$x 由每个与之相匹配的表达式所替换。

通过 cargo expand 命令可以输出宏展开的内容,在项目目录下运行:

cargo expand --bin my_project        

可以看到当以 my_vec![1, 2, 3]; 调用该宏时,替换该宏调用所生成的代码会是下面这样:

fn main() {                
    let v1 = {             
        let mut temp_vec = Vec::new();            
        temp_vec.push(1);  
        temp_vec.push(2);  
        temp_vec.push(3);  
        temp_vec
    };
    let v2 = {             
        let mut temp_vec = Vec::new();            
        temp_vec.push(4);  
        temp_vec.push(5);  
        temp_vec.push(6);  
        temp_vec
    };
    let v3 = {             
        let mut temp_vec = Vec::new();            
        temp_vec.push(7);  
        temp_vec.push(8);  
        temp_vec.push(9);  
        temp_vec
    };
}

我们已经定义了一个宏,其可以接收任意数量和类型的参数,同时可以生成能够创建包含指定元素的 vector 的代码。

规则

Rust 声明宏允许编写一些类似 Rust match 表达式的代码,match 表达式是控制结构,其接收一个表达式,与表达式的结果进行模式匹配,然后根据模式匹配执行相关代码。声明宏也将一个值和包含相关代码的模式进行比较,该值是传递给宏的 Rust 源代码字面值,模式用于和前面提到的源代码字面值进行比较,每个模式的相关代码会替换传递给宏的代码。所有这一切都发生于编译时。

macro_rules!

macro_rules! 本身就是一个宏,也就是从技术上说,它并不是 Rust 语法的一部分。它的形式如下:

macro_rules! $name {
    $rule0 ;
    $rule1 ;
    // …
    $ruleN ;
}

至少得有一条规则(rule),而且最后一条规则后面的分号可被省略。规则里你可以使用大/中/小括号:{}[]()。每条“规则(rule)”都形如:

    ($matcher) => {$expansion}

在规则(rule)里分组符号可以是任意一种括号,这不会影响宏调用。但出于习惯,在模式匹配 (matcher) 外侧使用小括号、展开 (expansion) 外侧使用大括号。

注意:定义的规则不关心 ($matcher) => {$expansion} 中的外层括号类型,但 matcher 和 expansion 之内的括号属于匹配和展开的内容,所以它们内部使用什么括号取决于你需要什么语法。

在调用宏时可以使用这三种中任意一种括号,但是调用时末尾是否有分号 ; 会有所不同。末尾有分号的宏调用总是会被解析成一个条目 (item)。

条目 (item) 是 rust 中的术语(Introduction - The Rust Reference),是指各种语法项的声明或定义,比如函数定义、结构体定义、类型定义、use 声明等。

假如使用 m! 宏,如果该宏展的内容是条目 (item),则必须使用 m!{ ... }m!{ ... };m![ ... ]; 或者 m!( ... );;注意,除了 {}[]() 后必须有分号 ;

如果该宏展开内容是表达式,你可以使用 m!{ ... }m!( ... )m![ ... ],或者 m!{ ... };m![ ... ];m!( ... );

匹配

当一个宏被调用时,macro_rules! 解释器将按照声明顺序一一检查规则。

对每条规则,它都将尝试将输入标记树的内容与该规则的 matcher 进行匹配。某个 matcher 必须与输入完全匹配才被认为是一次匹配。

如果输入与某个 matcher 相匹配,则该调用将替换成相应的展开内容 (expansion) ;否则,将尝试匹配下条规则。如果所有规则均匹配失败,则宏展开失败并报错。

最简单的例子是空 matcher:

macro_rules! four {
    () => { 1 + 3 };
}

当且仅当匹配到空的输入时,匹配成功,即 four!()four![]four!{} 三种方式调用是匹配成功的。

注意所用的分组标记并不需要匹配定义时采用的分组标记,因为实际上分组标记并未传给调用。也就是说,可以通过 four![] 调用上述宏,此调用仍将被视作匹配成功。只有输入的内容才会被纳入匹配考量范围。

matcher 中也可以包含字面上的标记树,这些标记树必须被完全匹配。将整个对应标记树在相应位置写下即可。

这里不是指 Rust 的“字面值”,而是指不考虑含义的标记,比如这个例子中 fn[] 都不是 Rust 的字面标记 (token),而是 keyword 和 delimiter 标记。

比如,要匹配标记序列 4 fn ['spang "whammo"] @_@ ,我们可以这样写:

macro_rules! gibberish {
    (4 fn ['spang "whammo"] @_@) => {...};
}

使用 gibberish!(4 fn ['spang "whammo"] @_@]) 即可成功匹配和调用。

能写出什么标记树,就可以使用什么标记树。

元变量

matcher 还可以包含捕获 (captures)。即基于某种通用语法类别来匹配输入,并将结果捕获到元变量 (metavariable) 中,然后将替换元变量到输出。

捕获的书写方式是:美元符号 $ 后跟一个标识符,然后是冒号 :,最后是捕获方式,比如 $e:expr。捕获方式又被称作“片段分类符” (fragment-specifier),必须是以下一种:

  • block:一个块(比如一块语句或者由大括号包围的一个表达式)
  • expr:一个表达式 (expression)
  • ident:一个标识符 (identifier),包括关键字 (keywords)
  • item:一个条目(比如函数、结构体、模块、impl 块)
  • lifetime:一个生命周期注解(比如 'foo'static
  • literal:一个字面值(比如 "Hello World!"3.14'🦀'
  • meta:一个元信息(比如 #[...]#![...] 属性内部的东西)
  • pat:一个模式 (pattern)
  • path:一条路径(比如 foo::std::mem::replacetransmute::<_, int>
  • stmt:一条语句 (statement)
  • tt:单棵标记树
  • ty:一个类型
  • vis:一个可能为空的可视标识符(比如 pubpub(in crate)

比如以下声明宏捕获一个表达式输入到元变量 $e

macro_rules! one_expression {
    ($e:expr) => {...};
}

元变量对 Rust 编译器的解析器产生影响,而解析器也会确保元变量总是被“正确无误”地解析。expr 元变量总是捕获完整且符合 Rust 编译版本的表达式。

当元变量已经在 matcher 中确定之后,你只需要写 $name 就能引用元变量。比如:

macro_rules! times_five {
    ($e:expr) => { 5 * $e };
}

元变量被替换成完整的 AST 节点,这很像宏展开。这也意味着被 $e 捕获的任何标记序列都会被解析成单个完整的表达式。你也可以一个 matcher 中捕获多个元变量:

macro_rules! multiply_add {
    ($a:expr, $b:expr, $c:expr) => { $a * ($b + $c) };
}

然后在 expansion 中使用任意次数的元变量:

macro_rules! discard {
    ($e:expr) => {};
}
macro_rules! repeat {
    ($e:expr) => { $e; $e; $e; };
}

有一个特殊的元变量叫做 $crate ,它用来指代当前 crate 。

反复

matcher 可以有反复捕获 (repetition),这使得匹配一连串标记 (token) 成为可能。反复捕获的一般形式为 $ ( ... ) sep rep

  • $ 是字面上的美元符号标记
  • ( ... ) 是被反复匹配的模式,由小括号包围。
  • sep可选的分隔标记。它不能是括号或者反复操作符 rep。常用例子有 ,;
  • rep必须的重复操作符。当前可以是:
    • ?:表示最多一次重复,所以此时不能前跟分隔标记。
    • *:表示零次或多次重复。
    • +:表示一次或多次重复。

反复捕获中可以包含任意其他的有效 matcher,比如字面上的标记树、元变量以及任意嵌套的反复捕获。在 expansion 中,使用被反复捕获的内容时,也采用相同的语法。而且被反复捕获的元变量只能存在于反复语法内。

举例来说,下面这个宏将每一个元素转换成字符串:它先匹配零或多个由逗号分隔的表达式,并分别将它们构造成 Vec 的表达式。

macro_rules! vec_strs {
    (
        // 开始反复捕获
        $(
            // 每个反复必须包含一个表达式
            $element:expr
        )
        // 由逗号分隔
        ,
        // 0 或多次
        *
    ) => {
        // 在这个块内用大括号括起来,然后在里面写多条语句
        {
            let mut v = Vec::new();

            // 开始反复捕获
            $(
                // 每个反复会展开成下面表达式,其中 $element 被换成相应被捕获的表达式
                v.push(format!("{}", $element));
            )*

            v
        }
    };
}

fn main() {
    let s = vec_strs![1, "a", true, 3.14159f32];
    assert_eq!(s, &["1", "a", "true", "3.14159"]);
}

你可以在一个反复语句里面使用多次和多个元变量,只要这些元变量以相同的次数重复。所以下面的宏代码正常运行:

macro_rules! repeat_two {
    ($($i:ident)*, $($i2:ident)*) => {
        $( let $i: (); let $i2: (); )*
    }
}

fn main () {
    repeat_two!( a b c d e f, u v w x y z );
}

但是这下面的不能运行:

macro_rules! repeat_two {
   ($($i:ident)*, $($i2:ident)*) => {
       $( let $i: (); let $i2: (); )*
   }
}

fn main() {
    repeat_two!( a b c d e f, x y z );
}

运行报以下错误:

error: meta-variable `i` repeats 6 times, but `i2` repeats 3 times
 --> src/main.rs:6:10
  |
6 |         $( let $i: (); let $i2: (); )*
  |          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
元变量表达式

展开的部分(expansion)是每条声明宏规则的后半段。expansion 可以包含所谓的元变量表达 (metavariable expressions)。

元变量表达式为 expansion 提供了关于元变量的信息 —— 这些信息是不容易获得的。

目前除了 $$ 表达式外,它们的一般形式都是 $ { op(...) }:即除了 $$ 以外的所有元变量表达式都涉及反复。

可以使用以下表达式(其中 ident 是所绑定的元变量的名称,而 depth 是整型字面值):

  • ${count(ident)}:最里层反复 $ident 的总次数,相当于 ${count(ident, 0)}
  • ${count(ident,depth)}:第 depth 层反复 $ident 的次数
  • ${index()}:最里层反复的当前反复的索引,相当于 ${index(0)}
  • ${index(depth)}:在第 depth 层处当前反复的索引,向外计数
  • ${length()}:最里层反复的重复次数,相当于 ${length(0)}
  • ${length(depth)}:在第 depth 层反复的次数,向外计数
  • ${ignore(ident)}:绑定 $ident 进行重复,并展开成空
  • $$:展开为单个 $,这会有效地转义 $ 标记,因此它不会被展开(转写)

过程宏

第二种形式的宏被称为 过程宏procedural macros),因为它们更像函数(一种过程类型)。过程宏接收 Rust 代码作为输入,在这些代码上进行操作,然后产生另一些代码作为输出,而非像声明式宏那样匹配对应模式然后以另一部分代码替换当前代码。有三种类型的过程宏(自定义派生(derive),类属性和类函数),不过它们的工作方式都类似。

创建过程宏时,其定义必须驻留在它们自己的具有特殊 crate 类型的 crate 中。这么做出于复杂的技术原因,将来我们希望能够消除这些限制。在下例展示了如何定义过程宏,其中 some_attribute 是一个使用特定宏变体的占位符。

use proc_macro;

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

定义过程宏的函数接收一个 TokenStream 作为输入并生成 TokenStream 作为输出。TokenStream 是定义于 proc_macro crate 里代表一系列 token 的类型,Rust 默认携带了 proc_macro crate。这就是宏的核心:宏所处理的源代码组成了输入 TokenStream,宏生成的代码是输出 TokenStream。函数上还有一个属性;这个属性指明了我们创建的过程宏的类型。在同一 crate 中可以有多种的过程宏。

让我们看看不同种类的程序宏。我们将从一个自定义的派生宏开始,然后解释使其他形式不同的小差异。

规则

声明宏 不同,过程宏采用 Rust 函数的形式,接受一个(或两个)标记流并输出一个标记流。

过程宏的核心只是一个从 proc-macro crate type 这种类型的库中所导出的公有函数,因此当编写多个过程宏时,你可以将它们全部放在一个 crate 中。

[!note] 注意:在使用 Cargo 时,定义一个 proc-macro crate 的方式是将 Cargo.toml 中的 lib.proc-macro 键设置为 true,就像这样

[lib]
proc-macro = true

proc-macro 类型的 crate 会隐式链接到编译器提供的 proc_macro 库,proc_macro 库包含了开发过程宏所需的所有内容,并且它公开了两个最重要的类型:

  1. TokenStream:它表示我们所熟知的标记树
  2. Span:它表示源代码的一部分,主要用于错误信息的报告

因为过程宏是存在于 crate 中的函数,所以它们可以像 Rust 项目中的所有其他条目一样使用。

使用过程宏只需要将 proc-macro 类型的 crate 添加到项目的依赖关系图中,并将所需的过程宏引入作用域。

注意:调用过程宏与编译器展开成声明宏是在同一阶段运行,只是过程宏是编译器编译、运行、最后替换或追加的独立的 Rust 程序。

过程宏实际上存在三种不同的类型,每种类型的性质都略有不同。

  • 函数式:实现 $name!$input 功能的宏
  • 属性式:实现 #[$input] 功能的属性
  • 派生式:实现 #[derive($name)] 功能的属性

过程宏:

  • 从形式上看:是带着特定属性的公有函数,其输入为一个或两个 TokenStream,输出是一个 TokenStream
  • 从功能上看:是 AST 到 AST 的函数,即从编译器获取和返还 AST
  • 与声明宏的关系:是声明宏的拓展,而非声明宏的替代品
类别函数属性公有函数名函数签名
函数式#[proc_macro]函数名即宏名(TokenStream) -> TokenStream
派生式#[proc_macro_derive(Name)] 或者
#[proc_macro_derive(Name, attributes(attr))]
任意,因为宏名是 Name(TokenStream) -> TokenStream
属性式#[proc_macro_attribute]函数名即宏名(TokenStream, TokenStream) -> TokenStream

过程宏的优缺点:

  • 优点:
    1. 过程宏利用了 Rust 强大的静态类型系统的优势,从而以结构化的方式操作语法树
    2. 函数式过程宏可以实现与声明宏相同的功能
    3. 派生式过程宏和属性式过程宏对使用者更加方便
  • 缺点:
    1. 学习成本高:作为过程宏的编写者,你需要对 Rust 非常熟悉(对于过程宏的使用者,学习如何使用过程宏并不难)
    2. 增加了编译成本
    3. 相比编写声明宏,过程宏的代码量更多(当然,意味着过程宏的功能更丰富)

派生式宏与属性宏的区别:

  • 派生式宏所生成代码是附加性质的,通常生成某类型的 impl 代码,尤其是生成某 trait impl 的代码。
  • 而属性宏更加通用和自由,生成的代码可以是附加或者替换性质的。
函数式
#[proc_macro]
pub fn name(input: TokenStream) -> TokenStream {
    TokenStream::new()
}
属性式
#[proc_macro_attribute]
pub fn name(attr: TokenStream, input: TokenStream) -> TokenStream {
    TokenStream::new()
}
derive 式
#[proc_macro_derive(Name)]
pub fn my_derive(input: TokenStream) -> TokenStream {
    TokenStream::new()
}

如上所示,每个函数的基本结构是相同的:一个标记了一个属性的公有函数,这个属性定义了它的过程性宏类型,然后函数返回一个 TokenStream

注意,返回类型必须是一个 TokenStream,而且这个 TokenStream 类型必须是 proc_macro 所公开的 TokenStream,通常使用 quote 库构造这种类型。

过程宏也会失败,它们有两种报告错误的方式:

  1. panic:此时编译器会捕获到,然后把它作为来自于宏调用的错误发出
  2. 调用 compile_error!

注意:如果过程宏内出现无限循环,编译器会长时间等待(挂起),从而造成使用过程宏的 crate 也编译挂起。

函数式宏

类似函数的过程宏,像声明宏 那样被调用,即 makro!(…),只从调用形式无法与声明宏区分开。

类似函数式过程宏的简单编写框架如下所示:

use proc_macro::TokenStream;

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

TokenStream 参考: doc.rust-lang.org/proc_macro/…

可以看到,这实际上只是从一个 TokenStream 到另一个 TokenStream 的映射,其中 input 是调用分隔符内的标记。

例如,对于示例调用 foo!(bar),输入标记流将由单独的 bar 标记组成。返回的标记流将替换宏调用。

这种宏类型与声明宏具有相同的放置和展开规则,即宏必须在调用位置上输出正确的标记流。

但是,与声明性宏不同,函式过程宏对其输入没有特定的限制。很明显,过程宏更强大,因为它们可以任意修改其输入,并生成任何所需的输出,只要输出在 Rust 的语法范围内。

用法示例:

use tlborm_proc::tlborm_attribute;

fn foo() {
    tlborm_attribute!(be quick; time is mana);
}

属性式宏

基本概念

属性式过程宏定义了可添加到条目的的新外部属性。这种宏通过 #[attr]#[attr(…)] 方式调用,其中 是任意标记树。

属性宏与派生宏相似,不同的是 derive 属性生成代码,属性宏能创建新的属性,也更为灵活;derive 只能用于结构体和枚举;属性还可以用于其它的项,比如函数。

Rust 中的属性数量非常多。而且具有可扩展性(可自定义属性)。Rust 的属性语法遵从 C# 定义并标准化了的属性规范 ECMA-334

一个属性式过程宏的简单框架如下所示:

use proc_macro::TokenStream;

#[proc_macro_attribute]
pub fn tlborm_attribute(input: TokenStream, annotated_item: TokenStream) -> TokenStream {
    annotated_item
}

这里需要注意的是,与其他两种过程宏不同,这种宏有两个输入参数,而不是一个。

  • 第一个参数是属性名称后面的带分隔符的标记树,不包括它周围的分隔符。如果只有属性名称(其后不带标记树,比如 #[attr]),则这个参数的值为空。
  • 第二个参数是添加了该过程宏属性的条目,但不包括该过程宏所定义的属性。因为这是一个 activedoc.rust-lang.org/reference/a… 属性,在传递给过程宏之前,该属性将从条目中剥离出来。

返回的标记流将完全替换带被添加了该属性的条目。注意,不一定替换成单个条目,替换的结果可以是 0 或更多条目。

用法示例:

use tlborm_proc::tlborm_attribute;

#[tlborm_attribute]
fn foo() {}

#[tlborm_attribute(attributes are pretty handsome)]
fn bar() {}
简单的属性宏例子

让我们从一个简单的例子开始,创建一个属性宏用于在函数上方添加自定义的属性。

use proc_macro::TokenStream;

#[proc_macro_attribute]
pub fn my_attribute(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let mut result = item.to_string();
    result.push_str(" // This is my custom attribute!");
    result.parse().unwrap()
}

#[my_attribute]
fn hello() {
    println!("Hello, attribute macro!");
}

fn main() {
    hello();
}

在上述例子中,我们定义了一个名为 my_attribute 的属性宏。在宏的处理逻辑中,我们在函数上方添加了自定义的注释。在 main 函数中,我们应用了 my_attribute 宏到 hello 函数上。

带参数的属性宏例子

属性宏还可以带有参数,让我们创建一个带有参数的属性宏,用于生成不同类型的函数。

use proc_macro::TokenStream;

#[proc_macro_attribute]
pub fn my_function(attr: TokenStream, item: TokenStream) -> TokenStream {
    let function_name = attr.to_string();
    let mut result = item.to_string();
    result.push_str(&format!("fn {}() {{", function_name));
    result.push_str("println!(\"This is a custom function generated by attribute macro!\"); }");
    result.parse().unwrap()
}

#[my_function(hello)]
fn dummy() {}

fn main() {
    hello();
}

在上述例子中,我们定义了一个名为 my_function 的属性宏,并使其带有一个参数 attr,用于指定生成的函数名。在宏的处理逻辑中,我们根据参数生成了不同类型的函数。在 main 函数中,我们调用了通过 my_function 宏生成的 hello 函数。

属性宏的应用案例
自定义数据结构

属性宏可以用于定制化地生成自定义数据结构。让我们通过一个例子来演示如何使用属性宏生成一个自定义的数据结构。

use proc_macro::TokenStream;

#[proc_macro_attribute]
pub fn my_struct(attr: TokenStream, item: TokenStream) -> TokenStream {
    let struct_name = attr.to_string();
    let mut result = item.to_string();
    result.push_str(&format!("struct {} {{", struct_name));
    result.push_str("data: i32 }");
    result.parse().unwrap()
}

#[my_struct(Point)]
fn dummy() {}

fn main() {
    let point = Point { data: 10 };
    println!("Data: {}", point.data); // 输出:Data: 10
}

在上述例子中,我们定义了一个名为 my_struct 的属性宏,并使其带有一个参数 attr,用于指定生成的数据结构名。在宏的处理逻辑中,我们根据参数生成了一个自定义的数据结构。在 main 函数中,我们通过 my_struct 宏生成了 Point 结构体,并创建了一个 Point 的实例,并输出其中的字段。

条件编译

属性宏可以用于实现条件编译,让我们通过一个例子来演示如何使用属性宏实现条件编译。

use proc_macro::TokenStream;

#[proc_macro_attribute]
pub fn my_feature(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let mut result = item.to_string();
    #[cfg(feature = "my_feature")]
    result.push_str("fn my_function() { println!(\"my_feature is enabled!\"); }");

    result.parse().unwrap()
}

#[my_feature]
fn main() {
    #[cfg(feature = "my_feature")]
    my_function();
}

#[cfg(not(feature = "my_feature"))]
fn my_function() {
    println!("my_feature is not enabled!");
}

在上述例子中,我们定义了一个名为 my_feature 的属性宏,用于在代码中添加条件编译的逻辑。在宏的处理逻辑中,我们根据 cfg 属性来判断是否启用了特定的 feature,并根据不同情况生成了不同的代码。在 main 函数中,我们通过 my_feature 宏来控制是否调用 my_function 函数。

工具属性

Rust 还允许外部工具定义它们自己的属性,并且在独立的命名空间下面。比如:

// Tells the rustfmt tool to not format the following element.
#[rustfmt:: skip]
struct S {
}

// Controls the "cyclomatic complexity" threshold for the clippy tool.
#[clippy:: cyclomatic_complexity = "100"]
pub fn f () {}

不过如果你想在自己的工具中定义 Tool Attribute,那就想多了。现在 rustc 只认识两个外部工具(及它们内部的属性):一个是 rustfmt,另一个是 clippy。

内建属性宏

Rust 内建了 14 类属性。每一个属性都有自己的用法,有的用法还比较多,可以用到的时候,再去查阅(Attributes - The Rust Reference)。这里简单罗列说明一下。

  • 条件编译
    • cfg
    • cfg_attr
  • 测试
    • test
    • ignore
    • should_panic
  • 派生
    • derive
  • 宏相关
    • macro_export
    • macro_use
    • proc_macro
    • proc_macro_derive
    • proc_macro_attribute
  • 诊断
    • allowwarndenyforbid
    • deprecated
    • must_use
    • diagnostic::on_unimplemented
  • ABI, 链接, 符号, 和 FFI
    • link
    • link_name
    • no_link
    • repr
    • crate_type
    • no_main
    • export_name
    • link_section
    • no_mangle
    • used
    • crate_name
  • 代码生成
    • inline
    • cold
    • no_builtins
    • target_feature
  • 文档
    • doc
  • 预引入
    • no_std
    • no_implicit_prelude
  • 模块
    • path
  • 限制
    • recursion_limit
    • type_length_limit
  • 运行时
    • panic_handler
    • global_allocator
    • windows_subsystem
  • 语言特性
    • feature - 经常会碰到这里面一些陌生的 feature 名称,需要根据具体的 rustc 版本和所使用的库文档进行查阅。
  • 类型系统
    • non_exhaustive

上面的属性中,很多属性,其内容都可以单独开一篇文章来讲解。比如,条件编译相关的属性,FFI 相关属性等。

derive 式宏

derive 式过程宏为 derive 属性定义了新的输入。这种宏通过将其名称提供给 derive 属性的输入来调用,例如 #[derive(TlbormDerve)]

一个 derive 式过程宏的简单框架如下所示:

use proc_macro::TokenStream;

#[proc_macro_derive(TlbormDerive)]
pub fn tlborm_derive(input: TokenStream) -> TokenStream {
    TokenStream::new()
}

proc_macro_derive 稍微特殊一些,因为它需要一个额外的标识符,此标识符将成为 derive 宏的实际名称。

输入标记流是添加了 derive 属性的条目,它始终是 enumstruct 或者 union 类型,因为这些是 derive 属性仅可以添加上去的条目。

输出的标记流将被 追加 到带注释的条目所处的块或模块,所以要求标记流由一组有效条目组成。

属性宏与 derive 宏的显著区别在于,属性宏生成的标记是完全替换性质,而 derive 宏生成的标记是追加性质。

用法示例:

use tlborm_proc::TlbormDerive;

#[derive(TlbormDerive)]
struct Foo;
辅助属性

derive 宏又有一点特殊,因为它可以添加仅在条目定义范围内可见的附加属性。

这些属性被称为派生宏辅助属性 (derive macro helper attributes) ,并且是惰性的 (doc.rust-lang.org/reference/a…

辅助属性的目的是在每个结构体字段或枚举体成员的基础上为 derive 宏提供额外的可定制性。

也就是说这些属性可用于附着在字段或成员上,而且不会对其本身产生影响。

又因为它们是“惰性的”,所以它们不会被剥离,并且对所有宏都可见。根据 Reference,除了属性宏的属性是 active 的,其他属性都是 inert 的。

辅助属性的定义方式是向 proc_macro_derive 属性增加 attributes(helper0, helper1, ..) 参数,该参数可包含用逗号分隔的标识符列表(即辅助属性的名称)。

因此,编写带辅助属性的 derive 宏的简单框架如下所示:

use proc_macro::TokenStream;

#[proc_macro_derive(TlbormDerive, attributes(tlborm_helper))]
pub fn tlborm_derive(item: TokenStream) -> TokenStream {
    TokenStream::new()
}

这就是辅助属性的全部内容。在过程宏中使用(或者说消耗)辅助属性,得检查字段和成员的属性,来判断它们是否具有相应的辅助属性。

如果条目使用了所有 derive 宏都未定义的辅助属性,那么会出现错误,因为编译器会尝试将这个辅助属性解析为普通属性(而且这个属性并不存在)。

用法示例:

use tlborm_proc::TlbormDerive;

#[derive(TlbormDerive)]
struct Foo {
    #[tlborm_helper]
    field: u32
}

#[derive(TlbormDerive)]
enum Bar {
    #[tlborm_helper]
    Variant { #[tlborm_helper] field: u32 }
}
派生宏示例

让我们创建一个 hello_macro crate,其包含名为 HelloMacro 的 trait 和关联函数 hello_macro。不同于让用户为其每一个类型实现 HelloMacro trait,我们将会提供一个过程式宏以便用户可以使用 #[derive(HelloMacro)] 注解它们的类型来得到 hello_macro 函数的默认实现。该默认实现会打印 Hello, Macro! My name is TypeName!,其中 TypeName 为定义了 trait 的类型名。换言之,我们会创建一个 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 和其中宏的惯例如下:对于一个 foo 的包来说,一个自定义的派生过程宏的包被称为 foo_derive 。在 hello_macro 项目中新建名为 hello_macro_derive 的包。

cargo new hello_macro_derive --lib

由于两个 crate 紧密相关,因此在 hello_macro 包的目录下创建过程式宏的 crate。如果改变在 hello_macro 中定义的 trait,同时也必须改变在 hello_macro_derive 中实现的过程式宏。这两个包需要分别发布,编程人员如果使用这些包,则需要同时添加这两个依赖并将其引入作用域。我们也可以只用 hello_macro 包而将 hello_macro_derive 作为一个依赖,并重新导出过程式宏的代码。但现在我们组织项目的方式使编程人员在无需 derive 功能时也能够单独使用 hello_macro

我们需要声明 hello_macro_derive crate 是过程宏 (proc-macro) crate。我们还需要 synquote crate 中的功能,正如你即将看到的,需要将它们加到依赖中。将下面的代码加入到 hello_macro_deriveCargo. toml 文件中。

文件名:hello_macro_derive/Cargo. toml

[package]
name = "hello_macro_derive"
version = "0.1.0"
edition = "2021"

[lib]
proc-macro = true

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

为定义一个过程式宏,请将下例的代码放在 hello_macro_derive crate 的 src/lib. rs 文件里面。注意这段代码在我们添加 impl_hello_macro 函数的定义之前是无法编译的。

文件名:hello_macro_derive/src/lib. rs

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_deriveimpl_hello_macro 两个函数,前者负责解析 TokenStream,后者负责转换语法树:这使得编写过程宏更方便。几乎你看到或者创建的每一个过程宏的外部函数(这里是 hello_macro_derive)中的代码都跟这里是一样的。你放入内部函数(这里是 impl_hello_macro)中的代码根据你的过程宏的设计目的会有所不同。

现在,我们已经引入了三个新的 crate:proc_macrosynquote 。Rust 自带 proc_macro crate,因此无需将其加到 Cargo. toml 文件的依赖中。proc_macro crate 是编译器用来读取和操作我们 Rust 代码的 API。

syn crate 将字符串中的 Rust 代码解析成为一个可以操作的数据结构。quote 则将 syn 解析的数据结构转换回 Rust 代码。这些 crate 让解析任何我们所要处理的 Rust 代码变得更简单:为 Rust 编写整个的解析器并不是一件简单的工作。

当用户在一个类型上指定 #[derive(HelloMacro)] 时,hello_macro_derive 函数将会被调用。因为我们已经使用 proc_macro_derive 及其指定名称 HelloMacrohello_macro_derive 函数进行了注解,指定名称 HelloMacro 就是 trait 名,这是大多数过程宏遵循的习惯。

该函数首先将来自 TokenStreaminput 转换为一个我们可以解释和操作的数据结构。这正是 syn 派上用场的地方。syn 中的 parse 函数获取一个 TokenStream 并返回一个表示解析出 Rust 代码的 DeriveInput 结构体。下例展示了从字符串 struct Pancakes; 中解析出来的 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 代码是一个类单元结构体,其 ident(identifier,表示名字)为 Pancakes。该结构体里面有更多字段描述了所有类型的 Rust 代码,查阅 [synDeriveInput 的文档][syn-docs] 以获取更多信息。

很快我们将定义 impl_hello_macro 函数,其用于构建所要包含在内的 Rust 新代码。但在此之前,注意其输出也是 TokenStream。所返回的 TokenStream 会被加到我们的 crate 用户所写的代码中,因此,当用户编译他们的 crate 时,他们会通过修改后的 TokenStream 获取到我们所提供的额外功能。

你可能也注意到了,当调用 syn::parse 函数失败时,我们用 unwrap 来使 hello_macro_derive 函数 panic。在错误时 panic 对过程宏来说是必须的,因为 proc_macro_derive 函数必须返回 TokenStream 而不是 Result,以此来符合过程宏的 API。这里选择用 unwrap 来简化了这个例子;在生产代码中,则应该通过 panic!expect 来提供关于发生何种错误的更加明确的错误信息。

现在我们有了将注解的 Rust 代码从 TokenStream 转换为 DeriveInput 实例的代码,让我们来创建在注解类型上实现 HelloMacro trait 的代码,如下例所示。

文件名:hello_macro_derive/src/lib. rs

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)
}

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 作为注解类型名字(标识符)的 Ident 结构体实例。

quote! 宏能让我们编写希望返回的 Rust 代码。quote! 宏执行的直接结果并不是编译器所期望的所以需要转换为 TokenStream。为此需要调用 into 方法,它会消费这个中间表示(intermediate representation,IR)并返回所需的 TokenStream 类型值。

这个宏也提供了一些非常酷的模板机制;我们可以写 #name ,然后 quote! 会以名为 name 的变量值来替换它。你甚至可以做一些类似常用宏那样的重复代码的工作。查阅 [quote crate 的文档][quote-docs] 来获取详尽的介绍。

我们期望我们的过程式宏能够为通过 #name 获取到的用户注解类型生成 HelloMacro trait 的实现。该 trait 的实现有一个函数 hello_macro ,其函数体包括了我们期望提供的功能:打印 Hello, Macro! My name is 和注解的类型名。

此处所使用的 stringify! 为 Rust 内置宏。其接收一个 Rust 表达式,如 1 + 2 ,然后在编译时将表达式转换为一个字符串常量,如 "1 + 2" 。这与 format!println! 是不同的,它计算表达式并将结果转换为 String 。有一种可能的情况是,所输入的 #name 可能是一个需要打印的表达式,因此我们用 stringify!stringify! 也能通过在编译时将 #name 转换为字符串来节省内存分配。

此时,cargo build 应该都能成功编译 hello_macrohello_macro_derive 。我们将这些 crate 连接到代码中来看看过程宏的行为!在 projects 目录下用 cargo new pancakes 命令新建一个二进制项目。需要将 hello_macrohello_macro_derive 作为依赖加到 pancakes 包的 Cargo. toml 文件中去。如果你正将 hello_macrohello_macro_derive 的版本发布到 crates.io 上,其应为常规依赖;如果不是,则可以像下面这样将其指定为 path 依赖:

[package]
name = "pancakes"
version = "0.1.0"
edition = "2021"

[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)] 增加了该 trait 实现。