Procedural Macros
Procedural macros are Rust functions that:
- Run at compile time
- Take Rust code as input
- Return new Rust code as output
Instead of pattern matching like macro_rules!, they operate on Rust’s syntax tree (AST), allowing you to analyze and transform code programmatically.
They are written in Rust and compiled as a special kind of crate.
Types of Procedural Macros
There are three kinds:
| Type | Syntax | Purpose |
|---|---|---|
| Function-like | my_macro!(...) | Like println!, but procedural |
| Derive | #[derive(MyTrait)] | Automatically implement traits |
| Attribute-like | #[my_attr] | Modify items like functions, structs, modules |
Key Differences vs macro_rules!
Declarative (macro_rules!) | Procedural |
|---|---|
| Pattern-based | AST-based |
| Simpler | Much more flexible |
| Written inside normal crates | Must be in a proc-macro crate |
| No external crates needed | Usually use syn, quote, proc-macro2 |
How Procedural Macros Work (Conceptually)
- Rust compiler parses your code.
- When it sees a procedural macro, it:
- Converts the input tokens into a
TokenStream. - Calls your macro function.
- Replaces the macro invocation with the returned tokens.
- Converts the input tokens into a
- Compilation continues with the expanded code.
Setting Up a Procedural Macro Crate
Procedural macros must be in their own crate:
cargo new my_macros --lib
Edit Cargo.toml:
[lib]
proc-macro = true
[dependencies]
syn = "2"
quote = "1"
proc-macro2 = "1"
Example 1: A Custom derive Macro
Let’s create a #[derive(HelloMacro)] that adds a method.
Step 1: Define the trait (in a normal crate)
pub trait HelloMacro {
fn hello();
}
Step 2: Implement the procedural macro (in the proc-macro crate)
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// Parse input into a syntax tree
let ast = parse_macro_input!(input as DeriveInput);
let name = ast.ident;
// Generate code
let expanded = quote! {
impl HelloMacro for #name {
fn hello() {
println!("Hello from {}!", stringify!(#name));
}
}
};
TokenStream::from(expanded)
}
Step 3: Use it in another crate
use my_macros::HelloMacro;
#[derive(HelloMacro)]
struct Foo;
fn main() {
Foo::hello();
}
What gets generated:
impl HelloMacro for Foo {
fn hello() {
println!("Hello from Foo!");
}
}
Example 2: Attribute Macro
Let’s write an attribute macro that logs function entry and exit.
Macro crate:
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};
#[proc_macro_attribute]
pub fn log_fn(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input = parse_macro_input!(item as ItemFn);
let name = &input.sig.ident;
let block = &input.block;
let vis = &input.vis;
let sig = &input.sig;
let expanded = quote! {
#vis #sig {
println!("Entering {}", stringify!(#name));
let result = (|| #block)();
println!("Exiting {}", stringify!(#name));
result
}
};
TokenStream::from(expanded)
}
Usage:
use my_macros::log_fn;
#[log_fn]
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn main() {
let x = add(2, 3);
}
Expanded code:
fn add(a: i32, b: i32) -> i32 {
println!("Entering add");
let result = (|| { a + b })();
println!("Exiting add");
result
}
Example 3: Function-like Procedural Macro
use proc_macro::TokenStream;
use quote::quote;
use syn::parse_macro_input;
#[proc_macro]
pub fn make_answer(_input: TokenStream) -> TokenStream {
quote! {
fn answer() -> i32 {
42
}
}.into()
}
Usage:
make_answer!();
fn main() {
println!("{}", answer());
}
Why Use Procedural Macros
Use them when:
- You need to analyze Rust syntax, not just match patterns.
- You want to generate code based on struct fields, attributes, generics, lifetimes, etc.
- You want to enforce compile-time invariants or build DSLs.
Common real-world uses:
serde’s#[derive(Serialize, Deserialize)]tokio’s#[tokio::main]thiserror’s#[derive(Error)]clap’s#[derive(Parser)]
Safety and Best Practices
- Procedural macros are powerful but complex — test them thoroughly.
- Always provide good compile-time error messages using
syn::Error. - Keep macro logic small and predictable.
- Prefer
macro_rules!if it can solve the problem — it’s simpler and faster to maintain.