Macros are one of Rust’s most powerful features, enabling metaprogramming—code that writes code. Unlike macros in C and C++, which are simple text substitution mechanisms, Rust’s macros are hygienic and operate on the abstract syntax tree (AST), making them both powerful and safe. They allow you to extend the language, reduce boilerplate, create domain-specific languages, and implement compile-time code generation without sacrificing Rust’s safety guarantees.
In this comprehensive guide, we’ll explore Rust’s macro system in depth, from basic declarative macros to advanced procedural macros. You’ll learn how macros work, when to use them, and how to write your own macros to solve real-world problems. By the end, you’ll have a solid understanding of how to leverage Rust’s macro system to write more expressive, maintainable, and DRY (Don’t Repeat Yourself) code.
Understanding Macros: The Basics
At their core, macros are a way to write code that writes other code. Rust has two main types of macros:
- Declarative macros (also called “macro_rules!” macros)
- Procedural macros, which come in three flavors:
- Function-like macros
- Derive macros
- Attribute macros
Let’s start with declarative macros, which are the most common and easiest to understand.
Declarative Macros with macro_rules!
Declarative macros use pattern matching to transform code. They’re defined using the macro_rules!
syntax:
// A simple macro that prints a debug message
macro_rules! debug {
($msg:expr) => {
println!("DEBUG: {}", $msg);
};
}
fn main() {
debug!("Hello, macro world!");
}
This macro takes an expression ($msg:expr
) and transforms it into a println!
statement with a “DEBUG: " prefix.
Macro Syntax and Patterns
Declarative macros use a pattern-matching syntax:
macro_rules! example {
// Pattern => Replacement
($pattern:type_specifier) => {
// Code that replaces the pattern
};
}
Common type specifiers include:
expr
: An expression (e.g.,1 + 2
,foo()
,bar.baz
)ident
: An identifier (e.g., variable names, function names)ty
: A type (e.g.,i32
,String
,Vec<T>
)path
: A path (e.g.,std::collections::HashMap
)stmt
: A statement (e.g.,let x = 1;
)block
: A block of code (e.g.,{ println!("hello"); }
)item
: An item (e.g., functions, structs, modules)meta
: Meta information (e.g., attributes)tt
: A token tree (a catch-all for any token or group of tokens)
Multiple Patterns and Repetition
Macros can match multiple patterns and use repetition:
macro_rules! vector {
// Empty vector
() => {
Vec::new()
};
// Vector with elements
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
fn main() {
let v1 = vector![]; // Creates an empty vector
let v2 = vector![1, 2, 3, 4]; // Creates a vector with elements
println!("{:?}", v1);
println!("{:?}", v2);
}
The repetition syntax $( ... ),*
matches zero or more comma-separated expressions and generates code that pushes each expression into the vector.
Repetition Operators
Rust macros support several repetition operators:
*
: Zero or more repetitions+
: One or more repetitions?
: Zero or one repetition
These can be combined with separators:
macro_rules! hashmap {
( $( $key:expr => $value:expr ),* ) => {
{
let mut map = std::collections::HashMap::new();
$(
map.insert($key, $value);
)*
map
}
};
}
fn main() {
let map = hashmap! {
"one" => 1,
"two" => 2,
"three" => 3
};
println!("{:?}", map);
}
Advanced Declarative Macro Techniques
Let’s explore some more advanced techniques for declarative macros:
Recursive Macros
Macros can call themselves recursively:
macro_rules! factorial {
// Base case
(0) => {
1
};
// Recursive case
($n:expr) => {
$n * factorial!($n - 1)
};
}
fn main() {
println!("5! = {}", factorial!(5)); // Prints "5! = 120"
}
This macro computes the factorial of a number at compile time.
Hygiene in Macros
Rust macros are hygienic, meaning they don’t accidentally capture or shadow variables:
macro_rules! using_a {
($e:expr) => {
{
let a = 42;
$e // This won't use the 'a' defined inside the macro
}
};
}
fn main() {
let a = 10;
let result = using_a!(a * 2); // Uses the 'a' from the outer scope
println!("Result: {}", result); // Prints "Result: 20", not "Result: 84"
}
Debugging Macros
The trace_macros!
and log_syntax!
features can help debug macros:
#![feature(trace_macros)]
macro_rules! double {
($x:expr) => {
$x * 2
};
}
fn main() {
trace_macros!(true);
let y = double!(4);
trace_macros!(false);
println!("y = {}", y);
}
When compiled with the nightly compiler, this will print the macro expansion.
Procedural Macros: The Next Level
Procedural macros are more powerful than declarative macros because they operate on the tokenized source code directly. They’re defined in separate crates with the proc-macro
crate type.
Function-like Procedural Macros
Function-like procedural macros look like function calls:
use proc_macro::TokenStream;
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
// Parse the input tokens into a SQL query
// Generate Rust code that executes the query
// Return the generated code as a TokenStream
"println!(\"Executing SQL query\")".parse().unwrap()
}
This is a simplified example. In practice, you would use crates like syn
and quote
to parse and generate code:
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, LitStr};
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
// Parse the input as a string literal
let sql_query = parse_macro_input!(input as LitStr).value();
// Generate code that executes the query
let output = quote! {
{
println!("Executing SQL query: {}", #sql_query);
// Code to actually execute the query would go here
"Result of the query"
}
};
output.into()
}
Derive Macros
Derive macros automatically implement traits for structs and enums:
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(HelloWorld)]
pub fn hello_world_derive(input: TokenStream) -> TokenStream {
// Parse the input tokens into a syntax tree
let input = parse_macro_input!(input as DeriveInput);
let name = input.ident;
// Generate the implementation
let expanded = quote! {
impl HelloWorld for #name {
fn hello_world() {
println!("Hello, World! My name is {}", stringify!(#name));
}
}
};
// Convert back to tokens and return
expanded.into()
}
With this macro, you can automatically implement the HelloWorld
trait:
#[derive(HelloWorld)]
struct MyStruct;
fn main() {
MyStruct::hello_world();
}
Attribute Macros
Attribute macros define custom attributes that can be applied to items:
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
// Parse the attribute and the function
let path = attr.to_string();
let input = parse_macro_input!(item as ItemFn);
let name = input.sig.ident.clone();
// Generate the modified function
let expanded = quote! {
#input
#[test]
fn #name() {
println!("Route {} maps to function {}", #path, stringify!(#name));
}
};
expanded.into()
}
This macro can be used to annotate functions with routing information:
#[route("/hello")]
fn hello() -> &'static str {
"Hello, world!"
}
Real-World Macro Examples
Let’s look at some practical examples of macros in real-world Rust code:
Example 1: A Simple Logging Macro
macro_rules! log {
// log!(Level, "message")
($level:expr, $message:expr) => {
println!("[{}] {}", $level, $message);
};
// log!(Level, "message with {}", "formatting")
($level:expr, $format:expr, $($arg:tt)*) => {
println!("[{}] {}", $level, format!($format, $($arg)*));
};
}
fn main() {
log!("INFO", "Application started");
log!("ERROR", "Failed to connect to {}", "database");
}
This macro provides a simple logging facility with different log levels and formatting options.
Example 2: A Builder Pattern Macro
macro_rules! builder {
($name:ident { $($field:ident: $type:ty,)* }) => {
// Define the struct
pub struct $name {
$(
$field: $type,
)*
}
// Define the builder
pub struct Builder {
$(
$field: Option<$type>,
)*
}
impl Builder {
pub fn new() -> Self {
Builder {
$(
$field: None,
)*
}
}
$(
pub fn $field(mut self, value: $type) -> Self {
self.$field = Some(value);
self
}
)*
pub fn build(self) -> Result<$name, &'static str> {
Ok($name {
$(
$field: self.$field.ok_or(concat!("Missing field: ", stringify!($field)))?,
)*
})
}
}
impl $name {
pub fn builder() -> Builder {
Builder::new()
}
}
};
}
// Use the macro to define a Person struct with a builder
builder! {
Person {
name: String,
age: u32,
email: String,
}
}
fn main() {
let person = Person::builder()
.name("Alice".to_string())
.age(30)
.email("[email protected]".to_string())
.build()
.unwrap();
println!("Created person: {} ({}, {})", person.name, person.age, person.email);
}
This macro automatically generates a builder pattern implementation for a struct.
Example 3: A Simple State Machine
macro_rules! state_machine {
(
$vis:vis struct $name:ident {
$(
$state:ident => {
$(
$event:ident => $next_state:ident,
)*
}
)*
}
) => {
$vis enum $name {
$(
$state,
)*
}
impl $name {
$vis fn transition(self, event: Event) -> Result<Self, &'static str> {
match (self, event) {
$(
$(
($name::$state, Event::$event) => Ok($name::$next_state),
)*
)*
_ => Err("Invalid transition"),
}
}
}
$vis enum Event {
$(
$(
$event,
)*
)*
}
};
}
// Define a simple state machine for a traffic light
state_machine! {
pub struct TrafficLight {
Red => {
Next => Green,
}
Green => {
Next => Yellow,
}
Yellow => {
Next => Red,
}
}
}
fn main() {
let mut light = TrafficLight::Red;
// Cycle through the states
for _ in 0..6 {
println!("Current state: {:?}", light);
light = light.transition(Event::Next).unwrap();
}
}
This macro generates a state machine with states, events, and transition rules.
Best Practices for Using Macros
Macros are powerful but should be used judiciously. Here are some best practices:
1. Use Macros as a Last Resort
Before reaching for a macro, consider if you can solve the problem with regular functions, traits, or generics:
// Instead of a macro for simple operations
macro_rules! add {
($a:expr, $b:expr) => {
$a + $b
};
}
// Prefer a regular function
fn add(a: i32, b: i32) -> i32 {
a + b
}
Macros are best used when you need to:
- Generate repetitive code
- Create domain-specific languages
- Implement compile-time features
- Reduce boilerplate that can’t be eliminated with other abstractions
2. Document Your Macros Thoroughly
Macros can be harder to understand than regular code, so good documentation is essential:
/// Creates a vector containing the given elements.
///
/// # Examples
///
/// ```
/// let v = vector![1, 2, 3];
/// assert_eq!(v, vec![1, 2, 3]);
/// ```
macro_rules! vector {
() => {
Vec::new()
};
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
3. Follow Naming Conventions
- Use
snake_case!
for macro names, just like functions - Use
PascalCase
for derive macros - Use
snake_case
for attribute macros
4. Make Macros Robust
Ensure your macros handle edge cases and provide good error messages:
macro_rules! divide {
($a:expr, $b:expr) => {
{
if $b == 0 {
Err("Division by zero")
} else {
Ok($a / $b)
}
}
};
}
fn main() {
match divide!(10, 2) {
Ok(result) => println!("Result: {}", result),
Err(err) => println!("Error: {}", err),
}
match divide!(10, 0) {
Ok(result) => println!("Result: {}", result),
Err(err) => println!("Error: {}", err),
}
}
5. Test Your Macros
Write tests for your macros to ensure they work as expected:
#[cfg(test)]
mod tests {
#[test]
fn test_vector_macro() {
let empty = vector![];
assert_eq!(empty.len(), 0);
let numbers = vector![1, 2, 3, 4];
assert_eq!(numbers, vec![1, 2, 3, 4]);
}
}
Common Macro Crates in the Rust Ecosystem
Several crates provide useful macros or tools for working with macros:
syn and quote
These crates are essential for writing procedural macros:
syn
parses Rust code into a syntax treequote
converts a syntax tree back into Rust code
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(Debug)]
pub fn derive_debug(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = input.ident;
let expanded = quote! {
impl std::fmt::Debug for #name {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}(..)", stringify!(#name))
}
}
};
expanded.into()
}
paste
The paste
crate allows you to concatenate identifiers in macros:
use paste::paste;
macro_rules! create_functions {
($($name:ident),*) => {
$(
paste! {
fn [<get_ $name>]() -> &'static str {
stringify!($name)
}
}
)*
};
}
create_functions!(foo, bar, baz);
fn main() {
println!("{}", get_foo()); // Prints "foo"
println!("{}", get_bar()); // Prints "bar"
println!("{}", get_baz()); // Prints "baz"
}
lazy_static
The lazy_static
macro allows for lazily evaluated static variables:
use lazy_static::lazy_static;
use std::collections::HashMap;
lazy_static! {
static ref HASHMAP: HashMap<u32, &'static str> = {
let mut m = HashMap::new();
m.insert(0, "zero");
m.insert(1, "one");
m.insert(2, "two");
m
};
}
fn main() {
println!("The entry for `0` is \"{}\".", HASHMAP.get(&0).unwrap());
}
Advanced Topics in Macro Development
Debugging Procedural Macros
Debugging procedural macros can be challenging. Here are some techniques:
Print the input and output: Use
println!
to print the input and output token streams during development.Use the
proc_macro_diagnostic
feature: This allows you to emit compiler diagnostics from your macro.
#![feature(proc_macro_diagnostic)]
use proc_macro::{Diagnostic, Level, TokenStream};
#[proc_macro]
pub fn debug_tokens(input: TokenStream) -> TokenStream {
// Emit a warning with the input tokens
Diagnostic::new(Level::Warning, format!("Input tokens: {}", input))
.emit();
// Return empty token stream
TokenStream::new()
}
- Write the generated code to a file: This can help you inspect the output.
use std::fs::File;
use std::io::Write;
#[proc_macro]
pub fn write_to_file(input: TokenStream) -> TokenStream {
let output = generate_code(input);
let mut file = File::create("generated_code.rs").unwrap();
file.write_all(output.to_string().as_bytes()).unwrap();
output
}
Span Hygiene
Procedural macros can manipulate spans to improve error messages:
use proc_macro::TokenStream;
use quote::{quote, quote_spanned};
use syn::{parse_macro_input, DeriveInput, spanned::Spanned};
#[proc_macro_derive(MyTrait)]
pub fn derive_my_trait(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = input.ident;
// Use the span of the struct name for better error messages
let expanded = quote_spanned! {name.span()=>
impl MyTrait for #name {
fn my_method(&self) {
println!("Called my_method on {}", stringify!(#name));
}
}
};
expanded.into()
}
Custom Derive Helper Attributes
You can create helper attributes for your derive macros:
#[proc_macro_derive(MyTrait, attributes(my_attr))]
pub fn derive_my_trait(input: TokenStream) -> TokenStream {
// Parse the input
let input = parse_macro_input!(input as DeriveInput);
// Process attributes
for attr in &input.attrs {
if attr.path.is_ident("my_attr") {
// Process the my_attr attribute
}
}
// Generate implementation
// ...
}
Conclusion
Rust’s macro system is a powerful tool for metaprogramming, allowing you to extend the language, reduce boilerplate, and create domain-specific abstractions. While macros should be used judiciously, they can significantly improve the expressiveness and maintainability of your code when applied appropriately.
The key takeaways from this exploration of Rust’s macro system are:
- Declarative macros provide pattern-matching-based code generation
- Procedural macros offer more powerful code manipulation through direct access to the token stream
- Macros should be used when other abstractions are insufficient
- Good documentation and testing are essential for maintainable macros
- The Rust ecosystem provides many useful macro crates
By mastering Rust’s macro system, you gain the ability to write more expressive, concise, and maintainable code, while still benefiting from Rust’s strong safety guarantees. Whether you’re building a complex library, reducing boilerplate in your application, or creating a domain-specific language, macros are a valuable tool in your Rust programming toolkit.