When you first hear the word "macro," it might conjure images of complex, mysterious code constructs that only seasoned developers can wield. I certainly felt that way when I began exploring Rust. The truth, however, is that macros are one of the most powerful tools you can learn in Rust—and they're not as intimidating as they might seem!
In Rust, macros are a gateway to metaprogramming. They allow you to write code that writes other code, making your programs more flexible, reusable, and elegant. Whether you’ve used println!
, vec!
, or even tried your hand at procedural macros like #[derive(Debug)]
, you’ve already encountered macros in some form. But how do they work? And how can you leverage them in your own projects?
In this guide, we’ll peel back the curtain on procedural macros and dive into:
- What makes Rust macros unique
- How to write your own procedural macros
- Practical examples you can use today
- Common pitfalls to avoid
By the end of this post, you’ll not only understand the fundamentals of procedural macros but also be able to create your own custom macros to solve real-world problems. So, if you’re ready to unlock the magic of metaprogramming, let’s get started!
What Are Procedural Macros?
In Rust, macros come in two flavors: declarative macros and procedural macros. While declarative macros (macro_rules!
) let you define patterns that expand to code, procedural macros allow for more fine-grained control and customization.
Procedural macros work at the level of tokens (raw syntax) and can transform or generate code. They come in three primary types:
-
Function-like macros - invoked with
my_macro!(...)
, acting like regular functions that operate on the input and generate code. -
Derive macros - invoked with
#[derive(MyTrait)]
, generating trait implementations based on struct or enum definitions. -
Attribute macros - invoked with
#[my_attr]
, modifying or generating code around functions, structs, or modules.
Unlike declarative macros, procedural macros are defined in separate crates marked as proc-macro
. This makes them versatile and powerful, since you can manipulate the abstract syntax tree (AST) of the program to perform complex code generation.
Getting Started: The proc-macro
Crate
Before we dive into writing procedural macros, you need to set up a procedural macro crate. In Cargo, procedural macros are created in separate crates marked with proc-macro = true
in Cargo.toml
.
Here's how to create one:
cargo new my_procedural_macros --lib
Inside the Cargo.toml
, add:
[lib]
proc-macro = true
Now, let's explore each type of procedural macro and see how to create them!
1. Function-Like Procedural Macros
Function-like procedural macros are invoked with my_macro!()
syntax, just like the built-in println!
or vec!
. They take a TokenStream
as input and return a TokenStream
as output.
Example: make_answer!
Macro
This macro will generate a function answer()
that returns 42
:
extern crate proc_macro;
use proc_macro::TokenStream;
#[proc_macro]
pub fn make_answer(_input: TokenStream) -> TokenStream {
// Generate a function that returns 42
"fn answer() -> u32 { 42 }".parse().unwrap()
}
Usage:
extern crate my_procedural_macros;
use my_procedural_macros::make_answer;
make_answer!();
fn main() {
println!("{}", answer()); // Prints 42
}
Here, make_answer!
is invoked like a function and generates a function that returns 42
.
2. Derive Procedural Macros
Derive macros allow you to automatically implement traits for structs or enums by annotating them with #[derive(Trait)]
. Rust's standard library provides built-in derive macros like #[derive(Debug)]
, but you can create your own custom derive macros.
Example: #[derive(AnswerFn)]
This derive macro will append a function answer()
to any struct it's applied to:
extern crate proc_macro;
use proc_macro::TokenStream;
#[proc_macro_derive(AnswerFn)]
pub fn derive_answer_fn(_input: TokenStream) -> TokenStream {
// Append a function named answer to the struct
"fn answer() -> u32 { 42 }".parse().unwrap()
}
Usage:
extern crate my_procedural_macros;
use my_procedural_macros::AnswerFn;
#[derive(AnswerFn)]
struct MyStruct;
fn main() {
println!("{}", answer()); // Prints 42
}
In this example, the derive macro #[derive(AnswerFn)]
attaches a function answer()
to the struct MyStruct
. Whenever this struct is used, the answer()
function becomes available.
3. Attribute Procedural Macros
Attribute macros modify the behavior of items (e.g., functions or structs) with an annotation #[my_macro]
. You can think of them as more general-purpose macros that act on any item.
Example: #[log_execution]
Let’s create an attribute macro #[log_execution]
that logs when a function is called:
extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};
#[proc_macro_attribute]
pub fn log_execution(_attr: TokenStream, item: TokenStream) -> TokenStream {
// Parse the input TokenStream into a syntax tree
let input = parse_macro_input!(item as ItemFn);
let fn_name = &input.sig.ident;
// Generate the modified function with logging
let expanded = quote! {
fn #fn_name() {
println!("Executing function: {}", stringify!(#fn_name));
#input
}
};
TokenStream::from(expanded)
}
Usage:
extern crate my_procedural_macros;
use my_procedural_macros::log_execution;
#[log_execution]
fn greet() {
println!("Hello, world!");
}
fn main() {
greet();
// Output:
// Executing function: greet
// Hello, world!
}
In this example, the #[log_execution]
macro wraps the greet()
function and logs a message before executing it. You can apply this macro to any function in your code.
Common Pitfalls to Avoid
- Complexity Creep: Start simple! Macros can get complicated quickly, especially when manipulating large token streams. Keep your initial macros focused on solving small problems.
-
Error Handling: Remember, your procedural macros run at compile time, and errors in macro code can be hard to debug. Use
compile_error!
to emit meaningful messages instead of panicking. -
Unhygienic Macros: Unlike declarative macros, procedural macros are unhygienic, meaning they don’t automatically prevent name clashes. Always use full paths (e.g.,
::std::string::String
) to avoid conflicts.
Conclusion
Procedural macros are a powerful feature that allows you to extend the capabilities of the Rust compiler and automate code generation. In this guide, we’ve explored:
- Function-like procedural macros: Creating macros that generate code like functions.
- Derive macros: Automatically implementing traits for structs or enums.
- Attribute macros: Modifying the behavior of items like functions and structs.
Armed with this knowledge, you can now build custom macros to eliminate boilerplate code, enhance code readability, and write more expressive Rust code. While we've covered a lot, procedural macros are just one part of Rust's rich macro system. Declarative macros (created using macro_rules!
) are another key feature that allows for even more flexibility and abstraction. These allow you to define pattern-matching macros for various syntactical use cases, and we’ll cover them in an upcoming post.
Rust macros are vast and incredibly versatile—there’s so much more that can’t be fit into a single blog post! By mastering both procedural and declarative macros, you’ll have powerful tools at your disposal to write more elegant, maintainable, and expressive code.
Stay tuned for the next post on declarative macros and other advanced macro features. Until then, happy coding!
Top comments (1)
Oooooh I didn't actually realize "Function-Like Procedural Macros" were different than declarative macros. Great article!
Aside: I have a draft post about Rust macros and have been facing a lot of difficulty with the markdown editor here. It keeps rendering my declarative macro invocations within HTML tags, have you encountered that at all?