RustArena

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:

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.

Previous: Hygiene, small DSLs and debugging macros