Step 6 of 6
Procedural macros and derive
macro_rules! matches patterns. Procedural macros are different: they're ordinary Rust functions that run inside the compiler, receive your code as a stream of tokens, and return new tokens. There are three kinds:
| Kind | Looks like | Examples |
|---|---|---|
| derive | #[derive(Serialize)] on a struct or enum |
serde's Serialize/Deserialize, thiserror::Error, clap::Parser |
| attribute | #[name] on an item, can rewrite it |
#[tokio::main], #[tracing::instrument], #[async_trait] |
| function-like | name!(...), like macro_rules! but with full Rust logic |
sqlx::query! (checks SQL at compile time), html! |
What a derive writes for you
A derive reads the struct's definition and adds an impl next to it. For #[derive(Debug)] on a struct, the generated code is close to this hand-written impl:
use std::fmt;
struct Point {
x: i32,
y: i32,
}
// Roughly what #[derive(Debug)] generates:
impl fmt::Debug for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.debug_struct("Point")
.field("x", &self.x)
.field("y", &self.y)
.finish()
}
}
fn main() {
let p = Point { x: 3, y: -1 };
println!("{:?}", p);
println!("{:#?}", p);
}
Point { x: 3, y: -1 }
Point {
x: 3,
y: -1,
}
debug_struct is a helper in std::fmt that handles both {:?} and the pretty {:#?} form. Tuple structs use debug_tuple, and unit variants just write their name.
#[derive(Serialize)] (from serde, covered in the serde module) works the same way. It reads the field names and any #[serde(...)] attributes, and generates an impl that describes the struct to a serializer:
use serde::ser::{Serialize, SerializeStruct, Serializer};
struct Point {
x: i32,
y: i32,
}
// Roughly what #[derive(Serialize)] generates:
impl Serialize for Point {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut state = serializer.serialize_struct("Point", 2)?;
state.serialize_field("x", &self.x)?;
state.serialize_field("y", &self.y)?;
state.end()
}
}
fn main() {
let p = Point { x: 3, y: -1 };
println!("{}", serde_json::to_string(&p).unwrap());
}
{"x":3,"y":-1}
The generated code never mentions JSON. It only calls the Serializer trait's methods ("a struct with 2 fields", "field x", "field y"), and serde_json is one implementation of that trait. That's why the same derive works for TOML, YAML, MessagePack and dozens of other formats. #[serde(rename = "pos_x")] simply changes the string the derive passes to serialize_field.
How a procedural macro is built
A proc macro must live in its own crate with a special flag, which is why this site can't compile one for you:
# describe-derive/Cargo.toml
[lib]
proc-macro = true
[dependencies]
syn = "2" # parses the tokens into a syntax tree
quote = "1" # turns Rust-like templates back into tokens
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(Describe)]
pub fn derive_describe(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput); // the struct or enum
let name = &ast.ident; // its name
let expanded = quote! {
impl Describe for #name {
fn describe() -> String {
format!("I am {}", stringify!(#name))
}
}
};
expanded.into()
}
Users add describe-derive as a dependency and write #[derive(Describe)]. #name in quote! pastes a value in, like $name in macro_rules!.
An attribute macro receives the item it's attached to and may replace it entirely. #[tokio::main] turns your async fn main into roughly this:
fn main() {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
// the body of your async fn main
})
}
Should you write one?
Rarely. You'll use derive and attribute macros daily, but writing one costs a lot: a separate crate, slower compile times (syn is big), and code that's harder to debug. Teams write them when a pattern repeats across many types in a large codebase and no trait or macro_rules! can capture it. Before that, try a trait with default methods, a generic function, or a macro_rules! that implements a trait for a list of types (one of the challenges below). cargo expand shows derive output too, which is the best way to learn what a derive really does.
Your task
Implement Debug for Ticket by hand, the way #[derive(Debug)] would, using f.debug_struct(...). The output must be exactly what the derive prints: Ticket { id: 7, title: "Crash on start", open: true }, and the pretty {:#?} form must match too.
Challenge 2
Now the other two shapes. Implement Debug by hand (no derive) for the tuple struct Rgb and the enum Status, matching what #[derive(Debug)] prints:
Rgb(255, 0, 10)printsRgb(255, 0, 10): usef.debug_tuple("Rgb")with one.field(...)per value.Status::ActiveprintsActive, andStatus::Banned(reason)printsBanned("spam"). Usef.write_strfor the unit variant anddebug_tuplefor the other.
Challenge 3
You don't need a proc macro to implement a trait for many types. std implements its traits for all the integer types with macro_rules!. Write impl_describe!, which takes a comma-separated list of types and implements Describe for each one: describe() returns the type's name, a space, and the value's Display text (5i32.describe() is "i32 5"). Then use it once for i32, u8, f64, String.