While declarative macros (macro_rules!) provide a powerful way to write code that writes code, they are limited to matching and replacing patterns. For more advanced metaprogramming, Rust offers procedural macros.
Procedural macros are more like functions. They accept a stream of Rust code as input, operate on it, and produce a new stream of code as output. This allows for much more complex logic, such as generating entire function bodies, implementing traits based on a struct’s fields, or creating powerful domain-specific languages (DSLs).
The Three Types of Procedural Macros
There are three kinds of procedural macros, each serving a different purpose:
- Custom
#[derive]macros: The most common type. They allow you to generate implementations for traits on structs and enums. This is how#[derive(Debug)],#[derive(Serialize)], etc., work. - Attribute-like macros: These are more general attributes that can be attached to any item (like a function or struct). They can modify the item they are attached to. A common use case is in web frameworks, like
#[get("/")]. - Function-like macros: These look like function calls (e.g.,
sql!(...)) but have more power than declarative macros. They can parse complex syntax thatmacro_rules!cannot.
How to Write a Procedural Macro
Writing a procedural macro is more involved than writing a declarative one. Procedural macros must live in their own special crate with the proc-macro crate type enabled in Cargo.toml.
# In Cargo.toml of the proc-macro crate
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"
You will almost always use two essential helper crates:
syn: For parsing Rust code from a token stream into an Abstract Syntax Tree (AST) that you can easily work with.quote: For turning your generated AST back into a stream of Rust code tokens.
Example: A Custom #[derive] Macro
Let’s create a classic “Hello, World” of procedural macros: a HelloMacro trait and a #[derive(HelloMacro)] that implements it.
Step 1: Create the Crates
We need two crates: one for the trait (hello_macro) and one for the procedural macro itself (hello_macro_derive).
cargo new hello_macro --lib
cargo new hello_macro_derive --lib
Step 2: Define the Trait
In hello_macro/src/lib.rs, define the trait we want to implement.
// filepath: hello_macro/src/lib.rs
pub trait HelloMacro {
fn hello_macro();
}
Step 3: Write the Procedural Macro
Now for the main event. In hello_macro_derive/Cargo.toml, set up the crate:
# filepath: hello_macro_derive/Cargo.toml
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"
In hello_macro_derive/src/lib.rs, write the macro function.
// filepath: hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn;
// This function is called by the compiler when it sees #[derive(HelloMacro)].
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// 1. Parse the input tokens into a syntax tree.
let ast = syn::parse(input).unwrap();
// 2. Build the trait implementation.
impl_hello_macro(&ast)
}
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
// Get the name of the struct we're deriving for (e.g., "Pancakes").
let name = &ast.ident;
// 3. Use the `quote!` macro to generate the implementation.
let gen = quote! {
impl HelloMacro for #name {
fn hello_macro() {
// `stringify!` converts an identifier to a string literal at compile time.
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
// 4. Convert the generated code back into a TokenStream.
gen.into()
}
Let’s break this down:
- The
#[proc_macro_derive(HelloMacro)]attribute registers our function as a derive macro for theHelloMacrotrait. - We use
syn::parseto turn the rawTokenStreaminto asyn::DeriveInputstruct, which is a structured representation of the struct or enum. - We use the
quote!macro to build the output code. The#namesyntax interpolates thenamevariable into the generated code. - The
quote!macro returns aTokenStream, which our function then returns.
Step 4: Use the Macro
Finally, let’s use it in a new binary crate.
// In a new crate's main.rs
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro; // The derive macro itself
#[derive(HelloMacro)]
struct Pancakes;
fn main() {
Pancakes::hello_macro(); // Prints: "Hello, Macro! My name is Pancakes!"
}
Attribute-like and Function-like Macros
The other two types of procedural macros work similarly but have different function signatures.
-
Attribute-like:
#[proc_macro_attribute]- Signature:
pub fn my_attribute(attr: TokenStream, item: TokenStream) -> TokenStream attrcontains the tokens inside the attribute (e.g.,GET, "/").itemcontains the tokens of the item the attribute is attached to (e.g., the function).
- Signature:
-
Function-like:
#[proc_macro]- Signature:
pub fn my_macro(input: TokenStream) -> TokenStream inputcontains the tokens inside the macro’s parentheses.
- Signature:
Conclusion
Procedural macros are a significantly more advanced feature than declarative macros, but they provide unparalleled power to reduce boilerplate and build clean, expressive APIs. By leveraging the syn and quote crates, you can parse, analyze, and generate Rust code at compile time, effectively extending the language to suit your specific needs. They are the magic behind many of Rust’s most popular and powerful libraries, like Serde, Tokio, and Diesel.