Skip to content

在 Rust 使用宏与元编程

发布于  at 04:47 AM

程序都是由数据和指令(代码)构成的。通常对于程序来讲,指令(代码)是不变的。然而,如果可以使用指令(代码)生成指令(代码),程序就可以获得更加高度的灵活性,而这就是 元编程

元编程通常实现的方式有两种,根据代码生成的时机可以划分为「编译期」或者「运行期」。对于动态语言来讲,例如:Python、Lisp 或者 JavaScript,可以在运行期实现元编程。编译型的静态语言,例如:C/C++、Rust,是没有办法实现在运行时生成指令的,因此只有「编译期」的元编程。

最简单元编程是 C 语言的 #define,它会在预处理器期间实现文本替换功能。在 C++ 中,可以通过「模板」实现更为高级的元编程。

对于 C 语言的 #define,它并不是一个“卫生”的宏。预编译期间展开文本后,可能会破坏上下文的代码环境,从而产生一些意想不到的问题。“卫生”宏则指的是对于上下文没有潜在的破坏,没有副作用。

Rust 宏的使用场景

实际应用场景中,也会使用到宏,例如:

  1. 领域特定语言(DSL):如 regex! 宏编译期验证正则表达式
  2. 零成本抽象lazy_static! 实现安全的惰性初始化
  3. 编译期计算const_format! 在编译时生成格式化的字符串
  4. 元数据处理serde 的序列化/反序列化实现
  5. 测试工具增强tokio::test 宏简化异步测试

需要注意,应该谨慎地使用宏,它们会使代码难以维护和理解。这是因为它们是在元级别工作,所以没有多少开发人员会习惯使用它们。使用太多的宏会使代码更难理解,从可维护性的角度来看,可读性的优先级始终应该排在前面。

此外,大量使用宏会导致产生大量重复的代码,这会影响 CPU 指令缓存,从而降低性能。

宏的类型

使用 macro_rules! 创建宏

使用 macro_rules! 创建一个简单的宏,用于将输入的字符串读入缓冲区:

use std::io;

macro_rules! scanline {
    ($x:expr) => {
        io::stdin().read_line(&mut $x).unwrap();
    };
}

fn main() {
    let mut input = String::new();
    scanline!(input);
    println!("I read: {:?}", input);
}

声明宏非常类似 match 表达式,也是一个模式匹配的过程。

($x:expr)$x 是一个标记树的变量,右侧的部分是一个规则,expr 是标记树类型之一,表示只能接受表达式。

宏可以有多个匹配规则,可以添加一个空规则:

use std::io;

macro_rules! scanline {
    ($x:expr) => {
        io::stdin().read_line(&mut $x).unwrap();
    };
    () => {{
        let mut s = String::new();
        io::stdin().read_line(&mut s).unwrap();
        s
    }};
}

fn main() {
    let mut input = String::new();
    scanline!(input);
    println!("I read: {:?}", input);

    let a = scanline!();
    println!("I read: {:?}", a);
}

标记类型

