发布于

Rust的宏(三)

Authors
  • avatar
    Name
    Charly
    Twitter

Rust的宏(三)

前言

上一篇文章讲了Rust过程宏(Procedural macros)中的派生宏(Derive macros), 派生宏主要是针对struct(或 enum, union) 做便捷扩展,今天我们继续讲一下函数宏(Function-like macros, 也有人翻译为“类函数宏”,“类”很容易让人想到面向对象语言中的类,所以我在本文章里都称作函数宏)

函数宏(Function-like macros)

函数宏和声明宏类似,调用可以像函数一样哪里需要就在哪里调用,不过宏在调用的时候是需要有!号的。在开启Rust大门时,当写下println!("Hello,World")的时候,!号是唯一的疑惑。从声明宏到现在的函数宏一路走来,基本上认可了这种写法,它确实是区分函数和宏比较直观的一种表示方式。函数宏比声明宏灵活性更强,可以分析任何传入的内容,并且可以根据内容添加自己的逻辑,更进一步可以说函数宏可以让我们实现自定义语法。

函数宏实践

开胃小菜

先看一个简单的例子,实现一个创建hello函数的函数宏。

#[proc_macro]
pub fn make_hello(_input: TokenStream) -> TokenStream {
   let tt = quote! (
        fn hello () {
            println!("hello, world");
        }
   );
   tt.into() // proc_macro2::TokenStream -> proc_macro::TokenStream
}

fn main() {
   make_hello!();
   hello(); // hello, world
}

过程宏的函数签名都是(TokenStream)-> TokenStream, 函数宏也不例外。函数宏使用 #[proc_macro]标识来区分与其他过程宏的不同(其他过程宏相关可以参考前两篇文章)。 quote!宏是用来把Rust代码转译成proc_macro2::TokenStream的, 使开发者不需要关注分词(Token)。

这里函数宏是main 函数的上下文添加了hello函数,这个行为说明函数宏是不具有卫生性hygiene,所以上面hello函数名称在作用域内要保证唯一性。

深入函数宏

在上面的例子中,我们没有考虑到调用函数宏传入参数的情况。在实际应用中大部分情况我们都需要处理函数宏input参数的,类似属性宏和派生宏那样。我们处理派生宏的input参数宏时可以直接使用syn库提供的DeriveInput, 由于函数宏参数比较灵活,原则上可以接受任何输入,所以我们需要借助syn库自己处理解析参数。 我们还是使用 dtolnay 大神分享 proc-macro-workshop 库里的 seq 题目来实践一下函数宏,题目大概描述如下: 我们应用需要有一系列的数值变体(variants)就像 Cpu0 Cpu1 Cpu2 ... Cpu511, 由于写代码时候 声明出 500多个枚举的成员,代码会又丑又长,所以通过seq函数宏来做实现。 代码如下:

use seq::seq;

seq!(N in 0..512 {
    #[derive(Copy, Clone, PartialEq, Debug)]
    pub enum Processor {
        #(
            Cpu~N,
        )*
    }
});

fn main() {
    let cpu = Processor::Cpu8;

    assert_eq!(cpu as u8, 8);
    assert_eq!(cpu, Processor::Cpu8);
}

通过分析传入的给seq函数宏的语句块,我们大概知道需要做如下几步处理:

  1. N in 0..512循环语句的解析.
  2. 解析大括号语句块。
  3. 属性#[derive(Copy, Clone, PartialEq, Debug)]的解析
  4. 枚举Processor名称的解析
  5. #()* 重复表达式的解析以及重复表达式内容Cpu~N的解析(需要解析出Cpu 后面用来枚举Processor变体variant的拼接) 经过以上几步解析后,把解析后的信息通过quote宏“拼接”在一起返回TokenStream。

关于 N in 0..512的解析

在自定义解析时,需要实现syn库里的 parse trait。代码如下:

