#[derive(Debug)] is the first attribute every Rust beginner writes and the last
one they understand. This article opens both it and its more alarming cousin, the
attribute macro, because between them they account for most of the code in a
typical Rust project that nobody typed.
First, a correction
The built-in derives are not procedural macros.
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash,
Default are implemented inside rustc. They share the #[derive(...)] syntax
with user-written derive macros, and they behave the same way from the outside,
but they are compiler built-ins with direct access to internal representations —
no syn, no quote, no separate crate.
That matters mostly for expectations: built-in derives are fast and always
available, while a #[derive(Serialize)] costs you a dependency on syn and a
compilation of that dependency.
What a derive does, conceptually
Four steps, and they are the same for the built-ins and for anything you write:
-
Receive the item’s tokens. The whole
structorenum, verbatim. -
Parse them into a syntax tree.
syn::parse_macro_input!(input as DeriveInput)gives aDeriveInputwith the name, the generics, the visibility, and theData— a struct’s fields or an enum’s variants. -
Iterate. For
Debugon a struct, that means walking the fields and producing one.field("name", &self.name)call per field. -
Emit an
impl.
Written out for a simple struct, #[derive(Debug)] on
#[derive(Debug)]
pub struct Point { x: f64, y: f64 }
is equivalent to roughly
impl ::core::fmt::Debug for Point {
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
f.debug_struct("Point")
.field("x", &self.x)
.field("y", &self.y)
.finish()
}
}
debug_struct / field / finish is a real public API — it is what gives you
the {:#?} pretty-printed form for free, and you can call it by hand when you
want a custom Debug that still looks standard.
The output is appended next to the struct (18.17). The struct itself is untouched, which is why a derive can never add a field or change a type.
The generics problem, and why hand-rolled derives break
This is the part that separates a toy derive from a correct one.
#[derive(Debug)]
struct Wrapper<T> { inner: T }
The generated impl cannot be impl<T> Debug for Wrapper<T>, because printing
inner requires T: Debug. It must be:
impl<T: ::core::fmt::Debug> ::core::fmt::Debug for Wrapper<T> { ... }
Adding that bound for every type parameter is what syn gives you helpers for,
and forgetting it is the number-one bug in hand-written derive macros: the macro
works perfectly on every concrete type in your test suite and fails the moment
someone uses it on a generic.
It also has a well-known downside, which item 7.24 covered from the other
direction: the derived bound is T: Debug on every parameter, whether or not
that parameter actually appears in a field. struct Marker<T> { data: u32, _p: PhantomData<T> }
gets T: Debug it does not need, and then Marker<NotDebug> mysteriously stops
being printable. Crates that care about this (serde with #[serde(bound = "...")],
derivative) let you override the bound explicitly. The built-in derives do not.
Attribute macros: whole-item rewriters
A derive appends. An attribute macro replaces, and the replacement need bear no resemblance to what you wrote.
The clearest example in the ecosystem:
#[tokio::main]
async fn main() {
do_something().await;
}
main is declared async. But main cannot be async — the language has no
async runtime, and the entry point must be a synchronous function. So what runs?
#[tokio::main] receives the entire async fn main as tokens and returns
something like:
fn main() {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
do_something().await;
})
}
The async is gone. The signature changed. The body has been moved inside a
closure passed to block_on. The item was replaced. Nothing about the source
you wrote survives except the body, and even that got relocated.
Arguments arrive as the first TokenStream:
#[tokio::main(flavor = "current_thread")]
hands flavor = "current_thread" to the macro, which parses it and picks
new_current_thread() instead. There is no type checking on those arguments
beyond whatever the macro implements — a typo gives you whatever error the macro
author chose to emit.
Since this course teaches async with a hand-rolled std-only executor, this is
worth dwelling on: #[tokio::main] is a source rewrite, not a language
feature. Async in Rust needs no compiler magic beyond async/await desugaring;
the runtime is a library, and the attribute is a convenience that saves you eight
lines.
#[test] is different again
#[test]
fn it_works() { assert_eq!(2 + 2, 4); }
#[test] is not a proc macro. It is a built-in attribute that the compiler
recognises, collects during compilation, and uses to generate a test harness
main containing a registry of every annotated function. That is why #[test]
functions can be scattered across modules and still all run: something with a
whole-crate view is collecting them, which no proc macro could do (18.16, point
6 — no state across expansions).
#[cfg(test)], #[derive], #[repr], #[inline] are similarly built in.
Distinguishing “compiler feature” from “library-provided macro” is worth doing
when you are trying to work out where some behaviour comes from.
Ordering when you stack them
#[my_attribute]
#[derive(Debug, Clone)]
struct Thing { ... }
Attribute macros run before derives, and the attribute sees the #[derive]
in its input token stream. So #[my_attribute] could remove the derive, add
another one, or rewrite the struct such that the derive then applies to something
different from what you wrote.
Swap the order and you get a different program. This is a real source of confusing behaviour with attribute macros that manipulate fields, and it is why crates in that space document their required attribute order.
Reading generated code is the actual job
Almost nobody writes proc macros. Everybody reads their output — in a stack
trace, in a cargo expand dump, in an error message pointing at a line that does
not exist. The useful skills are:
- Knowing whether an annotation could have changed the item (derive: no; attribute: yes).
-
Recognising
#nameinterpolation andsyntype names when you open the source of a macro you depend on. -
Expecting the generics bound problem when a derive works on
Foo<u32>but not onFoo<T>. -
Reaching for
cargo expandearly rather than guessing.