Dec 16, 2024
3 min read
Rust,

Rust 宏编程: 从零开始学习Rust声明式宏

声明宏的使用方法

你或许没有见过 Rust 宏的实现,但是你一定使用过它。因为我们第一次学习 Rust 编程的时候,写下的第一句“hello world”就是通过 println! 这个宏来打印的。它大概是 Rust 中一个最常用的宏,可以看到它和函数最大的区别是:它在调用时多了一个“!”,除此之外还有 vec!assert_eq!都是相当常用的,可以说宏在 Rust 中无处不在。

如果你使用的宏够多,你肯定会发现一个问题,为什么有些宏使用 () 调用,有些使用[]调用,还有使用 {} 的。其实,这三种方法都能使用:

fn main() {
    println!("hello world");
    println!["hello world"];
    println!{"hello world"}
}

只不过, println! 使用 ()vec!使用 [] ,只是一个约定俗成。

声明式宏

Rust 有两类宏,声明式宏( declarative macros ) macro_rules! 和过程宏( procedural macros )。

过程宏相对比较复杂,这个我们以后再说。

声明式宏使得我们可以创建出语法上类似于内置 match 表达式的自定义代码结构。match 作为一种控制流构造,它接受一个表达式,并将其结果与多个预定义模式进行比较。当某个模式成功匹配时,相应的代码段即刻执行,这提供了一种直观而简洁的方式来处理多种可能的情况。借助声明式宏的力量,开发者不仅能够模拟 match 的行为,还能根据特定需求定制更加复杂和灵活的匹配逻辑,从而极大提升了Rust代码的表达能力和可维护性。

我们用它能干什么?

代码生成

其实大多数库作者用声明宏,基本都是用来消除一些样板代码,毕竟没有谁愿意写重复的工作内容。

通过宏可以自动生成重复性高、结构相似的代码。这有助于减少手动编写大量冗余代码的工作量,并提高代码的一致性和可维护性。

简化API

开发者可以使用宏来创建更简洁和易用的API接口。例如,通过宏可以将复杂的配置选项或初始化过程封装起来,使调用方只需提供少量参数即可完成操作。典型的如 vec!

DSL

还有一部分人使用声明宏实现小型DSL。DSL可以让特定领域的逻辑表达更加直观,提升开发效率和代码的可读性。

实现一个声明宏

声明宏不像属性宏那样需要单独在一个包中声明。我们可以像定义函数一样定义声明宏。

声明宏使用 macro_rules! 来定义,如果你还需要导出给外部使用,还要添加#[macro_export] 。看起来像这样:

#[macro_export]
macro_rules! my_macro {
    ( $( $x:expr ),* ) => {
        {
            //...
        }
    };
}

我们以一个实例的例子出发,看看如何使用声明宏来消除样板代码。

假设我们在业务中需要定义一个枚举,看起来像这样的:

pub enum Value {
    None,
    Bool(bool),
    Int8(i8),
    Int16(i16),
    Int32(i32),
    Long(i64),
    Float(f32),
    Double(f64),
}

一般我们需要为这个枚举实现一系列的 From trait ,比如这样:

impl From<bool> for Value {
    fn from(value: bool) -> Self {
        Self::Bool(value)
    }
}

如果我们不实现声明式宏,就得老老实实的写7次上面这个类似的代码,上面只有8个成员还好,实现的业务可能远远不止8个呢?让我们看看怎么实现一个声明宏来消除这些重复代码。

macro_rules! impl_from_for_field {
    ( $($t: ty, $o: ident ),+ ) => {$(
        impl From<$t> for Value {
            fn from(v: $t) -> Self {
                Self::$o(v as _)
            }
        }
    )*};
}

让我们解释一下这段声明宏:

  • macro_rules! 是 Rust 中定义声明宏的关键字。
  • impl_from_for_field 是宏的名字。
  • ( $($t: ty, $o: ident ),+ ) 是宏的参数模式:
    • 最外面的圆括号 () 将整个宏模式包裹
    • $()括号中模式相匹配的值会被捕获,然后用于代码替换。
    • $t: ty,该模式中的 ty 表示会匹配任何 Rust 类型,并给予该模式一个名称 $t
    • $o: ident,该模式中的 ident 表示会匹配任何 Rust 表达式,并给予该模式一个名称 $o

上面的 tyident 被称为 MacroFragSpec(宏片段说明符),MacroFragSpec 的值只可能是以下几个: item |block |stmt |pat_param |pat | expr | expr_2021 | ident | lifetime | literal | meta | path | tt | ty | vis

下面是一些说明:

  • item:一个 Item (Function、Struct、等 )
  • block:一个 BlockExpression 块表达式({})
  • stmt:不带尾部分号的 Statement(需要分号的 itemStatement 除外)
  • pat_param:一个 PatternNoTopAlt
  • pat:至少任何 PatternNoTopAlt,并且可能更多,具体取决于版本
  • expr:除 UnderscoreExpressionConstBlockExpression 之外的 Expression(参见 macro.decl.meta.expr-underscore
  • expr_2021:与 expr 相同(参见 macro.decl.meta.edition2021
  • ty:类型
  • identIDENTIFIER_OR_KEYWORDRAW_IDENTIFIER
  • pathTypePath 样式路径
  • tt:一个 TokenTree(匹配分隔符 ()[]{} 中的单个令牌或令牌序列)
  • meta:一个 Attr,即一个 attribute 的内容
  • lifetime:一个 LIFETIME_TOKEN
  • vis:可能为空的 Visibility 限定符
  • literal:匹配 ?LiteralExpression(文本表达式)

$($t: ty, $o: ident ),+ 中的 + 有点类似于正则表达式,就是表示1个或者多个的意思。

所以我们在调用的时候,可以一次性这样调用:

impl_from_for_field! {
    bool, Bool,
    i8,  Int8,
    i16, Int16,
    i32, Int32,
    i64, Long,
    f32, Float,
    f64, Double
}

除了+外,还有另外两种

  • * — 表示任意数量的重复。
  • ? — 表示出现次数为零或出现一次的可选片段。

有兴趣或者有需求的朋友可以了解一下其他宏片段说明符的用法。