
Learn about rust marcos
本文最后更新于 2024-05-27,本文发布时间距今超过 90 天, 文章内容可能已经过时。最新内容请以官方内容为准
Learn about rust marcos
Rust marcos are a powerful feature that allow you to write code that is more concise, readable, and maintainable.
They are used to generate code at compile time, which can be used to perform tasks such as generating code for data structures, functions, and more.
声明式宏 (Declarative Macros)
Q1:Rust 宏中的 Token 是什么概念?A:Token 是 Rust 代码的最小单元,它是源代码中的一个元素,代表了语法的一部分。在 Rust 中,Token 可以是关键字、标识符、运算符、符号等。在宏中,我们需要操作和理解这些 Token,以便生成或转换代码。
macro_rules! add {
($a:expr,$b:expr) => {{
$a + $b
}};
}
上面的代码中,参数以$作为开头,:后表明该参数的类型,参数类型通常被称为 Token,Rust 中常见的 Token 类型有:
- 表达式(expr):表示 Rust 代码中的表达式,例如 x + y、if condition { true } else { false } 等,数字也是一种表达式。
- 语句(stmt):表示 Rust 代码中的语句,例如 let x = 1;、println!(“Hello, world!”); 等。
- 类型(ty):表示 Rust 代码中的类型,例如 i32、bool、String 等。
- 标识符(ident):表示 Rust 代码中的标识符,例如变量名、函数名、结构体名等。
通用 Token(tt):表示 Rust 代码中的任意 Token,可以用于匹配和生成任意类型的 Token。
Q2:声明式宏如何实现比函数更加灵活的功能,如不确定的参数类型、非固定数量的参数等?A:通过 Q1 我们知道,可以使用标记类型为 ty 的参数作为数据类型,如 u8、u16 等。我们接着改下上面的代码,使得该宏在添加数字之前,将其转换为特定类型。
macro_rules! add_as {
// ty 表示参数类型
($a:expr,$b:expr,$typ:ty) => {
$a as $typ + $b as $typ
};
}
fn main() {
// 这里 add! 宏可以使用多种数据类型
println!("{}", add_as!(0, 2, u8));
println!("{}", add_as!(0, 2, u16));
}
Rust 宏也支持接受非固定数量的参数。操作符与正则表达式非常相似,*用于零个或多个标记类型,+用于零个或一个参数。
macro_rules! add{
// 匹配单个参数
($a:expr)=>{
$a
};
// 匹配 2 个参数
($a:expr,$b:expr)=>{
{
$a+$b
}
};
// 递归调用
($a:expr,$($b:tt)*)=>{
{
$a+add!($($b)*)
}
}
}
fn main() {
println!("{}", add!(1, 2, 3, 4));
}
重复的标记类型包含在$() 中,后面跟着一个*或+,表示该标记将重复的次数。$($b:tt)表示 tt 类型的参数$b,可以重复 0~N 次,而 add!($($b)) 则表示多个$b会递归调用 add! 宏,因此也就实现了非固定数量的参数调用。
过程宏 (Procedural Macros)
过程宏中的派生宏。派生宏(Derive Macros):通常用于为 struct 结构体、enum 枚举、union 类型实现 Trait 特征。
使用时通过#[derive(CustomMacro)]这样的语法,允许用户轻松地为自定义类型提供一些通用的实现。
前文提到三种过程宏 (派生宏、属性宏、函数宏),它们的工作方式都是类似的:使用 Rust 的源代码作为输入参数,基于代码进行一系列操作后,再输出一段全新的代码。一个过程宏的简单框架如下:
use proc_macro::TokenStream;
// 标记类宏的类型
# [proc_macro_derive(CustomMacro)]
pub fn custom_macro_derive(input: TokenStream) -> TokenStream {
TokenStream::new()
}
proc_macro_derive 稍微特殊一些,因为它需要一个额外的标识符,此标识符将成为 derive 宏的实际名称,如 CustomMacro、Clone、Copy 等。input 输入标记流是添加了 derive 属性的类型,它将始终是 enum、struct 或者 union 类型,因为只有这些类型才可以使用 derive 派生宏。需要说明的是,过程宏中的派生宏输出的代码并不会替换之前的代码,而是在原来代码基础上追加指定宏的 Trait 实现
真实用例
solana 的 Anchor 框架中, #[derive(Accounts)]宏应用于指令所要求的账户列表,实现了给定 struct 结构体数据的反序列化,以及安全校验的功能。
// 派生宏
# [derive(Accounts)]
pub struct InitializeAccounts<'info> {
// 结构体中的字段
}
下面的代码我们定义了一个 Foo 结构体,通常情况下 struct 有许多 Trait 要实现。这里使用了 2 种方式,一种是常规的 impl,另一种是使用宏
struct Foo { x: i32, y: i32 }
// 方式一
impl Copy for Foo { ... }
impl Clone for Foo { ... }
impl Ord for Foo { ... }
impl PartialOrd for Foo { ... }
impl Eq for Foo { ... }
impl PartialEq for Foo { ... }
impl Debug for Foo { ... }
impl Hash for Foo { ... }
// 方式二
# [derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Debug, Hash, Default)]
struct Foo { x: i32, y: i32 }
这种情况下显然通过 derive 宏更加方便。但以上两种方式并没有孰优孰劣,主要在于不同的类型是否可以使用同样的默认特征实现,如果可以,那过程宏的方式可以帮我们减少很多代码实现。
FAQ
Q:派生宏的实现原理是什么?A:我们以 HelloMacro 这个 Trait 特征为例,这里有 MyStruct 和 YourStruct 这 2 个结构体实现了 Trait 特征的 hello_macro() 函数,并打印出对应的结构体的名称。
// 这是一个通用的 Trait 特征
trait HelloMacro {
fn hello_macro();
}
// 自定义结构体 MyStruct,并实现如上特征
struct MyStruct;
impl HelloMacro for MyStruct {
fn hello_macro() {
println!("Hello, Macro! My name is MyStruct!");
}
}
// 自定义结构体 YourStruct,并实现如上特征
struct YourStruct;
impl HelloMacro for YourStruct {
fn hello_macro() {
println!("Hello, Macro! My name is YourStruct!");
}
}
fn main() {
MyStruct::hello_macro();
YourStruct::hello_macro();
}
这个 HelloMacro 特征可以对任意结构体使用,并且在打印日志中自动替换成结构体的名称,因此把它定义为宏是比较合适的。按照过程宏定义的模板,我们实现如下的代码:
// 引入宏相关的依赖
extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;
use syn;
use syn::DeriveInput;
// HelloMacro 宏的实现逻辑
# [proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
let ast:DeriveInput = syn::parse(input).unwrap();
impl_hello_macro(&ast)
}
前几行代码引入了 macro 相关的依赖:Rust 的 quote 和 syn 库,其中 syn 库用于解析 Rust 代码的 AST(抽象语法树),而 quote 库用于生成 Rust 代码。
主体函数首先使用 syn::parse 函数解析输入的 TokenStream,并将其转换为 DeriveInput 类型的 ast。然后,它调用 impl_hello_macro 函数,将 ast 作为参数传递给它,生成实现 HelloMacro 特征的 Rust 代码,并将其转换为 TokenStream,并返回给调用者。
因此,当用户使用#[derive(HelloMacro)]标记了他的类型后,Rust 编译器在编译前会调用 hello_macro_derive 函数,生成相应的代码,即宏的展开。
在介绍 impl_hello_macro 函数之前,我们再来回顾下上节提到的 Token 概念,Token 是 Rust 代码的最小单元,它是源代码中的一个元素,代表了语法的一部分。
在 Rust 中,Token 可以是关键字、标识符、运算符、符号等。在宏中,我们需要操作和理解这些 Token,以便生成或转换代码。例如,在 Rust 中,let x = 5;这行代码可以被分解为以下 Token:
- let: 关键字
- x: 标识符
- =: 赋值运算符
- 5: 数字字面量
- ;: 分号
而 TokenStream 则是由一系列 Token 组成的序列,它是在宏展开期间传递和操作的数据类型。
它表示一段被解析和处理过的代码。TokenStream 可以包含多个 Token,它们组成了一个代码片段。
在宏中,通常会接收一个 TokenStream 作为输入,对其中的 Token 进行处理,然后生成一个新的 TokenStream 作为输出。
这种方式允许在编译时进行代码生成或代码转换。再来看一下一个结构体由哪些部分组成:
// vis,可视范围 关键字 ident,标识符 generic,范型
pub struct User <T> {
// fields: 结构体的字段
// vis ident type
pub name: &T,
}
- pub : 可视范围,在宏中通过 vis 来表示,表示标识符、模块、结构体等的访问权限。
- struct : 关键字 keyword,例如 fn、struct、if 等。
- User: 标识符 ident,例如变量名、函数名等。
:范型 generic - fields:结构体字段的集合
- T:在 pub name: &T 中表示 ident 的 type 类型,代码中的类型标识符,例如 i32、String 等。
syn::parse 调用会返回一个 DeriveInput 结构体来代表解析后的 Rust 代码,后续逻辑我们就是在此基础上调整相应的 Rust 代码,
这里大家也许更容易理解为什么说宏是一种元编程了:编写 Rust 代码的代码。
DeriveInput {
// --snip--
vis: Visibility,
ident: Ident {
ident: "MyStruct",
span: #0 bytes(95..103)
},
generics: Generics,
// Data 是一个枚举,分别是 DataStruct,DataEnum,DataUnion,这里以 DataStruct 为例
data: Data(
DataStruct {
struct_token: Struct,
fields: Fields,
semi_token: Some(
Semi
)
}
)
}
接下来看下如何构建特征实现的代码,也是过程宏的具体实现逻辑:
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let gen = quote! {
// 实现 HelloMacro 特征
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
gen.into()
}
首先,将结构体的名称赋予给 name,也就是 name 中会包含一个字段,它的值是字符串 MyStruct。其次,使用 quote! 可以定义我们想要返回的 Rust 代码。由于编译器需要的内容和 quote! 直接返回的不一样,因此还需要使用.into 方法其转换为 TokenStream。特征的 hell_macro() 函数只有一个功能,就是使用 println! 打印一行欢迎语句,使用 stringify! 获取#name 的字面值形式。有了这个宏,我们就可以直接用它来标记结构体
#[derive(HelloMacro)]
struct MyStruct;
如上就是过程宏定义的细节,完整的代码见下方 Example。
// Example
// HelloMacro 宏的定义
extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;
use syn;
use syn::DeriveInput;
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
let ast:DeriveInput = syn::parse(input).unwrap();
impl_hello_macro(&ast)
}
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let gen = quote! {
// 派生宏所实现的特征
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
gen.into()
}
// 宏的使用
#[derive(HelloMacro)]
struct MyStruct;
#[derive(HelloMacro)]
struct YourStruct;
fn main() {
println!("Hello, world!");
MyStruct::hello_macro();
YourStruct::hello_macro();
}
继续学习属性式宏和函数式宏。
说明:也有人将其称为 类属性宏 和 类函数宏,但这里提到的“类”并不是面向对象编程中的 class,而是 like,类似于的意思,因此这种叫法很容易让人混淆,翻译成“属性式”和“函数式”则更加贴切。
属性式宏(attribute-like macro):定义了可添加到标记对象的新外部属性。这种宏通过#[attr]或#[attr(…)]方式调用,其中…是标记的具体属性(可选)。一个属性式宏定义的简单框架如下所示:
use proc_macro::TokenStream;
// 这里标记宏的类型
#[proc_macro_attribute]
pub fn custom_attribute(input: TokenStream, annotated_item: TokenStream) -> TokenStream {
annotated_item
}
这里需要注意的是,与派生宏、函数式宏不同,属性式宏有两个输入参数,而不是一个。
- 第一个参数是属性名称后面的带分隔符的标记项(即#[attr(…)]中 (…) 的具体内容)。如果只有属性名称(其后不带标记项,比如 #[attr]),则这个参数的值为空。
- 第二个参数是标记的代码项本身的 Token 流,它可以是被标记的字段、结构体、函数等(见真实用例)。
真实用例
如下是 Solana 中 anchor 框架用到的#[account(…)]属性式宏,它按照该宏配置的属性来初始化 PDA 账户,其中 init、seeds、payer 等属性作为宏定义中第一个 TokenStream 参数,而 pub pda_counter: Account<'info, Counter>, 作为宏定义中第二个 TokenStream 参数。而对于结构体 Counter,则使用#[account]进行标记,以便 anchor 框架自动实现结构体的反序列化。
pub struct InitializeAccounts<'info> {
#[account(init, seeds = [b"my_seed", user.key.to_bytes().as_ref()], payer = user, space = 8 + 8)]
pub pda_counter: Account<'info, Counter>,
// ……
}
#[account]
struct Counter {
count: i32,
}
如下的代码展示了一些常见的属性式宏:#[cfg(…)]是根据条件编译的属性宏、#[test]是用于标记测试函数的属性宏、#[allow(…)]和 #[warn(…)]控制编译器的警告级别。
// 用于根据条件选择性地包含或排除代码
#[cfg(feature = "some_feature")]
fn conditional_function() {
// 仅在特定特性启用时才编译此函数
}
#[test]
fn my_test() {
// 测试函数
}
#[allow(unused_variables)]
fn unused_variable() {
// 允许未使用的变量
}
FAQ
Q:什么是函数式宏(function-like macro)?A:函数式宏跟声明宏类似,采用 macro_rules! 关键字定义,通过 custom_fn_macro!(…) 的方式来调用。但不同于声明式宏使用模式匹配的方式,函数宏则更像是常规的函数调用,可以使用各种 Rust 语法,包括条件语句、循环、模式匹配等,使得它更加灵活和强大。函数式的定义简单编写框架如下所示:
use proc_macro::TokenStream;
// 这里标记宏的类型
#[proc_macro]
pub fn custom_fn_macro(input: TokenStream) -> TokenStream {
input
}
可以看到,这实际上只是从一个 TokenStream 到另一个 TokenStream 的映射,其中 input 是调用分隔符内的标记项。例如,对于示例调用 foo!(bar),input 输入标记流即为 bar。返回的标记流将替换宏调用。
下面我们展示了 Rust 中常见的函数式宏,以及 Solana 中 anchor 框架的 declare_id! 宏
// vec! 用于创建 Vec 的宏。
let my_vector = vec![1, 2, 3];
// println! 和 format! 用于格式化字符串的宏。
let name = "World";
println!("Hello, {}!", name);
let formatted_string = format!("Hello, {}!", name);
// assert! 和 assert_eq! 用于编写断言的宏。
assert!(true);
assert_eq!(2 + 2, 4);
// panic! 用于在程序中引发 Panic 异常的宏。
panic!("Something went wrong!");
// env! 用于在编译时获取环境变量的宏。
let current_user = env!("USER");
println!("Current user: {}", current_user);
// declare_id! 是 anchor 框架中用于声明程序 ID 的宏
declare_id!("3Vg9yrVTKRjKL9QaBWsZq4w7UsePHAttuZDbrZK3G5pf");