发布于

Rust的宏(一)

Authors
  • avatar
    Name
    Charly
    Twitter

Rust的宏(一)

前言

作为Rust初学者,看到宏的奇奇怪怪语法,让人心生敬畏,瞬间Rust的门槛又提升了一级。学习这块的时候,我也翻阅不少文章,大部分主要是类比了一下其他语言的宏,然后就开始了rust宏的讲解,切入的比较直接,为了方便大家理解,我梳理了一下宏的来龙去脉。

为什么有宏

简单一点,其实宏的主要目的就是为了封装,把一些重复的逻辑封装到宏里,当然这一点函数也可以轻松做到。不过宏没有像执行函数时候的开销,Rust会在编译阶段把宏展开。想深一点在Rust语言里其实宏和函数做类比也不太合适,函数操作的对象是通过Rust语言对业务逻辑的实现。而宏操作的对象是Rust语言被编译处理后的词条(token), 和函数相比操作的对象不是一个维度,所以宏也被称为元编程。

Rust的编译

Rust强大之处也是基于LLVM编译框架,不禁要问,为什么基于LLVM编译框架就强大了呢,因为有Clang (Apple研发的用来编译C、C++ 和Object-C) 成功先例,包括后来Apple 推出的Swift语言也是基于LLVM编译器框架。LLVM架构如下:

llvm-架构

框架主要分为前端、中间层(LLVM IR)、后端。当有新的语言接入框架我们只需要新建前端编译器部分,例如rustc,clang。后端生成各个平台的二进制编码部分是通用的。 如果想理解宏何时被rustc 处理的 我们还需要整体看一下rustc 如何生成LLVM IR的,整理后流程如下图 rustc-pipeline

  1. 分析rust文本代码中编译器可以识别的词条(token)。

    Rust宏就是在这个阶段进行处理,在词条流转为AST之前对他进行“二次加工”。

  2. 将词条流转为抽象语法书(AST).
  3. 语法分析阶段MIR

    主要把语法糖进行转化以及类型推断与检测。

  4. 语法分析阶段MIR

    主要用来检查所有权,生命周期等。

宏的分类

Rust 中有两种类型的宏:

  1. 声明宏 (Delarative macros) 通过写类似匹配表达式方式来匹配rust代码中的关键字, 然后生成最终代码,来替换引用宏的位置。
  2. 过程宏 (Procedural macros) 过程宏的函数签名 TokenStream => TokenStream, 一般我们可以借助第三方包解析TokenStream 生成抽象语法树,在此基础上,添加我们的逻辑,然后再借助第三方包把逻辑转为TokenStream.

值得注意的是两类宏的展开都是在rustc编译器AST阶段之前。

声明宏

书写声明宏的时候,以 macro_rules! 作为标识,然后可以在语句块里写若干个"匹配函数",匹配到的函数,来替换代码中宏的位置。下面是一个简单的宏:

macro_rules! add {
	($a: expr, $b: expr)=> {
		{
			$a+$b
		}
	}
}

fn main() {
	let a = add!(1, 2);
    println!("the a is {}", a);
}

每个“匹配函数”可以有多个参数 $作为标识,后面跟着分词(token)类型(上面代码中的expr)。token类型可以有如下几种:

  • item 条目, 例如函数、结构体、模块等
  • block 代码块,
  • stmt 语句(statement)
  • pat 匹配模式(pattern)
  • expr 表达式
  • ty 类型(type) 例如 u8 u16
  • ident 标识符 例如结构体、函数的名字
  • path 路径 例如::std::mem::replace
  • meta 元条目 例如 #[...] #![...]
  • tt 词条树
  • vis 显示限定符 例如 pub

下面来给add! 进行改造使其可以接收任意多个参数,为了做区分命名为add_more, 代码如下:

macro_rules! add_more {
	// 匹配函数1
	($a: expr) => {
		$a
	}; 
	// 匹配函数2
	($a: expr, $b:expr) => {
		{
			$a + $b
		}
	};
	// 匹配函数3
	($a: expr, $($b:tt)*) => {
		{
			$a + add_more!($($b)*)
		}
	}
}

fn main() {
	println!("the sum is {}", add_more!(1,2,3,4,5,6,7,8,9)); // the sum is 45
}

上面代码通过使用递归调用来处理不确定数量参数,匹配函数1和匹配函数2用来处理参数数量为一个和两个的情况, 当参数数量大于等于三个的时候会进入匹配函数3,匹配函数3会触发递归调用,递归的结束条件就是进入匹配函数2。

下面通过实现一个宏来给 struct 添加pub 前缀使struct变为共有, 看一下其他token类型的匹配。

