Macros
You've been using macros since your very first Rust program — `println!` is one, and so are `vec!`, `assert_eq!`, and `#[derive(...)]`. That telltale exclamation mark, and those `#[...]` attributes, mark code that isn't a normal function call but metaprogramming: code that writes code at compile time. Macros are how Rust does the powerful, boilerplate-eliminating tricks that would need runtime reflection or code generators in other languages — all checked at compile time. This closing post of the series demystifies them.
This final post of Rust from the Ground Up covers macros — Rust’s metaprogramming feature (code that writes code at compile time). It covers what macros are, the difference from functions, declarative macros (macro_rules!), a conceptual look at procedural macros (like #[derive(...)]), and when to use macros. Macros are an advanced topic; this post demystifies the macros you’ve used throughout the series and gives a foundation for understanding (and occasionally writing) them. It’s a fitting capstone.
What macros are
Macros are Rust’s metaprogramming feature — code that writes code, expanded at compile time. You’ve used them all along:
- Code that writes code. A macro generates Rust code — you invoke it, and at compile time it expands into the actual code it represents (which is then compiled normally). Macros are metaprogramming: writing code that produces code, rather than code that runs directly. The macro’s expansion happens during compilation, before the final code is compiled. Macros generate code at compile time.
- You’ve been using them. Many things you’ve used are macros, marked by
!or#[...]:println!,vec!,assert_eq!,format!(the!marks a macro invocation), and#[derive(Debug)],#[test],#[derive(Error)](attribute-style macros). These aren’t ordinary functions — they’re macros that generate code at compile time. The!and#[...]you’ve seen throughout the series are macros. They’ve been there sinceprintln!. - Why Rust has macros. Macros let you write code that would be repetitive, impossible, or awkward as normal functions — eliminating boilerplate, creating flexible syntax, and generating code (like deriving trait implementations). They provide power that functions can’t (variable arguments, generating implementations, custom syntax) — while remaining compile-time and type-checked (unlike some languages’ runtime metaprogramming). Macros give compile-time metaprogramming power. Powerful, and still compile-time safe.
Macros are Rust’s compile-time metaprogramming — code that writes code, expanded during compilation — and you’ve used them throughout (println!, vec!, assert_eq!, #[derive(...)], marked by ! or #[...]). They provide power beyond functions (eliminating boilerplate, generating code, flexible syntax) while staying compile-time and type-checked. Understanding how they differ from functions clarifies why they exist.
Macros vs functions
Macros differ from functions in important ways — understanding the difference explains what macros can do that functions can’t:
- Macros operate on code; functions operate on values. A function takes values (at runtime) and returns a value. A macro takes code (tokens) and generates code (at compile time). This is the core difference: macros manipulate the code itself (before it runs), while functions manipulate values (when it runs). Macros work at a different level — the code, not the values. Code vs values, compile-time vs runtime.
- Macros can take variable arguments. Because macros generate code, they can take a variable number of arguments —
println!("{} {} {}", a, b, c)takes any number of arguments, which a normal function can’t (Rust functions have fixed arity). This variadic capability is one thing macros enable that functions can’t.vec is a macro for the same reason. Variable arguments need macros. - Macros can generate code you couldn’t write by hand practically. Macros can generate implementations — e.g.
#[derive(Debug)]generates a wholeDebugtrait implementation for your type, saving you writing it. This code-generation (implementing traits, creating boilerplate) is beyond what functions do — functions can’t write other code. Macros generate code; functions just run. Code generation is macro territory. - The cost: macros are more complex. Macros are more complex to write and understand than functions (they involve code-manipulation, their own syntax, and are harder to read/debug). So macros are powerful but should be used judiciously — prefer functions when they suffice, use macros for what needs metaprogramming. Power at the cost of complexity. Reach for macros when functions can’t do the job.
Macros differ from functions fundamentally: they operate on code (tokens, at compile time) rather than values (at runtime), which lets them do what functions can’t — take variable arguments, generate code (like trait implementations), and create flexible syntax — at the cost of greater complexity. This is why macros exist. Rust has two kinds — declarative and procedural.
Declarative macros: macro_rules!
Declarative macros (defined with macro_rules!) are the simpler, more common kind — they work by pattern matching on code and generating code from templates:
// A simple declarative macro that creates a Vec and pushes given elements.
macro_rules! my_vec {
// Match: any number of comma-separated expressions.
( $( $x:expr ),* ) => {
{
let mut temp = Vec::new();
$(
temp.push($x);
)*
temp
}
};
}
fn main() {
let v = my_vec![1, 2, 3]; // expands to code creating a Vec with 1, 2, 3
println!("{v:?}"); // [1, 2, 3]
}
- Pattern-matching on code. A
macro_rules!macro defines patterns (matching the code passed to it) and templates (the code to generate for each pattern). It’s likematch(Module 1) but on code structure — match the input code, generate the corresponding output code. Here,$( $x:expr ),*matches any number of comma-separated expressions, and the template generates code to push each into aVec. Match code, emit code. (This is essentially a simplifiedvec!.) - The
$(...)*repetition. The$( ... )*syntax handles repetition — matching and generating code for each repeated element (each expression$x). This is how a declarative macro handles variable numbers of arguments — repeating the template per argument. Repetition is how macros do variadic generation.$(...),*for the pattern,$(...)*for the expansion. - Declarative macros are the common, approachable kind.
macro_rules!macros are the simpler, more common way to write macros — many library macros are declarative. They’re the approachable entry to writing macros (pattern → template), and cover many metaprogramming needs. Most macros you’d write are declarative. Start withmacro_rules!.
Declarative macros (macro_rules!) work by pattern-matching on code and generating code from templates (like match on code structure), using $(...)* for repetition to handle variable arguments — the simpler, more common, approachable kind of macro (essentially how vec! works). The more powerful kind is procedural macros.
Procedural macros (and when to use macros)
Procedural macros are the more powerful, more complex kind — they’re Rust functions that operate on code as data, and they power features like #[derive(...)]:
- Procedural macros operate on code programmatically. A procedural macro is a special function that takes code (as a token stream) as input and produces code (a token stream) as output — manipulating code programmatically (with arbitrary Rust logic), rather than pattern-templates. This is more powerful (full programmatic code generation) but more complex (they’re written in separate crates with more machinery). Procedural macros are code-generating functions. More power, more complexity.
- They power
#[derive]and attributes. The#[derive(Debug)],#[derive(Error)](fromthiserror, previous post), and custom attribute macros you’ve used are procedural macros — they generate code (like trait implementations) based on the item they’re applied to. When you#[derive(Debug)], a procedural macro generates theDebugimpl for your type. Derive macros are procedural macros generating implementations. That’s how#[derive(...)]works — and howthiserrorderived error impls. Procedural macros are behind the derives you use. - When to use macros: judiciously. Macros are powerful but complex, so use them judiciously: reach for macros when you need metaprogramming — eliminating significant boilerplate, variadic interfaces, generating implementations, or custom syntax that functions/generics can’t provide. Prefer functions and generics (Module 2) when they suffice (they’re simpler and clearer). Use macros for what genuinely needs code generation. Don’t over-use macros — they add complexity. Functions first, macros when needed. The
!should be earned. - Mostly you use macros, occasionally write them. In practice, you use macros constantly (
println!,vec!,#[derive], etc.) and occasionally write simple declarative ones (to eliminate real boilerplate); procedural macros you write rarely (mostly you use library-provided ones likethiserror’s). Understanding macros mainly helps you use them well and read code that uses them — and write a simple one when it genuinely helps. Mostly consume, occasionally create. Understanding demystifies the tools you use daily.
Procedural macros are the more powerful, complex kind — functions operating programmatically on code (token streams), powering #[derive(...)] and attribute macros (generating trait implementations, like thiserror’s) — while declarative macros (macro_rules!) handle simpler pattern-template cases. Use macros judiciously (prefer functions/generics when they suffice; reach for macros for genuine metaprogramming). This completes Module 3 and the Rust from the Ground Up series — from ownership to fearless concurrency, async, testing, and metaprogramming: the foundations of writing real, correct, idiomatic Rust.
Key takeaways
- Macros are Rust’s compile-time metaprogramming — code that writes code, expanded during compilation — and you’ve used them throughout the series (
println!,vec!,assert_eq!,#[derive(Debug)],#[test]), marked by!(invocation) or#[...](attribute); they provide power beyond functions while staying compile-time and type-checked. - Macros differ from functions fundamentally: they operate on code (tokens, at compile time) rather than values (at runtime), which lets them do what functions can’t — take variable arguments (
println!/vec!with any number of args), generate code (like trait implementations), and create flexible syntax — at the cost of greater complexity. - Declarative macros (
macro_rules!) are the simpler, common kind — they pattern-match on code and generate code from templates (likematchon code structure), using$(...)*repetition to handle variable arguments (essentially howvec!works) — the approachable way to write your own macros. - Procedural macros are the more powerful, more complex kind — functions that operate programmatically on code (token streams) — and they power
#[derive(...)]and attribute macros (generating implementations likeDebugorthiserror’s error impls); you use these constantly but rarely write them. - Use macros judiciously — prefer functions and generics (Module 2) when they suffice (simpler, clearer), and reach for macros only for genuine metaprogramming (eliminating significant boilerplate, variadic interfaces, generating implementations, custom syntax) — in practice you mostly use macros and occasionally write simple declarative ones, so understanding them mainly helps you use and read them well.
Further reading
- The Rust Book — Macros
- Rust: Traits (Module 2) — what derive macros generate
- Error handling with anyhow and thiserror (previous post)