过程宏

过程宏允许通过执行函数来创建语法扩展。过程宏有以下三种类型:

过程宏允许你在编译时运行代码,这些代码操作 Rust 语法,既消费也生成 Rust 语法。你可以大致将过程宏视为从一个 AST 到另一个 AST 的函数。

过程宏必须在 crate 的根目录下定义,其crate 类型proc-macro。这些宏不能在其定义的 crate 内部使用,只能在导入到其他 crate 后才能使用。

注意

使用 Cargo 时,过程宏 crate 在你的 manifest 文件中通过 proc-macro 键来定义。

[lib]
proc-macro = true

作为函数,它们必须要么返回语法,要么 panic(崩溃),要么无限循环。返回的语法会根据过程宏的类型替换或添加原有的语法。Panic 会被编译器捕获并转化为编译错误。无限循环不会被编译器捕获,这会导致编译器挂起。

过程宏在编译期间运行,因此拥有与编译器相同的资源。例如,标准输入、错误和输出与编译器可访问的相同。类似地,文件访问也相同。正因为如此,过程宏与 Cargo 的构建脚本具有相同的安全顾虑。

过程宏有两种报告错误的方式。第一种是 panic(崩溃)。第二种是发出 compile_error 宏调用。

proc_macro crate

过程宏 crate 几乎总是会链接到编译器提供的 proc_macro crateproc_macro crate 提供了编写过程宏所需的类型以及使其更便捷的工具。

这个 crate 主要包含一个 TokenStream 类型。过程宏操作的是标记流(token stream)而不是 AST 节点,这对于编译器和过程宏来说都是一个随时间推移更为稳定的接口。一个标记流大致等同于 Vec<TokenTree>,其中 TokenTree 可以大致被认为是词法标记。例如,foo 是一个 Ident 标记,. 是一个 Punct 标记,1.2 是一个 Literal 标记。TokenStream 类型,与 Vec<TokenTree> 不同,克隆起来开销很小。

所有标记都关联着一个 SpanSpan 是一个不透明的值,不能被修改但可以被创建。Span 代表了程序中一段源代码的范围,主要用于错误报告。虽然你不能修改 Span 本身,但你总可以改变与任何标记关联Span,例如通过从另一个标记获取一个 Span

过程宏的卫生性

过程宏是不卫生的。这意味着它们的行为就像输出标记流直接内联到其旁边的代码一样。这表示它受外部项的影响,也会影响外部导入。

鉴于此限制,宏作者需要小心确保他们的宏能在尽可能多的上下文中使用。这通常包括使用库中项的绝对路径(例如,使用 ::std::option::Option 而不是 Option),或者确保生成的函数名不太可能与其他函数冲突(例如使用 __internal_foo 而不是 foo)。

函数式过程宏

函数式过程宏是使用宏调用运算符 (!) 来调用的过程宏。

这些宏通过带有 proc_macro 属性且签名类型为 (TokenStream) -> TokenStream公共函数来定义。输入 TokenStream 是宏调用分隔符内部的内容,输出 TokenStream 会替换整个宏调用。

proc_macro 属性在 crate 的根目录下将宏定义在宏命名空间中。

例如,下面的宏定义忽略其输入,并在其作用域内输出一个函数 answer

#![crate_type = "proc-macro"]
extern crate proc_macro;
use proc_macro::TokenStream;

#[proc_macro]
pub fn make_answer(_item: TokenStream) -> TokenStream {
    "fn answer() -> u32 { 42 }".parse().unwrap()
}

然后我们在一个二进制 crate 中使用它来将“42”打印到标准输出。

extern crate proc_macro_examples;
use proc_macro_examples::make_answer;

make_answer!();

fn main() {
    println!("{}", answer());
}

函数式过程宏可以在任何宏调用位置被调用,包括语句表达式模式类型表达式位置,包括 extern中的项、固有和 trait 实现,以及 trait 定义

派生宏

派生宏derive 属性定义新的输入。这些宏可以根据结构体(struct)枚举(enum)联合体(union)的标记流创建新的。它们还可以定义派生宏辅助属性

自定义派生宏通过带有 proc_macro_derive 属性且签名类型为 (TokenStream) -> TokenStream公共函数来定义。

proc_macro_derive 属性在 crate 的根目录下将自定义派生定义在宏命名空间中。

输入 TokenStream 是带有 derive 属性的项的标记流。输出 TokenStream 必须是一组项,这些项随后被附加到输入 TokenStream 中的项所在的模块(module)块(block)中。

以下是一个派生宏的示例。它没有对输入做任何有用的事情,只是附加了一个函数 answer

#![crate_type = "proc-macro"]
extern crate proc_macro;
use proc_macro::TokenStream;

#[proc_macro_derive(AnswerFn)]
pub fn derive_answer_fn(_item: TokenStream) -> TokenStream {
    "fn answer() -> u32 { 42 }".parse().unwrap()
}

然后在一个结构体上使用该派生宏

extern crate proc_macro_examples;
use proc_macro_examples::AnswerFn;

#[derive(AnswerFn)]
struct Struct;

fn main() {
    assert_eq!(42, answer());
}

派生宏辅助属性

派生宏可以在其所在的的作用域内添加额外的属性。这些属性被称为派生宏辅助属性。这些属性是惰性的(inert),它们唯一的目的是提供给定义它们的派生宏。不过,它们可以被所有宏看到。

