Introduction
Declarative macros are the most common form of metaprogramming in Rust. They let you write code that generates other code at compile time using pattern matching, similar to how match works on values but operating on syntax trees instead. If you have ever used println!, vec!, or assert_eq!, you have already used declarative macros.
Key Concepts
- macro_rules!: The built-in construct for defining declarative macros. It takes a name and a set of pattern-matching arms.
- Fragment specifier: A typed placeholder (like
$name:expr) that captures a specific kind of Rust syntax during macro invocation. - Macro expansion: The process where the compiler replaces a macro call with the code generated by the matching arm.
- Macro invocation: Calling a macro with
!(e.g.,my_macro!(...)) using parentheses, brackets, or braces.
Real World Context
Declarative macros power some of the most frequently used Rust APIs. The vec! macro eliminates boilerplate when constructing vectors, println! provides type-safe formatted output, and libraries like serde use macros extensively. In Rust 1.94, standard library macros are imported via the prelude rather than through the older #[macro_use] injection mechanism, making macro imports consistent with regular items.
Deep Dive
A declarative macro is defined with macro_rules! followed by a name and a block of match-like arms. Each arm has a pattern on the left and a template on the right.
Here is the simplest possible macro that takes no arguments:
rustmacro_rules! say_hello { () => { println!("Hello!"); }; } say_hello!(); // Expands to: println!("Hello!");
The () on the left matches an empty invocation, and the block on the right is the code that gets substituted in place of the macro call.
To capture parts of the caller's syntax, you use fragment specifiers prefixed with $. Each specifier tells the compiler what kind of syntax to accept:
rustmacro_rules! create_function { ($name:ident) => { fn $name() { println!("Called {}", stringify!($name)); } }; } create_function!(greet); // Creates fn greet() create_function!(compute); // Creates fn compute() greet(); // Output: "Called greet" compute(); // Output: "Called compute"
The $name:ident captures an identifier token. The stringify! macro converts the identifier back to a string literal at compile time.
Macros can have multiple arms, matched top to bottom like a match expression:
rustmacro_rules! log_value { ($val:expr) => { println!("{:?}", $val); }; ($val:expr, $label:literal) => { println!("{}: {:?}", $label, $val); }; } log_value!(42); // Output: 42 log_value!(42, "result"); // Output: result: 42
The compiler tries each arm in order and uses the first one whose pattern matches the invocation tokens.
Common Pitfalls
- Arm ordering matters — The compiler picks the first matching arm, so place more specific patterns before general ones. A catch-all
($($tt:tt)*)arm at the top would shadow all other arms below it. - Missing semicolons in arms — Each arm in
macro_rules!ends with a semicolon after the closing brace. Forgetting it produces a confusing parse error. - Confusing
macro_rules!with functions — Macros operate on tokens, not values. You cannot return early, use?, or apply normal control flow inside a macro definition.
Best Practices
- Start simple — Write the expanded code by hand first, then identify the variable parts and replace them with fragment captures.
- Use
cargo expand— Installcargo-expandto see the actual code your macro generates. This is invaluable for debugging. - Prefer functions when possible — Only reach for macros when you need variable argument counts, code generation, or syntax that functions cannot express.
Summary
macro_rules!defines declarative macros that expand at compile time via pattern matching.- Fragment specifiers (
$name:expr,$name:ident, etc.) capture caller syntax. - Arms are matched top-to-bottom; put specific patterns first.
- In Rust 1.94, standard macros come through the prelude, not
#[macro_use]. - Use
cargo expandto inspect generated code.
Code Examples
// Recreating the vec! macro from scratch
macro_rules! my_vec {
// Arm 1: Empty invocation creates an empty Vec
() => {
Vec::new()
};
// Arm 2: Comma-separated elements, optional trailing comma
($($elem:expr),+ $(,)?) => {
{
let mut v = Vec::new();
$(
v.push($elem);
)+
v
}
};
}
let empty: Vec<i32> = my_vec![];
let numbers = my_vec![1, 2, 3];
let trailing = my_vec![10, 20, 30,]; // Trailing comma accepted
assert!(empty.is_empty());
assert_eq!(numbers, vec![1, 2, 3]);
assert_eq!(trailing, vec![10, 20, 30]);