Rust过程宏入门(三)——实现简易派生宏

写于2021年1月10日。最早发表于知乎

再谈派生宏的原理

简单起见,我们先假定Command结构体中只有必填单选项,无选填或多选项,简化后的Command类如下:

#[derive(Builder)]
pub struct Command {
    executable: String,
    args: Vec<String>,
    current_dir: String,
}

首先实现Command类中的builder函数:

impl Command {
    pub fn builder() -> CommandBuilder {
        CommandBuilder
    }
}

pub struct CommandBuilder;

为此派生宏创建一个crate,注意需要在Cargo.toml中加上

[lib]
proc-macro = true

以表示该crate将编译为一个过程宏库。 回顾一下派生宏的定义方式,我们需要根据输入的TokenStream生成目标TokenStream

#[proc_macro_derive(Builder)]
fn derive_builder(input: TokenStream) -> TokenStream {
    let _ = input;

    unimplemented!()
}

编译器会将派生宏作用的结构体(或enumunion)展开成TokenStream作为函数参数传入。不出意料的话,展开后的TokenStream会由如下几项构成:

关键字pub 关键字struct 标识符Command 花括号{

标识符executable 冒号: 标识符String逗号,

标识符args 冒号: 标识符Vec 小于号< 标识符String 大于号> 逗号,

标识符current_dir 冒号: 标识符String逗号,

花括号}

我们需要用到的Token有:结构体名标识符Command,每个字段的名字标识符与类型的Token串。其他信息只起结构标记的作用,无需用到。

应当如何提取这些信息呢?

一种方法是利用TokenStream类的to_string函数,将这些Token转换为字符串,然后再用字符串处理的手段提取其中的信息。但这就有一个问题:编译器好不容易将源代码文件中的文本信息(也即字符串),转换成了语法树,在此处展开成TokenStream以调用过程宏处理,但我们反而再次将TokenStream转换为原始的字符串,岂不多此一举?

另一种方法是使用syn库。syn库提供了表示语法解析结果(一般为语法树上的某一节点)的一系列类。

若要使用syn库,需要在项目的Cargo.toml文件中指定:

[dependencies]
syn = "1.0" 

认识DeriveInput

由于派生宏的使用场景相对固定(目前只能作用于structenumunion),syn库中已提供了派生宏输入项的封装——DeriveInput。其结构如下:

pub struct DeriveInput {
    pub attrs: Vec<Attribute>,
    pub vis: Visibility,
    pub ident: Ident,
    pub generics: Generics,
    pub data: Data,
} 

其中ident字段正是我们所需的结构体名的标识符。结合parse_macro_input!宏,容易将输入的TokenStream解析为DeriveInput

#[proc_macro_derive(Builder)]
fn derive_builder(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    
    unimplemented!()
}

若以#ident指代结构体名Command,那么我们已经可以实现一个简易版的builder函数:

impl #ident {
    pub fn builder() -> #identBuilder {
        identBuilder
    }
}

pub struct #identBuilder;

当然上述代码中的#identBuilder是有问题的,这里暂时跳过。

quote!宏生成TokenStream

如何生成这段代码呢?不难想到,可以直接用字符串拼接的方法再转换成TokenStream输出,但这会让编译器再多一层词法分析的步骤,且不易阅读与扩展。(字符串格式化的占位符是{},当输入代码篇幅较大时,占位符与实际传入参数相隔很远,且不便于一一对应。)

另一种方法是用quote库,同样需在Cargo.toml后面加上quote = "1.0"的依赖。

quote库中的quote!宏提供了将Rust语法项展开为TokenStream的功能, 包含在quote!宏中的任何Rust代码都将展开为TokenStream,而以#开头的标识符将引用前文中已定义的标识符,而非像字符串格式化那样使用{}并在末尾传入参数的方式。这便是笔者在上文中用#ident指代Command类名的原因。

需要注意的是,与syn库搭配的TokenStream来自proc_macro2库, 其别名为TokenStream2,需要用.into()方法转换为TokenStream才能作为过程宏函数的返回值。

quote!宏包含Command::builder函数的实现部分,得:

#[proc_macro_derive(Builder)]
fn derive_builder(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let ident = input.ident;
    let ident_builder = /* 暂时跳过 */;
    quote!(
        impl #ident {
            pub fn builder() -> #ident_builder {
                ident_builder::default()
            }
        }
        pub struct #ident_builder;
    )
    .into()
}

