Skip to content

Rust Macro Programming: Learning Rust Declarative Macros from Scratch

kingzcheung
Published date:
Edit this post

You may not have seen the implementation of Rust macros, but you’ve certainly used them. The first line of “hello world” we wrote when learning Rust programming was printed using the println! macro. It is probably one of the most commonly used macros in Rust, and its biggest difference from a function is that it has an extra ”!” when called. In addition to println!, there are also quite commonly used macros like vec! and assert_eq!. One could say that macros are ubiquitous in Rust.

If you’ve used enough macros, you might have noticed an issue: why do some macros use () for invocation, others use [], and still others use {}. In fact, all three methods can be used:

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

However, it’s just a convention that println! uses (), and vec! uses [].

Declarative Macros

Rust has two types of macros: declarative macros (macro_rules!) and procedural macros.

Procedural macros are relatively complex, so let’s leave them for another time.

Declarative macros allow us to create custom code structures that resemble the built-in match expression in syntax. As a control flow construct, match takes an expression and compares its result with multiple predefined patterns. When a pattern matches successfully, the corresponding code block is executed immediately, providing an intuitive and concise way to handle various possible scenarios. With the power of declarative macros, developers can not only emulate the behavior of match but also customize more complex and flexible matching logic according to specific needs, thus greatly enhancing the expressiveness and maintainability of Rust code.

What Can We Do With Them?

Code Generation

Most library authors use declarative macros primarily to eliminate boilerplate code, as no one wants to write repetitive tasks.

By generating highly repetitive, structurally similar code through macros, it helps reduce the workload of manually writing large amounts of redundant code and improves code consistency and maintainability.

Simplified API

Developers can use macros to create more concise and user-friendly API interfaces. For example, by encapsulating complex configuration options or initialization processes within macros, callers only need to provide a few parameters to complete operations. A typical example is vec!.

DSL

Some people use declarative macros to implement small DSLs (Domain-Specific Languages). DSLs make the logic of a specific domain more intuitive, improving development efficiency and code readability.

Implementing a Declarative Macro

Unlike attribute macros, which require a separate package declaration, declarative macros can be defined similarly to functions.

Declarative macros are defined using macro_rules!, and if you need to export them for external use, you should add #[macro_export]. They look like this:

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

Let’s start with an example to see how to use declarative macros to eliminate boilerplate code.

Suppose we need to define an enum in our business logic, looking something like this:

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

Typically, we would need to implement a series of From<T> traits for this enum, like so:

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

Without implementing a declarative macro, we’d have to write similar code seven times. Even with only eight members, the actual business logic might involve far more than eight. Let’s see how to implement a declarative macro to eliminate this repetitive code.

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

Let’s explain this declarative macro:

The ty and ident above are known as Macro Fragment Specifiers (MacroFragSpec), and their possible values are limited to: item | block | stmt | pat_param | pat | expr | expr_2021 | ident | lifetime | literal | meta | path | tt | ty | vis.

Here are some explanations:

The + in $($t:ty, $o:ident),+ is somewhat similar to regular expressions, meaning one or more occurrences.

Therefore, when calling the macro, we can invoke it all at once like this:

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

Besides +, there are two other operators:

Those who are interested or have a need can learn about the usage of other macro fragment specifiers.

Previous
Nginx 的竞争者: Pingora 的使用
Next
Rust 宏编程: 从零开始学习Rust声明式宏