Understanding Rust Macros: Declarative and Procedural

Last updated: Apr 13, 2025

1. Introduction: Code that Writes Code

Macros are a fundamental feature of Rust that enable metaprogramming—writing code that generates
other code at compile time. You’ve likely already used macros like println! or vec!
(notice the exclamation mark !, which typically denotes a macro invocation).

Unlike macros in C/C++, Rust macros are more structured and integrated into the language’s syntax tree (AST),
making them safer and more powerful. They allow you to reduce boilerplate code, create domain-specific
languages (DSLs), and implement features that would otherwise be difficult or impossible with functions alone.

Rust has two main types of macros: declarative macros and procedural macros.

This guide assumes familiarity with basic Rust concepts covered in Getting Started with Rust.

2. Macros vs. Functions

Why use macros when functions exist? Macros can do things functions cannot:

  • Variadic Arguments:Macros can take a variable number of arguments (e.g.,println!can take a format string and any number of arguments to interpolate).

  • Code Generation:Macros operate on Rust code itself before it’s fully compiled, allowing
    them to generate trait implementations (like#[derive(Debug)]) or entirely new code structures
    based on patterns.

  • Control Flow Implementation:They can implement control flow structures or repetitive
    patterns more concisely than functions.

  • Avoiding Runtime Cost:Macro expansion happens at compile time, potentially avoiding
    function call overhead for simple operations.

However, macros are generally more complex to write and debug than functions, and their code generation can
sometimes make compiler errors harder to understand.

3. Declarative Macros withmacro_rules!

Declarative macros, often called “macros by example” or defined using the macro_rules! keyword,
are the simpler form. They work similarly to a match expression but operate on Rust code syntax.

3.1 Syntax Overview

You define patterns (the “matcher”) and corresponding code templates (the “transcriber”) that Rust code
should be transformed into if it matches the pattern.

macro_rules! macro_name {
    ( matcher_pattern_1 ) => { transcriber_code_1 };
    ( matcher_pattern_2 ) => { transcriber_code_2 };
    // ... more rules
}

Matchers use special syntax to capture parts of the input code, like:

  • $ident:ident: Matches an identifier (variable name, function name).

  • $expr:expr: Matches an expression.

  • $ty:ty: Matches a type.

  • $($item:kind),*: Matches zero or more items separated by commas (repetition).

3.2 Example: A Simple Vector Macro

Let’s look at a simplified version of how the standard vec! macro might be defined:

macro_rules! my_vec {
    // Rule 1: Match zero or more expressions separated by commas
    ( $( $x:expr ),* ) => {
        // Transcriber code block
        {
            let mut temp_vec = Vec::new();
            // Repeat push() for each matched expression ($x)
            $(
                temp_vec.push($x);
            )*
            temp_vec // Return the vector
        }
    };
}

fn main() {
    let v = my_vec![1, 2, 3, 4]; // Invoking the macro
    println!("{:?}", v); // Output: [1, 2, 3, 4]

    let empty_v: Vec = my_vec![];
    println!("{:?}", empty_v); // Output: []
}

This macro takes a comma-separated list of expressions, creates a new vector, pushes each expression onto the
vector, and returns the vector.

3.3 Common Use Cases

  • Creating convenient syntax for initializing collections (vec!,hashmap!- often
    from external crates).

  • Reducing boilerplate for repetitive code patterns.

  • Implementing simple DSLs within Rust.

4. Procedural Macros

4.1 Introduction

Procedural macros (or “proc macros”) are more powerful but also significantly more complex to write. Instead
of matching patterns, they operate directly on the stream of tokens that make up Rust code during compilation.
They are essentially Rust functions that take a stream of tokens as input and produce another stream of tokens
as output.

Writing procedural macros requires creating a separate crate with the proc-macro = true setting
in its Cargo.toml and using libraries like syn (for parsing Rust code into an AST)
and quote (for generating Rust code from an AST).

There are three types of procedural macros:

4.2 Custom#[derive]Macros

These allow you to automatically implement traits for structs and enums by adding a
#[derive(MyTrait)] attribute. The most common built-in example is #[derive(Debug)],
which generates an implementation of the std::fmt::Debug trait.

// Using a built-in derive macro
#[derive(Debug, Clone, PartialEq)] // Debug, Clone, PartialEq are derive macros
struct Point {
    x: i32,
    y: i32,
}

// Libraries like Serde provide custom derive macros:
// #[derive(Serialize, Deserialize)]
// struct User { ... }

4.3 Attribute-like Macros

These custom attributes can be attached to any item (functions, structs, modules, etc.) and can modify the
item they’re attached to. They look similar to derive macros but are used in different places.

// Example from Actix Web framework
#[actix_web::main] // An attribute-like macro applied to the main function
async fn main() -> std::io::Result<()> {
   // ... server setup ...
}

#[get("/users/{id}")] // Another attribute-like macro defining a route
async fn get_user(id: web::Path) -> impl Responder {
    // ... handler logic ...
}

4.4 Function-like Macros

These look like regular macro calls (with a !) but operate using the more flexible token stream
processing of procedural macros. Examples include macros for embedding SQL or parsing complex input formats.

// Hypothetical example (syntax may vary based on actual crate)
// let sql = sql_macro!("SELECT * FROM users WHERE id = {}", user_id);

4.5 Complexity

Writing procedural macros involves understanding Rust syntax trees and working with token streams, making
them considerably more complex than declarative macros. They are typically used by library authors to provide
powerful abstractions and code generation capabilities.

5. Commonly Encountered Macros

Besides custom macros, you’ll frequently use macros from the standard library or popular crates:

  • println!/eprintln!: Print to standard output/error with formatting.

  • format!: Creates aStringwith formatted arguments.

  • vec!: Creates aVecwith specified elements.

  • assert!/assert_eq!/assert_ne!: Used in tests to assert
    conditions (panic if false).

  • panic!: Causes the current thread to panic. See ourError
    Handling in Rustguide.

  • todo!/unimplemented!: Placeholder macros that panic if executed.

  • #[derive(…)]: Procedural macro attribute for auto-implementing traits.

  • Framework-specific macros (like#[tokio::main],#[actix_web::get(…)]).

6. Conclusion

Macros are a powerful metaprogramming feature in Rust, enabling code generation at compile time to reduce
boilerplate and create expressive APIs or DSLs.

  • Declarative macros (macro_rules!)use pattern matching and are suitable for
    simpler code generation tasks and creating variadic functions likevec!.

  • Procedural macros(custom derive, attribute-like, function-like) are more complex but
    offer greater power by directly manipulating code tokens, enabling features like automatic trait
    implementations and framework-specific attributes.

While you might not write complex procedural macros often as an application developer, understanding how
macros work and recognizing common patterns like println!, vec!, and
#[derive] is essential for effectively using Rust and its ecosystem.

Additional Resources

Related Articles on InfoBytes.guru

External Resources