pub trait Parse: Sized {
    fn parse(input: ParseStream<'_>) -> Result<Self>;
}

ParseStream 可以理解为拥有游标的Buffer(ParseStream其实是&ParseBuffer的别名)这就要求解析的时候要一个分词分词的处理,不能跳跃,或者不处理某个分词。不然的话,编译器不会放过你的。 所以在解析N in 0..512的时候,其实对N, in, 0..512的分别解析,代码如下:

let _: Ident = input.parse()?; // 解析 N
input.parse::<Token![in]>()?;  // 解析 in
let pat: Pat = input.parse()?; // 解析 0..512

这里初学者可能会疑惑,为什么都是调用的input参数的parse()方法就可以分析出不同的分词?这里其实运用了rust的类型推导,这里可以看一下ParseStream的parse 方法实现:

pub fn parse<T: Parse>(&self) -> Result<T> {
    T::parse(self)
}

它可以根据返回值的类型(像解析N0..512 那样),也可以显示指定类型(像解析in那样)调用对应类型的parse 方法。 另外值得一提的是在解析in的时候使用的是Token宏,它主要用来处理语句中rust语言的关键词或者运算符的,不需要为常出现的关键词或者运算符再实现一次parse 方法,当然它们的解析结果,我们往往也是不关心的。 关于Pat 类型相关 可以查阅一下文档0..512其实是Pat 枚举下的PatRange 的 Range 变体(variant).

关于大括号和小括号的解析

循环语句结束后紧挨着是一个被大括号包裹的语句块,大括号的解析结果我们其实并不关心,只需要取括号里面的内容,为了处理这类情况,syn 库提供了braced宏。

let content;
braced!(content in input); 

上面代码中content变量,就是大括号内容的ParseStream 我们可以拿来继续解析。类似的关于小括号的解析syn库提供了parenthesized宏,使用方法和braced宏是一样的。

关于属性#[derive(Copy, Clone, PartialEq, Debug)]的解析

看到派生宏的属性,初学者可能会犯愁,难道还要一层中括号,一层小括号的解析出来?,莫急,其实syn库就是 rust语言的解析库,这种派生宏属性的解析一定在库里有可以拿来用的接口,只要耐心查一下文档就可以了。由于是属性相关,我们可以检索到(Attribute 类型)[https://docs.rs/syn/latest/syn/struct.Attribute.html],解析派生宏属性可以用如下代码:

let attrs: Vec<Attribute> = input.call(Attribute::parse_outer)?; 

rust属性按语法分为内部属性(InnerAttribute)和外部属性(OuterAttribute), 内部属性以#!开头,应用在它所在的项(item),外部属性以#开头应用于后面紧跟的项(Item), 像派生宏属性,属于外部属性,所以调用Attribute::parse_outer。关于更多属性相关可以参考文档

关于 #(Cpu~N,)*的解析

这条重复语句需要解析出Cpu作为Processor枚举变体(variants)的前缀,像#~, , 以及*的解析,都可以使用Token宏,小括号的解析可以使用parenthesized宏。所以代码如下:

    input.parse::<Token![#]>()?;
    let content;
    parenthesized!(content in input);
    input.parse::<Token![*]>()?;

    let ident: Ident = content.parse()?; // Cpu 前缀
    content.parse::<Token![~]>()?;
    let _ = content.parse::<Ident>()?;
    content.parse::<Token![,]>()?;

整体代码结构设计

上面各个小节介绍了如何通过syn库处理各个需要解析的点,但代码是散乱的,写代码需要从整体设计。题目中的 N in 0..512 {} 可以设计成一个包含数值范围和大括号内需要通过quote处理的数据块的seq, 另外数值范围的解析也可以单独定义一个结构体Range 代码结构大概如下:

struct Range {
    start: i32,
    end: i32,
    limit: RangeLimits,
}

trait Render {
    fn render(&self, range: &Range) -> proc_macro2::TokenStream;
}

pub struct Seq {
    range: Range,
    data: Box<dyn Render>,
}

接着是大括号内的内容可以定义一个EnumData 结构体,包含枚举名称,前缀名称以及枚举的属性列表。代码如下:

struct EnumData {
    ident: Ident,
    field_prefix: Ident,
    attrs: Vec<Attribute>,
}

完整代码地址

各位读者在实践的时候,可以按着自己的想法去定义,这部分的定义仅做参考。

宏的总结

三篇文章讲解了Rust的声明宏、属性宏、派生宏、以及函数宏,在不同场景下的它们配合使用,使Rust语言的逻辑复用更便捷和强大。值得注意的是它们生成的TokenStream被编译器处理的方式有一些区别,声明宏,属性宏以及函数宏生成的TokenStream会替换到宏在代码中的调用位置。而派生宏比较特殊是追加到项(item)的后面。

参考文档

Rust Attribute

syn

hygiene