Macros in Rust - A Deep Dive

Garvit DadheechGarvit Dadheech
16 min read

super casual read. just enough to build real mental models.

we are not doing procedural macros here (derive / attribute / function style). that will be a separate blog. here we focus on declarative macros (macro_rules!).

why care?

if you've ever written:

println!("{}", String::from("Hello Solana"));

and thought "ok cool, function call" – surprise: println! is a macro, not a normal function. macros look like function calls, but they work before the normal compile step. they take in tokens (pieces of rust syntax), match patterns, and spit out new rust code that then gets compiled.

in this post we'll:

  • do a quick refresher on traits (esp Display and Debug) because printing a struct is the door that leads us into macros.

  • look at the error you get when you try to println!() a custom type.

  • fix it by implementing Display.

  • see why #[derive(Debug)] just works with {:?}.

  • talk about what a macro really is.

  • use cargo expand to peek at macro expansion.

  • write our own declarative macro step by step.

  • build a mini vec! clone called myvec!.

  • learn the repeat tokens: *, +, ?, and how separators like , work.

by the end, writing small useful macros would be fairly easy, i hope.


quick word on traits (think: interfaces-ish)

if you come from java: a trait in rust is kinda like an interface. it says: "any type that implements me promises to provide these methods." but traits can also provide default method bodies (interfaces in java 8+ can too, but you get the idea).

simple mental model:

  • define a trait with function signatures.

  • a struct (type) implements that trait.

  • each type gives its own body for the functions.

let's do shapes.

trait Shape {
    fn area(&self) -> f64; // just the signature here
}

struct Square {
    side: f64,
}

struct Circle {
    radius: f64,
}

impl Shape for Square {
    fn area(&self) -> f64 {
        self.side * self.side
    }
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
}

fn main() {
    let s = Square { side: 3.0 };
    let c = Circle { radius: 2.0 };
    println!("square area: {}", s.area());
    println!("circle area: {}", c.area());
}

ok that's traits. hold that thought.


printing a struct: the error that starts the story

now try to print a struct directly:

struct Square {
    side: i32,
}

fn main() {
    let sq = Square { side: 12 };
    println!("{}", sq);
}

compiler says something like:

error[E0277]: `Square` doesn't implement `std::fmt::Display`
  --> src/main.rs:XX:XX
   |
