Declarative Macros in Rust

Macros are a form of metaprogramming in Rust that allow you to write code that writes other code. Unlike functions, which operate on values, macros operate on the code itself, expanding into new code before the compiler interprets it. This enables you to reduce boilerplate, create domain-specific languages (DSLs), and implement patterns that are not possible with functions alone. You’ve already used macros if you’ve ever written println!, vec!, or panic!.

Rust has two types of macros:

  1. Declarative Macros: These use a match-like syntax to find and replace patterns in your code. They are often referred to as “macros by example.”
  2. Procedural Macros: These are more complex and operate as a function that takes a stream of tokens as input and produces a new stream of tokens as output. They are used for custom #[derive] attributes, attribute-like macros, and function-like macros.

This article focuses on the simpler and more common type: declarative macros, which are defined using the macro_rules! keyword.

Defining a Simple Macro with macro_rules!

A declarative macro is defined using macro_rules!. The structure is similar to a match expression. You define one or more “rules” that consist of a pattern to match (the “matcher”) and the code to generate (the “transcriber”).

Let’s create a simple macro that works like vec!, but for creating a Vec<String>.

// Define a macro named `svec`.
macro_rules! svec {
    // A single rule. The matcher is `( $( $x:expr ),* )`.
    // The transcriber is the code block that follows.
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push(String::from($x));
            )*
            temp_vec
        }
    };
}

fn main() {
    let my_vec = svec!["hello", "world", "rust"];
    println!("{:?}", my_vec); // Prints ["hello", "world", "rust"]
}

How macro_rules! Works: Pattern Matching

The core of macro_rules! is its pattern-matching system.

  • The macro invocation (svec![...]) is matched against the patterns defined in the macro.
  • The code inside the parentheses of the matcher is compared to the code passed to the macro.
  • $ is used to declare a variable that will capture a piece of Rust code.
  • $x:expr captures an expression and names it $x.

Designators: Specifying What to Match

The :expr part of $x:expr is called a designator. It specifies the type of code fragment to match. Here are some of the most common designators:

  • item: An item, like a function, struct, module, etc.
  • block: A block (a sequence of statements enclosed in braces).
  • stmt: A statement.
  • pat: A pattern.
  • expr: An expression.
  • ty: A type.
  • ident: An identifier (e.g., a variable name or function name).
  • path: A path (e.g., std::collections::HashMap).
  • tt: A token tree (can represent almost anything).
  • vis: A visibility specifier (e.g., pub).

Using the correct designator helps the compiler give you better error messages if the macro is called with the wrong syntax.

Repetition with $(...)*

The most powerful feature of declarative macros is repetition. The syntax $(...)* specifies that the pattern inside can match zero or more times.

Let’s break down the matcher from our svec! macro: ( $( $x:expr ),* )

  • (): The outer parentheses match the () in svec!(...).
  • $(): The dollar sign followed by parentheses indicates a repetition.
  • $x:expr: The pattern to be repeated is “an expression captured as $x”.
  • ,: The comma is the separator. It indicates that the repeated expressions must be separated by commas.
  • *: The asterisk indicates “zero or more” repetitions. You can also use + for “one or more” or ? for “zero or one”.

In the transcriber, $( temp_vec.push(String::from($x)); )* expands the code for each expression captured in the matcher.

A More Complex Example: A hashmap! Macro

Let’s create a macro to easily initialize a HashMap.

use std::collections::HashMap;

macro_rules! hashmap {
    // The pattern matches key-value pairs separated by commas.
    // e.g., hashmap!{ "a" => 1, "b" => 2 }
    ( $( $key:expr => $value:expr ),* ) => {
        {
            let mut temp_map = HashMap::new();
            $(
                temp_map.insert($key, $value);
            )*
            temp_map
        }
    };
}

fn main() {
    let counts = hashmap!{
        "apple" => 3,
        "banana" => 5,
        "orange" => 2
    };

    println!("I have {} apples.", counts["apple"]);
}

This macro makes creating a HashMap much more concise than calling insert manually multiple times.

Exporting Macros

By default, macros are only available in the module where they are defined. To make a macro available to other crates that use your crate, you need to annotate it with #[macro_export].

#[macro_export]
macro_rules! my_public_macro {
    // ...
}

When you use #[macro_export] on a macro, it is brought into the root namespace of your crate.

Conclusion

Declarative macros are a powerful feature for reducing boilerplate and creating convenient, expressive APIs. By mastering the pattern-matching and repetition syntax of macro_rules!, you can extend the Rust language itself to better suit the needs of your project. While they can seem complex at first, they are an indispensable tool for writing clean and maintainable Rust code.