- Introduction — Macros Are "Code That Writes Code"
- The Two Kinds of Macros at a Glance
- Declarative Macros — macro_rules!
- Procedural Macros — Treating Code as Data
- What Does serde's #[derive(Serialize)] Do?
- Declarative vs Procedural — When to Use Which
- When NOT to Use Macros
- Wrapping Up
- References
Introduction — Macros Are "Code That Writes Code"
Use Rust for a little while and you meet these every day: println! for output, vec! for building a vector, and above all #[derive(Debug)] sitting atop a struct. These are not functions — they are macros. The exclamation mark after the name, or the #[derive(...)] syntax, is the tell.
What makes a macro fundamentally different from a function is that it does not operate on values; it operates on code itself. A macro runs at compile time and expands the little you wrote into much more code. This is called metaprogramming. And Rust has two kinds of macros with distinctly different characters. The goal of this post is to understand the two separately.
If you want to learn the syntax by running it, try the examples in this site's Rust Learning Lab as you read.
The Two Kinds of Macros at a Glance
Rust's macros split broadly into two.
- Declarative macros — Defined with
macro_rules!. They match patterns in the input code and stamp out predetermined code from the matched fragments. Just as a regular expression works with patterns in strings, a declarative macro works with patterns in code fragments. - Procedural macros — They receive the input code as a token stream (TokenStream), analyze it with arbitrary Rust code, and produce a new token stream. They split further into three: the derive macro invoked by
#[derive(...)], the attribute macro like#[route(...)], and the function-like macro called likesql!(...).
In one line: a declarative macro is a tool that "looks at a pattern and stamps predetermined code," while a procedural macro is a tool that "reads code as a program and writes code as a program." Let us start with the easier one.
Declarative Macros — macro_rules!
Mimicking one of the most familiar macros, vec!, gives you a feel for declarative macros. Below is a simplified version that builds a vector from a list of elements.
macro_rules! my_vec {
// accept 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];
println!("{:?}", v); // [1, 2, 3]
}
This short snippet contains the whole heart of declarative macros. The left of the arrow is the pattern to match, and the right is the code to expand into.
Unpacking the symbols in the pattern: the dollar-prefixed macro variable (x in the example above) holds a matched code fragment. The expr after it is a fragment specifier meaning "an expression goes here." Besides expressions there are several kinds: ident (identifier), ty (type), stmt (statement), block (block), tt (token tree), and more. And the repetition notation wrapping the pattern means "this part repeats zero or more times." The comma inside the repetition is the separator, and the star means "zero or more." Use a plus instead of a star and it means "one or more."
The expansion side uses the same repetition notation. That macro variable, which captured several items during matching, is repeated again during expansion, stamping out one push call per line for each. In other words, my_vec![1, 2, 3] expands to push(1); push(2); push(3);.
You can also list several rules. The macro picks the first rule that matches, top to bottom.
macro_rules! greet {
// when there are no arguments
() => {
println!("Hello!");
};
// when it receives one name
($name:expr) => {
println!("Hello, {}!", $name);
};
}
fn main() {
greet!(); // Hello!
greet!("Rust"); // Hello, Rust!
}
Hygiene — Why a Macro Doesn't Pollute Your Variables
Anyone who has used C's text-substitution macros knows the nightmare of a macro quietly clobbering an outer variable. Rust's declarative macros are hygienic. A variable created inside a macro does not collide with a same-named variable outside the macro.
macro_rules! make_temp {
() => {
let temp = 42;
};
}
fn main() {
let temp = 1;
make_temp!(); // the temp inside the macro is a separate, independent variable
println!("{}", temp); // 1 (not polluted)
}
Even though the macro internally uses the name temp, that name belongs to the macro's context and does not mix with the outer temp. Thanks to hygiene, declarative macros can be used safely without worrying about name collisions. This is a decisive difference from plain text substitution.
Procedural Macros — Treating Code as Data
If declarative macros are "pattern matching," procedural macros are full-fledged "code transformation programs." A procedural macro receives its input code as a token stream. A token stream is a flow of tokens produced by chopping source code finely. For example, let x = 1; is roughly the sequence of tokens let, x, =, 1, ;.
The signature of a procedural macro function looks like this: it takes a token stream and returns a token stream.
use proc_macro::TokenStream;
#[proc_macro_derive(MyTrait)]
pub fn my_derive(input: TokenStream) -> TokenStream {
// input: the tokens of the struct/enum the macro is attached to
// return: the tokens of the new code to generate
// ...
}
The problem is that parsing and assembling a token stream by hand is extremely tedious. So the ecosystem effectively depends on two crates.
- syn — Parses a token stream into an easy-to-work-with syntax tree. You can conveniently pull out information like the struct name, field list, and types.
- quote — Does the opposite: you write out the code you want almost verbatim and it turns that into a token stream. Inside the
quote!macro you write the target code, and you interpolate values into the variable slots.
Using these two, the typical flow of a procedural macro becomes three steps.
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(HelloName)]
pub fn hello_name(input: TokenStream) -> TokenStream {
// 1) parse the input with syn
let ast = parse_macro_input!(input as DeriveInput);
let name = ast.ident; // the name of the struct (or enum)
// 2) assemble the code to generate with quote
let expanded = quote! {
impl #name {
fn hello() {
println!("Hi, I am {}!", stringify!(#name));
}
}
};
// 3) hand it back as a token stream
expanded.into()
}
Here, #name inside quote! is the syntax for interpolating the struct name we parsed into that slot. When the caller attaches this macro like so:
#[derive(HelloName)]
struct Robot;
fn main() {
Robot::hello(); // Hi, I am Robot!
}
the compiler automatically generates a hello method for the name Robot — even though we never wrote the impl block by hand. Note that a procedural macro must be defined in a separate crate and marked with proc-macro = true in Cargo.toml.
What Does serde's #[derive(Serialize)] Do?
The most famous and powerful real-world example of this principle is serde. When we serialize data into a format like JSON in Rust, we usually write this.
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct User {
name: String,
age: u32,
active: bool,
}
fn main() {
let user = User {
name: "Ayeong".to_string(),
age: 30,
active: true,
};
// serialize with serde_json
let json = serde_json::to_string(&user).unwrap();
println!("{}", json);
// {"name":"Ayeong","age":30,"active":true}
}
What #[derive(Serialize)] does here is exactly the procedural derive macro we saw in the previous section. At compile time, serde's derive macro receives the definition of the User struct as a token stream, iterates over each field (name, age, active), and generates a whole Serialize trait implementation that knows how to serialize each one.
The key point is that the tedious, error-prone serialization code you would otherwise have to write by hand for every field is instead stamped out by the macro reading the field definitions. Add a field and, on the next compile, the macro regenerates code that handles that field too. There is no code for you to maintain. This is the power procedural macros provide, and the practical value of "read code as data, write code" metaprogramming.
serde also mixes in a taste of attribute macros. Hints attached above a field can adjust the serialization behavior.
use serde::Serialize;
#[derive(Serialize)]
struct Config {
// use a different field name in JSON
#[serde(rename = "maxRetries")]
max_retries: u32,
// omit from the output entirely when there is no value
#[serde(skip_serializing_if = "Option::is_none")]
nickname: Option<String>,
}
These #[serde(...)] attributes are directives the derive macro consults when generating code. As the macro iterates over the fields, it reads these hints and changes the generated code accordingly.
Declarative vs Procedural — When to Use Which
Laying out the character of the two macros in a table makes the choice clear.
| Aspect | Declarative macro | Procedural macro |
|---|---|---|
| How to define | macro_rules! | dedicated crate + proc_macro |
| How it works | pattern match, then substitute code | transform a token stream as a program |
| Expressiveness | within fixed patterns | arbitrary Rust logic |
| Difficulty | low | high (needs syn/quote) |
| Representative example | vec!, println! | #[derive(Serialize)] |
The practical guidance is this. For simple code repetition or convenience syntax, a declarative macro is enough. For repetitive initialization or a short helper syntax, macro_rules! is lightweight and safe. On the other hand, when you actually need to analyze the structure of the input — like reading a struct or enum definition to auto-generate a trait implementation — a procedural macro is the answer. serde, clap's derive, and countless other libraries chose this path.
When NOT to Use Macros
Macros are powerful but not free. Overuse them and your codebase becomes harder to understand, not easier. Pause and think when you see these signs.
- When a function is enough, use a function. The real reasons to need a macro are usually two: you must accept a variable number of arguments (like
println!), or you must generate code itself. If you only need to operate on values, a plain function or generic is almost always better — easier to type-check, debug, and document. - Remember the debugging cost. Because the code a macro generates is invisible, error messages can point at unfamiliar locations or be hard to understand. Tools like
cargo expandlet you inspect the expanded code, but that itself is extra burden. - Be mindful of compile time. Procedural macros in particular pull in heavy dependencies like syn and run at compile time, so they lengthen your build. Check whether you are paying a large compile cost for a small convenience.
- Prioritize readability. If a macro is becoming "magic" that teammates find hard to read, ask whether that magic truly earns its keep. A few lines of explicit code often beat one clever macro.
In short, macros are a tool to reserve for "things you cannot express any other way." Variadic arguments, compile-time code generation, new syntax — they shine when there is a genuine need like this, and become a burden when scattered around for simple convenience.
Wrapping Up
Rust's macros are a metaprogramming tool that generates code at compile time, split into two branches of different character. Declarative macros are a lightweight, hygienic tool that matches patterns with macro_rules! to stamp out code; procedural macros are a powerful tool that receives input as a token stream and analyzes and generates with syn and quote. serde's #[derive(Serialize)] is the flagship achievement of the latter, automatically producing from field definitions the code that would have been tedious to write by hand.
Understand the boundary between the two tools and you can judge when macro_rules! is enough, when a procedural macro is needed, and when the right answer is just to use a function. Macros are most powerful when used sparingly. That restraint keeps your code a tool rather than magic.
References
- The Rust Programming Language: Macros — https://doc.rust-lang.org/book/ch19-06-macros.html
- The Little Book of Rust Macros — https://veykril.github.io/tlborm/
- Rust Reference: Macros — https://doc.rust-lang.org/reference/macros.html
- syn crate — https://docs.rs/syn/
- quote crate — https://docs.rs/quote/
- serde official site — https://serde.rs/
- cargo-expand (tool to inspect macro expansion) — https://github.com/dtolnay/cargo-expand
현재 단락 (1/143)
Use Rust for a little while and you meet these every day: `println!` for output, `vec!` for building...