#[macro_export]
macro_rules! make_public {
	(
		$(#[$meta:meta])*
		$vis: vis struct $struct_name:ident {
			$(
				$(#[$field_meta:meta])*
				$field_vis:vis $field_name:ident : $field_type:ty
			),*$(,)+ // 匹配结构体最后一个逗号
		}
	) => {
		{
			$(#[$meta])* 
			pub struct $struct_name {
				$(
					$(#[$field_meta:meta])*
					pub $field_name : $field_type,
				)*
			}
		}
	};
}

fn main () {
	make_public!(
        #[derive(Debug)]
        struct Name {
            n: i64,
            t: i64,
            g: i64, // 最后一个field需要逗号
        }
    )
}

$() 表示重复匹配当前类型的分词(token), 一般后面还会有分隔符和*+ (*表示匹配0次或多次,+表示匹配多次或一次), 值得注意的是$(), 这里逗号分隔符是分词之间的分割,像struct 结构体内最后一句field语句也需要逗号,所以实例中在$(...),*语句后又添加了逗号匹配语句$(,)+

过程宏

Rust的声明宏相比C语言的宏相比虽然很强大了,但是还是有一些局限性。声明宏只是针对匹配到的分词做“移花接木”,过程宏打破了这种局限,前面也提到过程宏的函数签名TokenStream => TokenStream, 在实现过程宏的时候一般需要借助syn 和 quote 两个库,syn 把接受到的TokenStream 转为 AST (抽象语法树),quote 库负责把Rust代码转为TokenStream。写过程宏的过程简单说就是 分析AST,添加业务逻辑,转回TokenStream。 为了说明声明宏和过程宏的区别,从Rust月刊上找到一个非常好的图(如侵权的话,联系必删)。 rust-macro-process

注意: 声明宏和过程宏的处理都是在rustc编译的TokenStream阶段,在rustc编译的AST阶段之前进行展开,syn库生成的AST,主要是为了帮我们分析TokenStream 这里不要和rustc编译的AST阶段弄混。

过程宏按功能分为三种:

  • 属性宏 (Attribute-like macros)
  • 派生宏 (Derive macros)
  • 类函数宏 (Function-like macros)

练习过程宏建议使用 dtolnay 大神分享 proc-macro-workshop 库,下面属性宏的使用的是sorted题目,派生宏使用的是CustomDebug题目。 proc-macro-workshop clone 下来后可以直接在已有的架构上写过程宏。为了讲述一下过程宏的配置,在属性宏部分还是讲解了过程宏的创建过程。

属性宏

在开始写属性宏之前我们先建一个 lib crate 使用如下命令

cargo new --lib sort-attr

需要在Cargo.toml 文件里声明一下过程宏

[lib]
proc_macro = true

添加synquote 两个依赖crate

syn = { version = "1.0.105", features = ["full", "extra-traits"] }
quote = "1"

下面我们以proc-macro-workshop sorted题目来实战一下属性宏,题目大概如下:

实现一个属性宏(Attribute macro)保证Enum里的属性是按照字母顺序排序的,如果发现不是这样的在编译阶段抛出错误,并且提示具体触发错误的属性位置 效果如下:

#[sorted]
#[derive(Debug)]
pub enum Error {
	BlockSignal(signal::Error),
	CreateCrasClient(libcras::Error),
	CreateEventFd(sys_util::Error),
	CreateSignalFd(sys_util::SignalFdError),
	CreateSocket(io::Error),
	DetectImageType(qcow::Error),
	DeviceJail(io_jail::Error),
	NetDeviceNew(virtio::NetError),
	SpawnVcpu(io::Error),
	}
  1. 先做一下准备工作在lib.rs 里 声明一下属性宏, 结构如下:
#[proc_macro_attribute]
pub fn sorted(_args: TokenStream, input: TokenStream) -> TokenStream {
	TokenStream::default()
}

proc_macro_attribute是属性宏的标识,另外属性宏函数除了接受被修饰元素的TokenStream, 还接受自身参数的TokenStrem, 可以根据参数做业务逻辑处理。比如Rocket 处理路由

#[get("/")]
fn index() -> &'static str {
   "Hello, world!"
}

宏属性参数当前sorted题目并没有涉及到。

  1. 想实现题目要求,其实只需要两步处理,第一步取出Enum 中的 variants, 第二步,对比每个variant 发现名称字符串没有按照顺序排序抛出错误,代码实现如下:
use proc_macro::{TokenStream };
use syn::{DeriveInput, parse_macro_input, Data, DataEnum, Ident };

#[proc_macro_attribute]
pub fn sorted(_args: TokenStream, input: TokenStream) -> TokenStream {
    let clone_input = input.clone(); 
	// 第一步
    let derive_input = parse_macro_input!(input as DeriveInput);
    let fields = match &derive_input.data {
        Data::Enum( DataEnum { variants, .. }) => variants,
        _ => panic!("expected enum or match expression"),
        
    };
    let idents = fields.iter().map(|field| &field.ident).collect::<Vec<_>>();
	// 第二步
    let mut prev_ident: Option<&Ident> = None;
    for ident in idents.into_iter() {
        if let Some(prev) = prev_ident {
            if ident < prev {
                panic!("{} should sort before {}", ident, prev);
            }
        }
        prev_ident = Some(ident)
    }
	// 没有panic 返回最初克隆tokenstream
    clone_input
}

对于没有接触过过程宏编程的同学可能会对第一步有点陌生,第一步其实主要是syn库的应用,针对返回的AST 做模式匹配取出据, 想要做深入的了解可以 参考一下syn官方文档。 更直观一点也可以打印derive_input查看一下数据结构(打印依赖 syn 的 extra-traits feature)。

上述代码github地址

大而全的文章读起来并不是很友好,所以关于派生宏 (Derive macros) 的内容准备在新起一个文档。

参考文档

https://zhuanlan.zhihu.com/p/140462815

https://rustmagazine.github.io/rust_magazine_2021/chapter_1/rustc_part1.html