XX |     println!("{}", sq);
   |                       ^^ `Square` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Square`

yep. we tried to use the {} formatter, which means: "use the Display formatting for this type." rust can't do that unless we give it the implementation.

fix it: implement Display

use std::fmt; // bring fmt stuff into scope

struct Square {
    side: i32,
}

// the same intuition when u implemented Shape to square
impl fmt::Display for Square {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Side of square is {}", self.side)
    }
}

fn main() {
    let sq = Square { side: 12 };
    println!("{}", sq); // now works!
}

boom. we told rust how to write our struct for user-facing output.

why no auto Display?

rust does not auto-generate a Display implementation because Display is supposed to be what your users see. rust can't guess what "nice" looks like for your type. so you write it. hmm makes sense!

but {:?} works out of the box if i derive Debug?

yes and that's because the standard library can give you a useful default for debugging use:

#[derive(Debug)]
struct Square {
    side: i32,
}

fn main() {
    let sq = Square { side: 12 };
    println!("{:?}", sq);     // debug print inline
    println!("{:#?}", sq);    // pretty debug output
}

#[derive(Debug)] generates a basic implementation of the Debug trait. this is aimed at devs, not end users. so it's ok that it's kind of raw.

btw you can’t use #[derive(Display)] - as this is not exposed as macro.

small mental model:

formattertrait usedwhen to use
{}Displayuser-facing text, nice print
{:?}Debugdeveloper-facing inspect print
{:#?}Debugpretty multi-line dev print

ok, so where do macros fit in?

that println! we keep calling? it's a macro that:

  1. takes your format string + args as tokens.

  2. passes them to another macro (format_args!).

  3. builds code that writes to stdout with a newline.

// a bit complex to read
macro_rules! println {
    () => {
        $crate::print!("\n")
    };
    ($($arg:tt)*) => {{
        $crate::io::_print($crate::format_args_nl!($($arg)*));
    }};
}

so the println! generates some real rust code during compilation. that code ends up calling into formatting traits (Display, Debug, etc.) based on the {} / {:?} you used.

short line: macros are code that writes code.

this idea (code generating code) is often called metaprogramming. in rust the common macro system we all meet first is macro_rules!.

macro expansion: what actually compiles

when you run cargo build, rust does these big phases (rough mental picture):

  1. parse your source into a syntax tree.

  2. expand macros (replace macro calls with the code they generate).

  3. resolve names, types, borrow checking, etc.

  4. produce machine code / binary.

so the code the compiler really works with after expansion may be way bigger than what you wrote.

see it yourself: cargo expand

install:

cargo install cargo-expand

then in your crate:

cargo expand

this prints the fully expanded rust code (macros expanded, derives expanded, etc.). it is noisy but super helpful when learning.

kinds of macros (big picture)

rust has 2 broad macro families:

  • declarative macros (macro_rules!) → pattern matching on token trees. what we cover here.

  • procedural macros → functions that run at compile time and build new code from syntax trees (derive macros, attribute macros, function-like proc macros). more powerful, needs its own blog (coming later).

for now we stay with declarative.


macro_rules! basics

syntax skeleton:

macro_rules! name {
    // one or more match arms
    ( pattern ) => { expansion };
    ( other_pattern ) => { other_expansion };
}

then call it:

name!();      // empty args
name!{};      // curly form
name![];      // square form

fun fact: the delimiter you use at call site ((), {}, []) does not have to match what you used in the definition. the patterns decide what is accepted. in practice most macros are called with !() but vec![] shows that other forms work fine.

let's write the simplest macro ever:

macro_rules! myvec {
    () => {
        println!("My vec");
    };
}

fn main() {
    myvec!();  // works
    myvec!{};  // also works (empty tokens inside {})
    myvec![];  // also works (empty tokens inside [])
}

why does this work? because all 3 calls pass no tokens, which matches our () pattern. the outer delimiter doesn't matter for empties.

token-based, not value-based

macros match syntax (token trees), not runtime values. that means you can feed them raw rust code, as long as it parses the way the pattern expects.

example: this pattern expects one expression:

macro_rules! show_expr {
    ($e:expr) => {
        println!("you gave: {:?}", stringify!($e));
        let val = $e; // we can use the expression
        println!("value = {:?}", val);
    };
}

fn main() {
    show_expr!(1 + 2 * 3);
    show_expr!({ let x = 5; x + 10 });
    show_expr!(vec![1,2,3].len());
}

$e:expr captures any valid expression token tree, even blocks.

macro hygiene & scope: names inside vs outside

one confusing part: what names are visible where?

case 1: name created inside the expansion

macro_rules! make_x {
    () => {
        let x = 2;
        println!("x inside macro = {}", x);
    };
}

fn main() {
    make_x!();
    // println!("{}", x); // error: cannot find value `x` in this scope
}

x lives only in the block produced by the macro. after expansion, your code basically looks like:

fn main() {
    {
        let x = 2;
        println!("x inside macro = {}", x);
    }
    // x is out of scope here
}

case 2: try to use a name from outer scope without capturing it

macro_rules! bump_x_wrong {
    () => {
        x += 1; // error: cannot find `x` (unless in unlucky cases it binds weirdly)
    };
}

fn main() {
    let mut x = 1;
    // bump_x_wrong!(); // won't compile cleanly
}

case 3: capture the name and use it

macro_rules! bump_x {
    ($x:ident) => {
        $x += 1;
    };
}

fn main() {
    let mut x = 1;
    bump_x!(x);
    println!("{}", x); // 2
}

this works because we told the macro: "i will pass you an identifier; please use that name." the $x:ident pattern grabs the actual variable name.

this is called hygiene: macros try not to accidentally capture names from outside unless you explicitly pass them in.

fragment specifiers cheat (the :expr, :ident, etc.)

when you write $name:kind, the kind tells the matcher what to expect. common ones you'll use early:

specifiermatchesexample snippet that matches
identan identifierfoo, SomeType, x
expran expression1 + 2, vec![1], { a + b }
tya typeu32, Vec<T>, (i32, i32)
pata patternSome(x), _, (a, b)
patha pathstd::io::Result, foo::bar
ttone token treesuper low-level catch-all
blocka { ... } block{ let x = 1; x }
metameta items used in attrsderive(Debug)

there are more, but these carry you far. i also googled them to show it here

repetitions: *, +, ?

this is the part you asked about. you see stuff like:

($($element:expr),*) => { ... }

what's with the $() and * at the end? it's a repetition matcher. think regex but for tokens.

format:

$( PATTERN )SEP *

$( PATTERN )SEP +

$( PATTERN )SEP ?
  • * → zero or more

  • + → one or more

  • ? → zero or one (optional)

  • SEP → an optional separator token (often a comma, but could be ; or anything)

comma-separated zero-or-more

($($element:expr),*) => { /* expansion */ }

matches:

  • nothing: myvec![] (0 elements is allowed)

  • 1 (single)

  • 1, 2, 3, 4

semicolon-separated one-or-more

($($element:expr);+) => { /* expansion */ }

matches:

  • 1;2;3

  • not empty (must have at least 1)

optional single expr

($($e:expr)?) => { /* expansion */ }

matches:

  • nothing

  • or exactly one expression

expansion side: repeat too!

inside the expansion side you can repeat the captured bits:

macro_rules! print_all {
    ( $( $x:expr ),* ) => {
        $( println!("{}", $x); )*
    };
}

fn main() {
    print_all!(10, 20, 30);
}

that expands to:

println!("{}", 10);
println!("{}", 20);
println!("{}", 30);

optional trailing comma ($(,)? trick)

we often want both of these to work:

myvec![1, 2, 3];
myvec![1, 2, 3,]; // trailing comma ok (nice for diffs)

pattern for that:

macro_rules! myvec {
    ( $( $element:expr ),* $(,)? ) => { /* expansion */ };
}

that last $(,)? means: optionally match one comma at the end.

i just searched some patterns to write this blog - there can be more - i will also forget the syntax after some days, but yeah, it gives you an idea of what you can do.

building myvec! step by step

let's re-create a tiny subset of vec![].

version 1: print something

macro_rules! myvec {
    () => {
        println!("My vec");
    };
}

version 2: collect expressions into a Vec

we want:

let v = myvec![1, 2, 3];

to expand into roughly:

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

here we go:

macro_rules! myvec {
    ( $( $element:expr ),* ) => {
        {
            let mut v = ::std::vec::Vec::new();
            $( v.push($element); )*
            v
        }
    };
}

fn main() {
    let v = myvec![1, 2, 3, 4];
    println!("{:?}", v);
}

note: i used ::std::vec::Vec with full path so that even if someone shadows Vec locally, my macro still builds the real std vec. this is a common macro pattern.

version 3: allow trailing comma

macro_rules! myvec {
    ( $( $element:expr ),* $(,)? ) => {
        {
            let mut v = ::std::vec::Vec::new();
            $( v.push($element); )*
            v
        }
    };
}

now both myvec![1,2,3] and myvec![1,2,3,] work.

customizing the separator

you can change the separator token. if you wrote:

macro_rules! semi_vec {
    ( $( $element:expr );* ) => {
        {
            let mut v = ::std::vec::Vec::new();
            $( v.push($element); )*
            v
        }
    };
}

fn main() {
    let v = semi_vec![1; 2; 3];
    println!("{:?}", v);
}

users must separate with semicolons. weird maybe, but shows the point.

combining patterns (overloading)

you can give your macro multiple arms to handle different input shapes. example: mimic vec! which supports a repeat form: vec![0; 5] builds 5 zeros.

macro_rules! myvec {
    // repeat form: value ; count
    ($value:expr; $count:expr) => {
        {
            let mut v = ::std::vec::Vec::new();
            let count = $count; // evaluate once
            v.reserve(count as usize);
            let tmp = $value;
            for _ in 0..count {
                v.push(tmp.clone()); // needs Clone if not Copy
            }
            v
        }
    };

    // list form
    ( $( $element:expr ),* $(,)? ) => {
        {
            let mut v = ::std::vec::Vec::new();
            $( v.push($element); )*
            v
        }
    };
}

note: we called .clone() to avoid moving $value multiple times. the real vec! macro handles this more carefully.

gotchas

beware of evaluation count

in the repeat form above, if we wrote:

for _ in 0..$count { v.push($value); }

and $value is an expression with side effects (say rand()), it runs many times. sometimes that's ok, sometimes not. often we bind to a temp:

let tmp = $value;
for _ in 0..count { v.push(tmp.clone()); }

local variable naming collisions

if your macro introduces a variable name like v, and the caller already has let v = ...; in scope, you might shadow or clash. safe trick: wrap in a new block { ... } so your names don't leak. or use let mut __myvec_tmp = ... with unlikely name.

macros expand textually. any names you introduce become real names in that scope.
If you name something v, but the caller also has v, you can shadow or clash depending on how expansion happens.

Example problem:

macro_rules! bad {
    () => {
        let mut v = Vec::new();
        v.push(1);
        v
    };
}

fn main() {
    let v = 10;
    let numbers = bad!(); // Now macro tries to declare `v` again in same scope?
}

Depending on where it's expanded, you may get a redefinition error or weird shadowing. To stay safe:

Wrap in a block so any temp names stay inside:

macro_rules! good {
    () => {{
        let mut v = ::std::vec::Vec::new();
        v.push(1);
        v
    }};
}

Now expansion is an expression (the { ... } block), and the inner v lives only inside the block. The outer let numbers = good!(); grabs the block’s final value.

seeing the expansion (again)

run:

cargo expand > expanded.rs

open expanded.rs and search for myvec! — you'll see the generated code. this is the fastest way to learn macros. read what the compiler actually sees.

more macro fragments in action

here are tiny examples showing different fragment types.

make a function

macro_rules! make_getter {
    ($name:ident, $field:ident, $ty:ty) => {
        fn $name(&self) -> $ty {
            self.$field
        }
    };
}

struct User {
    id: u64,
}

impl User {
    make_getter!(get_id, id, u64);
}

fn main() {
    let u = User { id: 42 };
    println!("{}", u.get_id());
}

repeat over patterns

macro_rules! match_many {
    ( $val:expr, $( $pat:pat ),+ ) => {
        match $val {
            $( $pat => true, )+
            _ => false,
        }
    };
}

fn main() {
    let x = 3;
    if match_many!(x, 1, 2, 3) {
        println!("hit");
    }
}

reading error messages: "missing tokens in macro arguments"

macro_rules! something {} // <-- Error happens here! But why??

fn main() {
   something!();
}

You might expect the compiler to complain about the something!() call, maybe with an error like "no rules matched." But instead, you get this:

error: missing tokens in macro arguments
 --> src/main.rs:1:1
  |
1 | macro_rules! something {}
  | ^^^^^^^^^^^^^^^^^^^^^^^^^ missing tokens in macro arguments

Just like println! or the myvec! macro we're building, macro_rules! expects a specific set of arguments to do its job. Its arguments are the pattern-matching rules that define your new macro (e.g., ( $( $element:expr ),* ) => { ... }).

When you write macro_rules! something {}, you are essentially calling the macro_rules! macro with an empty body, providing none of the pattern-matching rules it needs to create the something macro. The compiler sees this and complains that the arguments for macro_rules! are missing.

It's a great example of how deeply macros are integrated into the Rust language, even the tool for creating macros is a macro itself


full myvec! example (copy/paste)

// myvec.rs
#[macro_export] // optional: lets other crates use it
macro_rules! myvec {
    // repeat form: value ; count
    ($value:expr; $count:expr) => {
        {
            let count = $count as usize;
            let mut v = ::std::vec::Vec::with_capacity(count);
            // store value once to avoid multiple moves
            let tmp = $value; // may move here
            for _ in 0..count {
                v.push(tmp.clone()); // needs Clone if not Copy
            }
            v
        }
    };

    // list form + optional trailing comma
    ( $( $element:expr ),* $(,)? ) => {
        {
            let mut v = ::std::vec::Vec::new();
            $( v.push($element); )*
            v
        }
    };
}

use it:

// main.rs
mod myvec_mod {
    // declare the macro in this module or include! a file
    #[macro_export]
    macro_rules! myvec {
        ($value:expr; $count:expr) => {{
            let count = $count as usize;
            let mut v = ::std::vec::Vec::with_capacity(count);
            let tmp = $value;
            for _ in 0..count { v.push(tmp.clone()); }
            v
        }};
        ( $( $element:expr ),* $(,)? ) => {{
            let mut v = ::std::vec::Vec::new();
            $( v.push($element); )*
            v
        }};
    }
}

use myvec_mod::*; // bring in

fn main() {
    let a = myvec![1,2,3];
    let b = myvec![1,2,3,]; // trailing comma
    let c = myvec![0; 5];   // repeat form (needs Clone if not Copy)
    println!("a = {:?}", a);
    println!("b = {:?}", b);
    println!("c = {:?}", c);
}

note: the real vec! macro in std is smarter (handles Copy vs Clone better, does unsafe speedups, etc.). ours is for learning.


quick glossary

termquick meaning
macrocode that writes code before compile
declarative macromacro_rules! pattern → expansion
procedural macrorust function that builds code from syntax trees (later blog)
token treechunk of source tokens the macro matcher sees
fragment specifiertells matcher what kind of thing to grab (expr, ident, etc.)
repetition* + ? with optional separator token
hygienemacros don't grab outside names unless you pass them

mental model recap

  1. macros run at compile time.

  2. they work on syntax, not values.

  3. you match input tokens with patterns.

  4. you re-use captured bits to generate new code.

  5. the generated code must be valid rust after expansion.

  6. check results with cargo expand when confused.


practice ideas

try these mini exercises:

  • write a logln! macro that prints file + line: logln!("hi")[src/main.rs:12] hi.

  • write a make_fields!(A, B, C) macro that makes struct fields + a new() fn.

  • write a hashmap!{ key => val, ... } macro that returns a HashMap.

All above solutions available at - code

play, break, cargo expand, repeat.

0
Subscribe to my newsletter

Read articles from Garvit Dadheech directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Garvit Dadheech
Garvit Dadheech

I am a Full Stack, DevOps, and Web3 Developer with expertise in building end-to-end applications. As a fast learner, I quickly adapt to different technology stacks, ensuring the delivery of robust software solutions. I specialize in creating scalable web applications tailored to meet client needs and drive innovation.