类型解释示例
expr匹配任意表达式1x + 1if x == 4 { 1 } else { 2 }
ident匹配标识符。不是关键字(比如:iflet)的 Unicode 字符串x
long_identifier
SomeSortOfAStructType
item匹配元素,模块级的内容可以被当作元素,包括函数、use 声明及类型定义use std::io
fn main() { println!("hello") }
const X:usize = 8;
meta元项#[foo]的元项 foo
#[foo(bar)] 的元项 foo(bar)
pat模式,每个 match 表达式中左侧都是模式,由 pat 捕获1"x"tSome(t)1...3_
path限定名称,与标识符非常类似,只是允许在名称中使用双冒号foofoo::bar
stmt语句,和表达式类似let x = 1expr 不会接收这个语句)
tt标记树,它由一系列其他标记构成{bar; if x == 2 (3) ulse} 4 {;baz}(不一定具有语义的内容,只需要是一系列标记即可)
tyRust 类型u32u33(宏展开阶段并不需要进行语义检查,但是进入语义分析阶段,会报错)、String
vis可见性修饰符pubpub(crate)
lifetime生命周期'a'ctx'foo
literal任何标记的文字字符串文字(例如"foo")或标识符(例如 bar

重复

vec! 宏可以支持可变的参数,例如你可以使用 vec![1, 2, 3],可以查看一下标准库中 vec! 的定义:

macro_rules! vec {
    () => (
        $crate::__rust_force_expr!($crate::vec::Vec::new())
    );
    ($elem:expr; $n:expr) => (
        $crate::__rust_force_expr!($crate::vec::from_elem($elem, $n))
    );
    ($($x:expr),+ $(,)?) => (
        $crate::__rust_force_expr!(<[_]>::into_vec(box [$($x),+]))
    );
}

先忽略 => 后面的细节,重点放在匹配规则上:

($elem:expr; $n:expr)
($($x:expr),+ $(,)?)

和正则表达式类似,重复可以有以下三种形式:

第三个匹配规则中,vec! 宏只是将它转成 Box 类型,可以看到,右边并没有 expr,只有 $x。这就意味着可以宏里再调用宏。

示例:为 HashMap 初始化创建 DSL

我们可以使用宏生成一个语法糖,对于 HashMap,使用的时候通常是这样的:

let mut contacts = HashMap::new();
contacts.insert("GuYu", "123456");
contacts.insert("George", "774321");

如果希望实现类似下面的这样的语法,实现一个更加简洁、直观的插入操作:

let mut contacts = map! {
  "GuYu" => "123456",
  "George" => "774321"
};

可以定义下面这样的宏:

macro_rules! map {
    ($($k:expr => $v:expr), *) => {
        {
            let mut map = ::std::collections::HashMap::new();
            $(
                map.insert($k, $v);
            )*
            map
        }
    };
}

过程宏

对于复杂问题,如果需要完全控制代码的生成,你可以使用过程宏。按照调用方式,可以分为以下几类:

类属性宏

新建一个 lib 的项目 macro_demo,在 Cargo.toml 中指明要创建过程宏:

[lib]
proc-macro = true

过程宏传入是 TokenStream,并返回一个 TokenStream。为写一个过程宏,我们需要写一个解析器来解析 TokenStram。Rust 中的 syn 是个解析 TokenStream 很不错的依赖,提供了现成的解析器。

添加 synquoteCargo.toml

[dependencies]
syn = {version ="1.0.98", features = ["full", "fold"]}
quote = "1.0.20"

lib.rs 中添加代码:

extern crate proc_macro;

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_attribute]
pub fn my_custom_attribute(_metadata: TokenStream, _input: TokenStream) -> TokenStream {
    TokenStream::from(quote! {struct H{}})
}

可以新建一个测试文件进行测试:

use macro_demo::*;

#[my_custom_attribute]
struct S {}

#[test]
fn test_macro() {
    let _demo = H {};
}

派生宏

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput}; // 添加必要的导入

#[proc_macro_derive(Trait)]
pub fn derive_trait(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);

    let name = input.ident;

    let expanded = quote! {
        impl Trait for #name {
            fn print(&self) -> usize {
                println!("{}", "hello from #name")
            }
        }
    };

    TokenStream::from(expanded)
}

可以使用 DeriveInput 来解析输入到派生宏。

类函数宏

类函数宏类似于声明宏,但它比声明宏更加强大。类函数宏不是在运行时执行,而是在编译时执行。

#[proc_macro]
pub fn a_proc_macro(_input: TokenStream) -> TokenStream {
    TokenStream::from(quote!(
        fn answer() -> i32 {
            5
        }
    ))
}

小结

本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自小谷的随笔

上一篇
在 Go 语言中实现依赖注入
下一篇
明智行动的艺术:52 条行动指南