拼接标识符

最后回到CommandBuilder标识符的问题。刚才提到,Command标识符可由DeriveInput直接获取,但CommandBuilder的标识符却需要手动拼接生成。

笔者联想到C++中的宏定义中有##运算符可用于拼接标识符,譬如#define CONCAT(a, b) a##bCONCAT(foo, bar)就会得到一个foobar的标识符,想必Rust应当也有类似的宏,于是搜索Rust标准库发现果然有一个名为concat_idents!的宏,然后这样写:

pub struct concat_idents!(#ident, Builder);

但尝试未果,总是会报出类似error: expected `where`, `{`, `(`, or `;` after struct name, found `!` 的错误。后来才发现Rust中并不支持在标识符位置的宏调用(详见rust-lang/rust#4365 · Macros don't get expanded when used in item name token position)。编译器会将concat_idents当作struct的名字标识符 ,然后并不知道如何处理这个叹号!Token, 故而只能报编译错误。

几经周折,最终还是选择了手动拼接构造标识符,因为标识符位置不可用宏,所以一切基于宏的办法都失效了,总不至于退回到原始的字符串拼接的方式吧?

所幸syn库中的Ident类有由字符串新建实例的构造函数new,但观察其构造函数

pub fn new(string: &str, span: Span) -> Ident

发现,除了传入一个字符串以外,还需要一个Span对象。Ident类中的文档示例代码是传入了一个Span::call_cite(),如:

let ident = Ident::new("demo", Span::call_site());
let temp_ident = Ident::new(&format!("new_{}", ident), Span::call_site());     

文档中对Span::call_cite()的解释是:

identifiers created with this span will be resolved as if they were written directly at the location of the macro call, and other code at the macro call site will be able to refer to them as well.

大致意思是,标识符的span会解析为宏调用的地方。如何理解「span」的含义呢?在Span的文档中有定义:

A region of source code, along with macro expansion information.

Span代表了源代码的某一区间,并携带了宏展开的信息。也就是说,Ident::new()中传入的Span是用来定位标识符的位置的。而CommandBuilder标识符是与Command相关联的,不妨将其span设置为Command标识符的区间就好。 因此有:

let ident = input.ident;
let ident_builder = Ident::new(&format!("{}Buidler", ident), ident.span());

利用cargo expand命令检验代码生成结果

将前面的代码全部综合到一起,得:

use proc_macro::TokenStream;

use quote::quote;
use syn::{parse_macro_input, DeriveInput, Ident};

#[proc_macro_derive(Builder)]
pub fn derive(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let ident = input.ident;
    let ident_builder = Ident::new(&format!("{}Builder", ident), ident.span());
    quote! (
        impl #ident {
            pub fn builder() -> #ident_builder {
                ident_builder
            }
        }

        pub struct #ident_builder;
    )
    .into()
}

最后我们用cargo expand命令来检验生成的代码。这里要指出的是,使用过程宏须与定义过程中处在不同的crate中。

use derive_builder::Builder;

#[allow(dead_code)] // 忽略警告
#[derive(Builder)]
pub struct Command {
    executable: String,
    args: Vec<String>,
    current_dir: String,
}

fn main() {}

展开后将得到:

#![feature(prelude_import)]
#[prelude_import]
use std::prelude::v1::*;
#[macro_use]
extern crate std;
use derive_builder::Builder;
#[allow(dead_code)]
pub struct Command {
    executable: String,
    args: Vec<String>,
    current_dir: String,
}
impl Command {
    pub fn builder() -> CommandBuilder {
        CommandBuilder
    }
}
pub struct CommandBuilder;
fn main() {}

正是所期望的结果。


本期的介绍到此为止,主要介绍了如何将输入的TokenStream解析为DeriveInput,提取其中的名字信息,使用Ident类提供的构造函数拼接成新的标识符,最后用quote!宏生成目标代码的TokenStream。 接下来将继续介绍用派生宏实现CommandBuilder类的字段生成、函数实现等功能。

本文中的代码实现详见 https://github.com/frank-king/proc-macro-workshop/tree/example/builder/01-parse.rs/builder

其中,测试用的main.rs代码见example/builder/01-parse.rs中的main.rs文件