RustSystems Programming

Rust Macros: Metaprogramming with Declarative and Procedural Macros

TT
TopicTrick Team
Rust Macros: Metaprogramming with Declarative and Procedural Macros

Rust Macros: Metaprogramming with Declarative and Procedural Macros

Fundamentally, functions in Rust have a limitation: they must enforce strict typing, and they must accept a specific, fixed number of arguments.

Look at the println! macro. You can pass it one argument (println!("Hello")) or twenty arguments (println!("{} {}", a, b...)). Looking at vec![1, 2, 3], you are instantiating a struct dynamically utilizing standard square-bracket array syntax!

This flexibility is impossible with standard Rust functions. To achieve this, rust implements Metaprogramming through Macros.

A Macro is code that writes code. Before the Compiler ever attempts to check ownership or compile machine instructions, the Macro Engine runs a parsing pass. It identifies all the macros, expands them by dynamically generating raw Rust code, and injects that generated code back into the source tree.

In this module, we will explore the two completely different varieties of Macros in Rust: Declarative Rules engines, and Procedural Syntax Trees.


1. Declarative Macros (macro_rules!)

Declarative macros are the most common type of macro you will write yourself. They behave similarly to a match expression, but instead of matching against data values, they match against the Abstract Syntax Tree (AST) syntax of the source code itself.

To define one, use the macro_rules! construct. Let's build a heavily simplified representation of how the standard vec! macro fundamentally operates beneath the hood.

rust
// The macro_export attribute makes this macro available globally 
// to any file that imports this crate.
#[macro_export]
macro_rules! my_vec {
    // We define a matching pattern: `$( $x:expr ),*`
    // This translates to: "Take any Rust Expression ($x:expr), 
    // note that it might be repeated separated by commas (,*) "
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            
            // For every single expression that matched the pattern, 
            // generate a `.push()` command!
            $(
                temp_vec.push($x);
            )*
            
            temp_vec // Return the filled vector!
        }
    };
}

fn main() {
    // When you use the macro...
    let v = my_vec![1, 2, 3];
}

When you write my_vec![1, 2, 3];, the compiler intercepts it. It recognizes three integers separated by commas. It then deletes the my_vec![...] line entirely and literally copy-pastes this exact code in its place before trying to verify types:

rust
let v = {
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec
};

This drastically reduces boilerplate while maintaining zero runtime overhead, because by the time the application forms machine code, the macro has completely vanished into raw, perfectly unrolled methods.


2. Procedural Macros

Declarative macros are essentially advanced find-and-replace tools based on pattern matching boundaries.

Procedural Macros are entirely different beasts. They are physical Rust functions that you write. They accept a stream of tokens (the raw source code of the file), manipulate that stream mathematically or structurally, and generate a completely new stream of source code tokens to replace it.

Because they require interacting with the compiler's abstract syntax tree directly, procedural macros must actually reside in their own distinct crate (specifically configured with proc-macro = true in the Cargo.toml).

There are three types of Procedural macros.

A. Custom #[derive] Macros

When we wrote #[derive(Debug)] above a Struct earlier in the series, we were using a custom derive macro. The compiler passed the exact definition of our structurally defined Struct directly to the Debug macro, which mathematically mapped over our fields and appended a massive impl Debug for Struct block automatically.

You can write your own. For instance, if you write an API system, you could write a procedural macro to generate JSON parsers automatically: #[derive(Serialize, Deserialize)] (as seen in the famous serde crate).

B. Attribute Macros

Attribute macros define custom decorators you can place over functions or structs. We used one in the previous chapter when building Async functions:

rust
#[tokio::main]
async fn main() {
    // Application code
}

The #[tokio::main] attribute macro consumes the entire main function. It generates a massive new, hidden main function that initializes an entire multithreaded runtime environment (tokio::runtime::Builder::new_multi_thread()), and then executes the code you originally wrote inside that dynamically generated thread pool! It literally swallows and rewrites your core loops.

C. Function-Like Macros

Function-like macros look exactly like declarative macros (they are invoked with an exclamation mark !), but the internal logic isn't macro_rules!; it is a complex procedural function.

The most famous example is executing SQL queries securely:

rust
// The sql! macro takes your string, contacts the actual Database driver
// purely during compile time, and verifies the SQL syntax is mathematically valid
// BEFORE allowing the project to finish building.
let user = sql!("SELECT * FROM users WHERE id = {}", user_id);

3. The Power and Danger of Code Generation

The primary library developers use to parse these Token Streams when building Custom Procedural Macros is syn (for parsing rust code into an AST tree) and quote (for converting an AST tree back into physical Rust code strings).

rust
// A heavily simplified look at writing a procedural macro
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    let ast = syn::parse(input).unwrap();

    // The name of the struct it was attached to
    let name = &ast.ident;
    
    // Generate the raw rust implementation string!
    let gen = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };
    
    // Send it back to the compiler!
    gen.into()
}

The Compile-Time Tax

Metaprogramming enables features like ORMs (Object Relational Mappers), Web Frameworks (Rocket, Actix), and serialization logic (serde). Without Procedural macros, using Rust for enterprise web development would be excruciating.

However, because these macros must execute full Rust functions while your code is compiling, using hundreds of macro attributes massively increases compile time. If your cargo build is taking 5 minutes to run, it is overwhelmingly likely due to deep macro expansion routines in third-party library crates.

Declarative (macro_rules!)Procedural Macros
Primary MechanismPattern Matching against syntax tokens.Functional Rust code that manually parses AST definitions.
Invocation StyleFunction-like invocation (e.g., vec![]).Attributes (#[derive], #[tokio::main]) and functional syntax.
Complexity & LocationLower complexity. Defined directly inside standard application files.Massive complexity. Must be isolated in designated proc-macro Crates.

Summary and Next Steps

By opening up the AST to developers, Rust allows you to extend the raw capabilities of the language syntax. You aren't at the mercy of the compiler's rigidity; if you hate the boilerplate required to map states, you can engineer a macro that writes the boilerplate dynamically for you.

This flexibility is essential, as the next feature we look at requires heavily modifying how the compiler analyzes code blocks.

In the next module, we investigate Unsafe Rust. We deliberately tell the strict Borrow Checker to turn off its safety constraints, allowing us to manually dereference raw 0x00 C-level memory pointers to interoperate freely with High-Frequency Trading systems written in C++.

Read next: Unsafe Rust: Unlocking Raw Pointers and FFI Integration →



Quick Knowledge Check

Why might the development team for an HTTP routing framework choose to rely on Procedural Attribute Macros (like #[get('/index')]) rather than exclusively relying on standard typed struct methodologies?

  1. Because Procedural Macros automatically bypass the Borrow Checker, ensuring routing definitions evaluate with less system latency.
  2. Because standard Structs cannot hold string data at compile time, eliminating URL pathways entirely.
  3. Because it radically improves ergonomics. Instead of requiring developers to manually construct massive implementation blocks and route registries by hand, the procedural macro does the heavy lifting structurally during compilation. ✓
  4. Because the 'macro_rules!' declarative engine is slated for deprecation in the upcoming 2026 Edition.

Explanation: Macros exist to remove boilerplate. Writing routing tables manually requires tedious state management. A procedural attribute macro like #[get] extracts the URL, generates the requisite implementation routing structs, and links the handlers completely automatically without polluting the developer's source file.