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!()
}
编译器会将派生宏作用的结构体(或enum
、union
)展开成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
由于派生宏的使用场景相对固定(目前只能作用于struct
、enum
、union
),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##b
,CONCAT(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
文件。