簡介 Rust 編程語言裡面有兩種巨集系統,一種是聲明巨集(Declarative Macros),另一種為過程巨集(Procedural Macros)。聲明巨集和過程巨集是兩種基本上完全不一樣的巨集系統,編寫的方式也完全不一致,使用方式除了函數式外也不一致。關於聲明巨集學習,Rust 巨集小冊 裡面有比較詳細的 ...
簡介
Rust 編程語言裡面有兩種巨集系統,一種是聲明巨集(Declarative Macros),另一種為過程巨集(Procedural Macros)。聲明巨集和過程巨集是兩種基本上完全不一樣的巨集系統,編寫的方式也完全不一致,使用方式除了函數式外也不一致。關於聲明巨集學習,Rust 巨集小冊 裡面有比較詳細的說明,這裡不再啰嗦。而對於過程巨集,網上是可以搜索到的資料則相對較少,系統介紹學習的資料就更加少了。
過程巨集所做的事情則是從輸入中獲取到標記流,處理這些標記流或者生成新的標記流,然後將處理後的標記流返回給編譯器作下一步的處理。需要註意的是,過程巨集操作的是Rust AST
(抽象語法樹),所以即使是在巨集裡面,也必須是合法Rust的語法結構。這也就意味著,解析過程巨集的過程中,var
表示的是一個合法的標識符,而6var
則是非法的。
這篇文章是,對過程巨集進行一些不完全的探討和學習。
三種過程巨集形式
過程巨集必須是一個獨立的庫(很多開源項目喜歡用xxx_derive
的名稱命名),這個庫只導出過程巨集的函數,而這個巨集是被編譯器調用的。Cargo.toml
裡面必須有以下內容表明是一個過程巨集:
[lib]
proc-macro = true
proc_macro
是 Rust 編譯器提供的編寫過程巨集所需的類型和工具,過程巨集有以下三種表示形式:
derive
式
- 函數帶有
#[proc_macro_derive(Name)]
屬性或者#[proc_macro_derive(Name, attributes(attr))]
屬性 - 函數簽名為
pub fn xxxx (proc_macro::TokenStream) -> proc_macro::TokenStream
函數的名稱叫什麼並不重要,使用時是使用proc_macro_derive
裡面的名稱,如下例子
#[proc_macro_derive(Getters, attributes(getter))]
pub fn getters(input: TokenStream) -> TokenStream {
//...
}
使用
#[derive(Getters)]
struct Test {
#[getter(name=get_name)]
name: String
}
函數式
- 函數帶有
#[proc_macro]
屬性 - 函數簽名為
pub fn xxx (proc_macro::TokenStream) -> proc_macro::TokenStream
函數的名稱就是使用時名稱,如下例子:
#[proc_macro]
pub fn lazy_static(input: TokenStream) -> TokenStream {
//...
}
使用方式和聲明巨集調用一摸一樣
lazy_static!{
//...
}
屬性式
- 函數帶有
#[proc_macro_attribute]
屬性 - 函數簽名為
pub fn xxx(proc_macro::TokenStream, proc_macro::TokenStream) -> proc_macro::TokenStream
函數的名稱就是使用時名稱,如下例子:
#[proc_macro_attribute]
pub fn retry(attr: TokenStream, input: TokenStream) -> TokenStream {
//...
}
使用方式和Python
裝飾器的使用方式類似
#[retry(times=5, timeout=60s)]
pub fn fetch_data(url: String) -> Result<MyData> {
//...
}
一般來說,derive
式是對原有功能的擴展,原有的聲明是保留下的,更多是在原有基礎上增加功能,比如增加impl
函數,增加泛型約束等等。函數式則更多的是用於自定義語法的解析,如果聲明巨集描述語法困難一般可以考慮用函數式來替代。而屬性式則是完全對原有功能的改寫了,屬於替代性的。特別需要註意的是,過程並不是衛生性的,這點是和聲明巨集不一樣。也就是說,過程巨集它是會污染當前模塊。所以,在過程巨集裡面定義或使用類型時,必須要用全路徑的形式,而自定生成的函數變數等等命名也要特殊考慮,以防止污染了當前模塊。
quote
, syn
, proc_macro2
以及trybuild
和cargo-expand
編寫過程巨集,編譯器只提供proc_macro
這個crate
,不過它所提供的功能非常有限,單獨使用這個庫的話,編寫比較啰嗦和麻煩。因此大神dtolnay提供了,
和``這三個庫來簡化過程巨集的編寫,有了這三個crate
,編寫過程巨集就如同編寫普通的代碼一樣,除了調試困難一點外,基本沒什麼差別。這三個庫基本成功編寫過程巨集事實上的標準。
同時為了使編寫過程更加溫柔點而不至於暴躁,dtolnay提供了trybuild
這個庫用於編寫巨集的單元測試,cargo-expand
用於過程巨集的展開。
proc_macro2
proc_macro
所提供的TokenStream
和quote
和syn
所處理的TokenStream
是不相容的,所以另外增加一個 proc_macro2
,用於和proc_macro
的TokenStream
互相轉換。實際上這個庫的使用,只是在最後返回值裡面調用一下into
而已。
quote
quote
是將編寫的代碼轉換為Rust token的方式,提供一種稱之為quasi-quoting
的方式,將代碼視為數據,並可以進行插值。比較常用的是這兩個巨集:parse_quote!
,quote!
,以及format_ident!
。
syn
這是編寫過程巨集最重要的一個,大部分時間都是和這個庫進行打交道,它表示了一個完整的Rust 語法,如果看語言的Reference感覺到抽象,最好來這裡看代碼,它以非常具體的編碼實現告訴你這個Reference是怎麼表示的。
trybuild
平常的單元測試是編譯好的代碼,然後再運行測試用例。然而過程巨集的的測試,處需要測試是否編譯通過,也需要編譯出錯的結果是否正確。這是需要這個trybuild
庫了。
cargo-expand
cargo-expand
用於巨集代碼的展開,需要註意的是,需要正常編譯通過的代碼才可以進行展開。
使用這quote
, syn
, proc_macro2
三個庫來編寫過程巨集後,框架代碼基本一致,一般有如下三個步驟,如下方式:
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(MyMacro)]
pub fn my_macro(input: TokenStream) -> TokenStream {
// 將輸入的標記解析成語法樹
let input = parse_macro_input!(input as DeriveInput);
// 使用quote!進行插值處理
let expanded = quote! {
// ...
};
// 將proc_macro2的TokenStream轉換為proc_macro的TokenStream
TokenStream::from(expanded)
}
以下,編寫三種形式是示例,來看下著三種形式的過程過程巨集怎麼編寫以及怎麼使用。示例代碼倉庫:https://github.com/buf1024/my_macro_demo
示例:derive
式
假設我們需要為結構體生成一系列的getter
函數,當然getter
的名字是可以自定義的也可以根據預設的欄位名稱生成,也可以設置getter
的可見性,同時根據是否註釋生成對應的desc
函數。不需要考慮這樣的功能在實際工作中是否有意義,這裡的重點是學校過程巨集的編寫過程。
首先編寫巨集就是為了使用它,所以第一步,要瞭解是怎麼使用這個巨集:
// 首先可以是這樣簡單使用
#[derive(Getters)]
struct MyStruct {
data: String,
}
// 又或者想變更一下它的名稱
#[derive(Getters)]
struct MyStruct {
#[getter(name=get_fuck_data)]
data: String,
}
// 又或者是這樣
#[derive(Getters)]
struct MyStruct {
#[getter(vis=pub(crate))]
#[getter(name=get_fuck_data)]
data: String,
}
// 以及可能有註釋
#[derive(Getters)]
struct MyStruct {
/// 這是一個data的屬性
#[getter(vis=pub(crate))]
#[getter(name=get_fuck_data)]
data: String,
}
// 設置機構體可能複雜, 帶上了生命周期參數和泛型
#[derive(Getters)]
struct MyStruct<'a, T: Sync+Send+Constraint> {
#[getter(vis=pub(crate))]
#[getter(name=get_fuck_data)]
data: &'a str,
constraint: T
}
確定了過程巨集的使用方式後,我就可以可以定義我們的導出函數了:
#[proc_macro_derive(Getters, attributes(getter))]
pub fn getters(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let token_stream = expand_getters(input);
token_stream
.unwrap_or_else(|e| e.into_compile_error())
.into()
}
我們將輸入token
流解析為DeriveInput
,是因為DeriveInput
實現了Parse
trait。定義如下:
pub trait Parse: Sized {
fn parse(input: ParseStream) -> Result<Self>;
}
pub struct DeriveInput {
pub attrs: Vec<Attribute>,
pub vis: Visibility,
pub ident: Ident,
pub generics: Generics,
pub data: Data,
}
pub enum Data {
Struct(DataStruct),
Enum(DataEnum),
Union(DataUnion),
}
從DeriveInput
所實現的Parse
和DeriveInput
數據結構可以看出,derive
式過程巨集只支持Struct
,Enum
和Union
三種數據結構。
寫過程巨集的一個重要的工作就是獲取所修飾的數據結構的基本信息,而對於derive
式過程巨集來說,這些數據放到attrs
這個屬性裡面,用Attribute
這個結構來表示,Meta
則是存儲這樣數據的。
pub struct Attribute {
pub pound_token: Token![#],
pub style: AttrStyle,
pub bracket_token: token::Bracket,
pub meta: Meta,
}
pub enum Meta {
Path(Path),
/// A structured list within an attribute, like `derive(Copy, Clone)`.
List(MetaList),
/// A name-value pair within an attribute, like `feature = "nightly"`.
NameValue(MetaNameValue),
}
Meta是什麼鬼?按照syn
的文檔:
/// text
/// #[derive(Copy, Clone)]
/// ~~~~~~Path
/// ^^^^^^^^^^^^^^^^^^^Meta::List
///
/// #[path = "sys/windows.rs"]
/// ~~~~Path
/// ^^^^^^^^^^^^^^^^^^^^^^^Meta::NameValue
///
/// #[test]
/// ^^^^Meta::Path
///
需要註意的是,註釋文檔是解析為#[doc = r" Single line doc comments"]
的。所以,文檔註釋的獲取:
let doc_str: String = f
.attrs
.iter()
.filter(|attr| attr.path().is_ident("doc"))
.try_fold(String::new(), |acc, attr| {
let mnv = match &attr.meta {
syn::Meta::NameValue(mnv) => mnv,
_ => return Err(syn::Error::new_spanned(attr, "expect name value!")),
};
let doc_str = match &mnv.value {
syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(lit),
..
}) => lit.value(),
_ => return Err(syn::Error::new_spanned(attr, "expect string literal!")),
};
Ok(format!("{}\n{}", acc, doc_str))
})?;
前面提到DeriveInput
實現了Parse
trait進行解析,而我們要對Attribute
裡面的內容進行解析,則是需要實現該trait:
impl Parse for GetterMeta {
fn parse(input: syn::parse::ParseStream) -> Result<Self> {
let lookahead = input.lookahead1();
if lookahead.peek(kw::name) {
let _: kw::name = input.parse()?;
let _: Token![=] = input.parse()?;
let name: Ident = if input.peek(LitStr) {
let sl: LitStr = input.parse()?;
let value = sl.value();
format_ident!("{}", value.trim())
} else {
input.parse()?
};
Ok(Self {
name: Some(name),
vis: None,
})
} else if lookahead.peek(kw::vis) {
let _: kw::vis = input.parse()?;
let _: Token![=] = input.parse()?;
let vis = input.parse()?;
Ok(Self {
name: None,
vis: Some(vis),
})
} else {
Err(lookahead.error())
}
}
}
寫法可以完全參考DeriveInput
的寫法,調用Atrribute
的,parse_args
或parse_args_with
,則可以調用到Parse
trait。
獲取到基本的數據後,自然就生成代碼,這裡兩個重要的巨集: quote!
和parse_quote!
。最後生成的代碼用#[automatically_derived]
進行裝飾,說明這是自動生成的代碼。
let (impl_generic, type_generic, where_clause) = input.generics.split_for_impl();
Ok(quote! {
#[automatically_derived]
impl #impl_generic #st_name #type_generic #where_clause {
pub fn hello(&self) {
println!("hello!");
}
#getters
}
})
過程巨集寫好之後,我們就要寫單元測試了,當然也可以先寫單元測試,再寫過程巨集,這稱為測試用例驅動的開發模式。不過,作為示例,我們不寫很詳細的測試用例,我們只寫兩個測試用例,一個是成功的,一個是失敗的,展示怎麼使用即可。失敗的用例,我們需要提供一個和測試文件一致,以.stderr
結尾的輸出,如果編譯器的輸出一致則測試通過,這個輸出可以先編譯出來,讓編譯器生成,然後自己對比是不是自己想要的結果。
過程巨集使用後,是否符合自己的需要,需要巨集展開來觀察,使用是cargo exand
命令:
#![allow(dead_code)]
use my_derive::Getters;
#[derive(Getters)]
struct MyStructRef<'a> {
/// 你好呀
#[getter(vis=pub(crate))]
#[getter(name = "get_fuck_data")]
data: &'a str,
}
fn main() {}
//展開代碼
#![feature(prelude_import)]
#![allow(dead_code)]
#[prelude_import]
use std::prelude::rust_2021::*;
#[macro_use]
extern crate std;
use my_derive::Getters;
struct MyStructRef<'a> {
/// 你好呀
#[getter(vis = pub(crate))]
#[getter(name = "get_fuck_data")]
data: &'a str,
}
#[automatically_derived]
impl<'a> MyStructRef<'a> {
pub fn hello(&self) {
{
::std::io::_print(format_args!("hello!\n"));
};
}
pub(crate) fn get_fuck_data(&self) -> &'a str {
&self.data
}
pub fn get_fuck_data_desc(&self) -> &'static str {
"你好呀"
}
}
fn main() {}
示例:屬性式
假設我們有這樣一個需求,可以對原有函數進行增加一些其他功能,比如retry
,可以設置調用超時時間,超時後或者出錯後,可以進行重新調用,類似於Python裝飾器,可以考慮用屬性式過程巨集表示。同樣,因為是測試例子,所以,沒有超時和重試功能,只做怎麼獲取屬性巨集的數據和生成代碼。
首先確定調用的形式:
#[retry(times=5, timeout=60)]
fn remote_request(a: i32, b: i32) -> i32 {
println!("@remote_request!");
a + b
}
再確定其展開形式:
// cargo exapnd 生成的
fn remote_request(a: i32, b: i32) -> i32 {
fn __new_remote_request(a: i32, b: i32) -> i32 {
{
::std::io::_print(format_args!("@remote_request!\n"));
};
a + b
}
for _ in 0..5 {
__new_remote_request(a, b);
}
for _ in 0..60 {
__new_remote_request(a, b);
}
__new_remote_request(a, b)
}
屬性式的簽名是這個樣子的:
#[proc_macro_attribute]
pub fn retry(attr: TokenStream, input: TokenStream) -> TokenStream
屬性和輸入流已經作為參數傳遞給我們了,而我需所要做的是需要將屬性解析出來和對原函數的重寫。
let item_fn = parse_macro_input!(input as ItemFn);
let args = parse_macro_input!(attr as Args);
和derive
式的一樣,通過實現Parse
trait來解析:
impl Parse for RetryAttr {
fn parse(input: syn::parse::ParseStream) -> Result<Self> {
let lookahead = input.lookahead1();
if lookahead.peek(kw::times) {
let _: kw::times = input.parse()?;
let _: Token![=] = input.parse()?;
let times: LitInt = if input.peek(LitInt) {
input.parse()?
} else {
return Err(lookahead.error());
};
Ok(Self {
times: Some(times),
timeout: None,
})
} else if lookahead.peek(kw::timeout) {
let _: kw::timeout = input.parse()?;
let _: Token![=] = input.parse()?;
let timeout: LitInt = if input.peek(LitInt) {
input.parse()?
} else {
return Err(lookahead.error());
};
Ok(Self {
times: None,
timeout: Some(timeout),
})
} else {
Err(lookahead.error())
}
}
}
示例:函數式
假設我們要計算二元二次方程組的值,我們計劃是這樣使用的:
let (x, y) = formula!(1 * x + 1 * y = 2, 2 * x + 1 * y = 9);
這個巨集直接就在編譯期間就計算出x,y的值(當然是存在有解的情況下),無解就panic
。為了使問題簡單,我們假設x,y前面都是有繫數的。過程巨集的操作過程就是解析出裡面的表達式:
pub(crate) struct FormulaArgs {
formula: Vec<Formula>,
}
impl Parse for FormulaArgs {
fn parse(input: syn::parse::ParseStream) -> Result<Self> {
let attrs = Punctuated::<Formula, Token![,]>::parse_terminated(input)?;
let formula: Vec<_> = attrs.into_iter().collect();
if formula.len() != 2 {
panic!("require two formula")
}
Ok(FormulaArgs { formula })
}
}
#[derive(Default)]
pub(crate) struct Formula {
x: i32,
y: i32,
rs: i32,
}
impl Parse for Formula {
fn parse(input: syn::parse::ParseStream) -> Result<Self> {
let lookahead = input.lookahead1();
let x = if lookahead.peek(LitInt) {
let x_lit: LitInt = input.parse()?;
let x: i32 = x_lit.to_string().parse().unwrap();
let _: Token![*] = input.parse()?;
let _: kw::x = input.parse()?;
x
} else {
return Err(lookahead.error());
};
let r1: Result<Token![+]> = input.parse();
let r2: Result<Token![-]> = input.parse();
let factor = match (r1, r2) {
(Ok(_), Err(_)) => 1,
(Err(_), Ok(_)) => -1,
(_, _) => return Err(lookahead.error()),
};
// let factor = if lookahead.peek(Token![+]) {
// let _: Token![+] = input.parse()?;
// 1
// } else if lookahead.peek(Token![-]) {
// let _: Token![-] = input.parse()?;
// -1
// } else {
// return Err(lookahead.error());
// };
let y = if lookahead.peek(LitInt) {
let y_lit: LitInt = input.parse()?;
let y: i32 = y_lit.to_string().parse().unwrap();
let _: Token![*] = input.parse()?;
let _: kw::y = input.parse()?;
y * factor
} else {
return Err(lookahead.error());
};
let _: Token![=] = input.parse()?;
let rs_lit: LitInt = input.parse()?;
let rs: i32 = rs_lit.to_string().parse().unwrap();
if x == 0 && y == 0 && rs != 0 {
return Err(syn::Error::new_spanned(rs_lit, "invalid equal"));
}
Ok(Self { x, y, rs })
}
}
當解析出所需要的數據後,直接就可以進行插值生成自己所需要的數據。
總結
過程巨集的編寫過程有兩個步驟。
首先是解析出過程巨集所需要的信息,這個步驟一般是通過實現syn
所提供的Parse
trait實現的。由於syn表示的也是合法的語法結構,所以並不是所以的寫法都是支持的。有時候,解析的時候出出現一些莫名奇妙解析不了的問題,比如解析二元方程時:
// let factor = if lookahead.peek(Token![+]) {
// let _: Token![+] = input.parse()?;
// 1
// } else if lookahead.peek(Token![-]) {
// let _: Token![-] = input.parse()?;
// -1
// } else {
// return Err(lookahead.error());
// };
這段代碼總是無法解析成功,原因未解,或者使用姿勢不對,又或者是syn
可能潛在有bug
。不過都有變通的方法實現。
其次解析出自己所要的數據後,就可以根據具體的情況進行插值處理,只有是使用到quote
這個庫,而大多數情況之下只使用到兩個巨集: quote!
和parse_quote!
。當然也不是說沒有坑。比如說解析出函數的block
時,再重組時,要加上大擴號。再比如,解析出行數調用列表時,解析成一個元組表達式,而插值時,可以放個函數名稱在前面,就變成了函數調用。而這些都是變成過程巨集編寫的慣例吧,習慣就好。
錯誤處理是給巨集的使用者看的,友好的錯誤提示很容易就讓調用者知道哪裡錯了。而使用錯誤處理是比較簡單的,直接掉用syn::Error
生成一個Span
即可,Span
也是可以combine
的裡面有個token
的參數。不過如果不知道token
的情況之下怎麼處理呢?目前自己的做法是panic
,這並非是一種明智的方式。或者有更加靈活的處理方式。
使用cargo expand
是可以查看到巨集展開的內容,不過cargo expand
的問題是過程巨集沒有問題時,才可以正常的展開,出現編譯問題不展開,這也就造成了調試過程巨集的困難,目前也沒有什麼好的辦法去解決。
總體來說,過程巨集的編寫並不是非常困難,syn
表示了一個完整的Rust語法,查看裡面語法的表示,比看語言Reference強太多了,而這對於更深入瞭解聲明巨集的片段分類符更有幫助!proc-macro-workshop 是大神dtolnay設計的過程巨集系統,全部做出來,估計寫過程巨集沒有什麼問題了。
再貼一下示例代碼倉庫:https://github.com/buf1024/my_macro_demo
Post AT:
https://mp.weixin.qq.com/s/17jPRjzyU4lkSD-mS1wxvg