定义辅助属性的方法是在 proc_macro_derive 宏中放置一个 attributes 键,其值为一个逗号分隔的标识符列表,这些标识符就是辅助属性的名称。

例如,下面的派生宏定义了一个辅助属性 helper,但最终没有使用它。

#![crate_type="proc-macro"]
extern crate proc_macro;
use proc_macro::TokenStream;

#[proc_macro_derive(HelperAttr, attributes(helper))]
pub fn derive_helper_attr(_item: TokenStream) -> TokenStream {
    TokenStream::new()
}

然后在一个结构体上使用该派生宏

#[derive(HelperAttr)]
struct Struct {
    #[helper] field: ()
}

属性宏

属性宏定义了新的外部属性(outer attributes),这些属性可以附加到上,包括 extern中的项、固有和 trait 实现,以及 trait 定义

属性宏通过带有 proc_macro_attribute 属性且签名类型为 (TokenStream, TokenStream) -> TokenStream公共函数来定义。第一个 TokenStream 是属性名后面带分隔符的标记树,不包括外部分隔符。如果属性只写了属性名而没有括号,则属性 TokenStream 为空。第二个 TokenStream 是剩下的,包括上的其他属性。返回的 TokenStream 会用任意数量的替换原有的

proc_macro_attribute 属性在 crate 的根目录下将属性定义在宏命名空间中。

例如,这个属性宏接受输入的标记流并原样返回,实际上是一个无操作(no-op)属性。

#![crate_type = "proc-macro"]
extern crate proc_macro;
use proc_macro::TokenStream;

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

以下示例展示了属性宏看到的字符串化后的 TokenStream。输出会显示在编译器的输出中。输出显示在函数后的注释中,以“out:”为前缀。

// my-macro/src/lib.rs
extern crate proc_macro;
use proc_macro::TokenStream;

#[proc_macro_attribute]
pub fn show_streams(attr: TokenStream, item: TokenStream) -> TokenStream {
    println!("attr: \"{attr}\"");
    println!("item: \"{item}\"");
    item
}
// src/lib.rs
extern crate my_macro;

use my_macro::show_streams;

// Example: Basic function
#[show_streams]
fn invoke1() {}
// out: attr: ""
// out: item: "fn invoke1() {}"

// Example: Attribute with input
#[show_streams(bar)]
fn invoke2() {}
// out: attr: "bar"
// out: item: "fn invoke2() {}"

// Example: Multiple tokens in the input
#[show_streams(multiple => tokens)]
fn invoke3() {}
// out: attr: "multiple => tokens"
// out: item: "fn invoke3() {}"

// Example:
#[show_streams { delimiters }]
fn invoke4() {}
// out: attr: "delimiters"
// out: item: "fn invoke4() {}"

声明宏标记和过程宏标记

声明式 macro_rules 宏和过程宏对标记(或者更确切地说,TokenTree)使用相似但不同的定义。

macro_rules 中的标记树(对应于 tt 匹配器)定义如下:

  • 带分隔符的组 ((...), {...} 等)
  • 语言支持的所有运算符,包括单字符和多字符的 (+, +=)。
    • 注意,这个集合不包含单引号 '
  • 字面量 ("string", 1 等)
    • 注意,负号(例如 -1)绝不是此类字面量标记的一部分,而是一个单独的运算符标记。
  • 标识符,包括关键字 (ident, r#ident, fn)
  • 生命周期 ('ident)
  • macro_rules 中的元变量替换(例如,在 macro_rules! mac { ($my_expr: expr) => { $my_expr } } 中,mac 扩展后的 $my_expr,无论传入的表达式是什么,都将被视为一个单独的标记树)

过程宏中的标记树定义如下:

  • 带分隔符的组 ((...), {...} 等)
  • 语言支持的运算符中使用的所有标点符号(例如 +,但不包括 +=),以及单引号 ' 字符(通常用于生命周期,有关生命周期拆分和合并行为请参见下文)
  • 字面量 ("string", 1 等)
    • 负号(例如 -1)作为整数和浮点数字面量的一部分被支持。
  • 标识符,包括关键字 (ident, r#ident, fn)

在标记流传递进出过程宏时,会考虑这两种定义之间的不匹配之处。
注意,以下转换可能是惰性发生的,因此如果标记实际上没有被检查,转换可能不会发生。

当传递给过程宏时

  • 所有多字符运算符都被分解成单字符。
  • 生命周期被分解成一个 ' 字符和一个标识符。
  • 所有元变量替换都表示为其底层的标记流。
    • 当需要保留解析优先级时,此类标记流可能会被包裹在带有隐式分隔符(Delimiter::None)的带分隔符的组(Group)中。
    • ttident 替换绝不会被包裹在此类组中,而是始终表示为其底层的标记树。

当从过程宏发出时

  • 适用时,标点符号会被拼接成多字符运算符。
  • 单引号 ' 与标识符拼接时,会被合并成生命周期。
  • 当需要保留解析优先级时,负数字面量会被转换为两个标记(- 和字面量),并可能被包裹在带有隐式分隔符(Delimiter::None)的带分隔符的组(Group)中。

注意,声明宏和过程宏都不支持文档注释标记(例如 /// Doc),因此当它们传递给宏时,总是会被转换为表示其等效的 #[doc = r"str"] 属性的标记流。