Zum Inhalt springen

Prozedurale Makros in Rust: dein eigenes #[derive] schreiben

Jedes Mal, wenn du #[derive(Debug)] oder #[derive(Serialize)] schreibst, läuft ein kleines Programm im Compiler. Es schaut sich dein Struct an und schreibt einen impl-Block für dich. Dieses kleine Programm ist ein prozedurales Makro (Procedural Macro). Und du kannst auch eigene schreiben.

Stell es dir wie einen hilfsbereiten Sachbearbeiter vor. Du gibst ihm ein Formular (dein Struct). Er liest jedes Feld und gibt dir den langweiligen Papierkram fertig ausgefüllt zurück. Auf der Seite Makros hast du macro_rules! kennengelernt. Das arbeitet mit Mustern, die es vergleicht. Prozedurale Makros sind anders: Es sind ganz normale Rust-Funktionen, die Code bekommen und Code zurückgeben. Auf dieser Seite baust du Schritt für Schritt ein echtes und siehst die echte Ausgabe.

Es gibt drei Arten. Innen funktionieren sie alle gleich, aber für die Person, die sie benutzt, sehen sie unterschiedlich aus:

Art Was man beim Benutzen schreibt Bekannte Beispiele
Derive-Makro #[derive(Serialize)] über einem Struct oder Enum Serialize von serde, Error von thiserror, Parser von clap
Attribut-Makro #[route(GET, "/")] über einer Funktion oder einem anderen Element #[tokio::main], #[get("/")] von actix-web
Funktionsähnliches Makro sql!(SELECT * FROM users) query! von sqlx, html! von yew
  • Ein Derive-Makro fügt nur Code neben deinem Typ hinzu. Dein Struct bleibt genau so, wie du es geschrieben hast.
  • Ein Attribut-Makro ersetzt das Element, über dem es steht. #[tokio::main] nimmt dein async fn main und macht daraus ein normales fn main, das die tokio-Runtime startet.
  • Ein funktionsähnliches Makro sieht aus wie ein Aufruf von macro_rules!. Aber hier entscheidet eine Rust-Funktion, was die Eingabe bedeutet. So kann sqlx::query! dein SQL schon beim Kompilieren prüfen.

Derive-Makros aus anderen Crates benutzt du schon längst. Hier erledigen zwei davon echte Arbeit. Serialize schreibt den JSON-Code (siehe JSON mit Serde), und Error schreibt den Display-Code aus dem Text in #[error(...)] (siehe anyhow und thiserror):

use serde::Serialize;
use thiserror::Error;
#[derive(Serialize)]
struct Order {
id: u32,
item: String,
quantity: u8,
}
#[derive(Debug, Error)]
enum ShopError {
#[error("Artikel {0} ist ausverkauft")]
OutOfStock(String),
}
fn main() {
let order = Order { id: 42, item: "Teekanne".to_string(), quantity: 2 };
println!("{}", serde_json::to_string(&order).unwrap());
let err = ShopError::OutOfStock("Teekanne".to_string());
println!("{err}");
}

Ausgabe:

{"id":42,"item":"Teekanne","quantity":2}
Artikel Teekanne ist ausverkauft
▶ Selbst ausprobieren

Du hast nie ein impl Display oder JSON-Code geschrieben. Das haben die Derives erledigt, während dein Programm kompiliert wurde.

Der Compiler zerlegt deinen Quellcode zuerst in Tokens: Namen, Satzzeichen, Literale, Klammern. Ein prozedurales Makro ist eine Funktion, die diese Tokens als TokenStream bekommt und einen neuen TokenStream zurückgibt. Danach kompiliert der Compiler, was du zurückgegeben hast – so, als hättest du es selbst getippt.

Hier sind die drei Funktionsformen. Jede bekommt ein Attribut, damit der Compiler weiß, um welche Art es sich handelt:

use proc_macro::TokenStream;
// #[derive(HelloMacro)]: bekommt das Struct oder Enum, gibt Code zum HINZUFÜGEN zurück.
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream { /* ... */ }
// #[route(GET, "/")]: bekommt die Tokens des Attributs und das Element, gibt einen ERSATZ zurück.
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream { /* ... */ }
// sql!(...): bekommt alles in den Klammern, gibt den Ersatz zurück.
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream { /* ... */ }

Ein roher TokenStream ist nur eine Liste von Tokens. Damit zu arbeiten ist mühsam. Deshalb benutzt fast jedes prozedurale Makro drei Hilfs-Crates:

  • syn macht aus Tokens einen Syntaxbaum. Statt Tokens bekommst du ein DeriveInput mit dem Namen des Typs, seinen Generics und seinen Feldern.
  • quote macht aus Code, der wie Rust aussieht, wieder Tokens. In quote! { ... } schreibst du normales Rust, und #name fügt eine Variable ein.
  • proc-macro2 ist eine Hülle um die proc_macro-Typen des Compilers, die syn und quote benutzen. Es funktioniert auch außerhalb des Compilers. Das macht Hilfsfunktionen und Tests einfacher.

Ein wichtiger Gedanke: Dein Makro läuft beim Kompilieren. Cargo kompiliert zuerst das Makro-Crate, und zwar für deinen eigenen Rechner. Der Compiler führt es dann aus, während er das Crate baut, das es benutzt. Wenn das Programm am Ende läuft, ist das Makro längst weg. Übrig bleibt nur der Code, den es geschrieben hat.

Ein prozedurales Makro muss in einem eigenen Crate mit einem besonderen Crate-Typ leben. Dieses Crate darf nur Makros exportieren, keine normalen Funktionen oder Traits. Deshalb sieht man meist zwei Crates nebeneinander, wie serde und serde_derive: eins für den Trait, eins für das Derive. Wir fügen noch ein drittes Crate hinzu: ein kleines Programm namens shop, das beide benutzt. (Du willst Crates und Workspaces auffrischen? Schau in Cargo und Crates.)

Terminal window
mkdir procmacro-demo && cd procmacro-demo
cargo new --lib hello_macro
cargo new --lib hello_macro_derive
cargo new shop

So sieht der fertige Dateibaum aus:

procmacro-demo/
├── Cargo.toml (der Workspace)
├── hello_macro/
│ ├── Cargo.toml
│ └── src/lib.rs (die Traits und ein Re-Export der Derives)
├── hello_macro_derive/
│ ├── Cargo.toml (proc-macro = true)
│ └── src/lib.rs (die Makros selbst)
└── shop/
├── Cargo.toml
└── src/main.rs (ein Programm, das #[derive(...)] benutzt)

Die oberste Cargo.toml verbindet die drei Crates zu einem Workspace:

[workspace]
members = ["hello_macro", "hello_macro_derive", "shop"]
resolver = "2"

Nur das Makro-Crate hat etwas Besonderes: proc-macro = true. Außerdem hängt es von syn, quote und proc-macro2 ab:

hello_macro_derive/Cargo.toml
[package]
name = "hello_macro_derive"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = "2"

Die anderen beiden Crates sind ganz normal. hello_macro hängt vom Derive-Crate ab, und shop braucht nur hello_macro:

hello_macro/Cargo.toml
[dependencies]
hello_macro_derive = { version = "0.1.0", path = "../hello_macro_derive" }
shop/Cargo.toml
[dependencies]
hello_macro = { version = "0.1.0", path = "../hello_macro" }

Fangen wir ganz klein an. Jeder Typ mit #[derive(HelloMacro)] soll eine Funktion hello_macro() bekommen, die den Namen des Typs ausgibt. Zuerst der Trait, in hello_macro/src/lib.rs. Die letzte Zeile exportiert die Derive-Makros weiter (Re-Export). So brauchen Nutzer nur dieses eine Crate. Genau so macht es auch serde.

hello_macro/src/lib.rs
pub trait HelloMacro {
fn hello_macro();
}
pub trait Describe {
fn type_name() -> &'static str;
fn field_names() -> &'static [&'static str];
}
// Die Derive-Makros weiterexportieren, damit Nutzer nur dieses eine Crate brauchen.
pub use hello_macro_derive::{Describe, HelloMacro};

(Describe ist für Schritt 2.) Jetzt das Makro selbst, in hello_macro_derive/src/lib.rs:

hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields}; // Data und Fields: Schritt 2
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// 1. Die Tokens in einen Syntaxbaum umwandeln, den wir untersuchen können.
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
// 2. Den neuen Code mit quote! bauen.
let generated = quote! {
impl hello_macro::HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
// 3. Die neuen Tokens an den Compiler zurückgeben.
generated.into()
}

Drei Schritte. Du wirst sie in fast jedem Derive-Makro wiederfinden:

  1. parse_macro_input! macht aus den Tokens ein DeriveInput. ast.ident ist der Name des Typs, zum Beispiel Product. Wenn das Parsen fehlschlägt, gibt dieses Makro für dich einen ordentlichen Kompilierfehler zurück.
  2. quote! baut den impl-Block. #name fügt den Namen ein. stringify!(#name) macht aus dem Namen den Text "Product".
  3. .into() wandelt quotes proc_macro2::TokenStream in den proc_macro::TokenStream des Compilers um.

Achte auf den vollen Pfad hello_macro::HelloMacro. Der erzeugte Code wird in die Datei der Nutzerin oder des Nutzers eingefügt. Er kann sich also nicht auf deren use-Zeilen verlassen.

Wenn jemand #[derive(HelloMacro)] struct Product schreibt, fügt der Compiler Code hinzu, der genau wie diese handgeschriebene Version funktioniert. Diese Version läuft im Playground, weil kein Makro-Crate beteiligt ist. (Den Text lassen wir auf Englisch, damit er zum Beispielprojekt passt.)

trait HelloMacro {
fn hello_macro();
}
struct Product;
// Das schreibt #[derive(HelloMacro)] für dich:
impl HelloMacro for Product {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", "Product");
}
}
fn main() {
Product::hello_macro();
}

Ausgabe:

Hello, Macro! My name is Product!
▶ Selbst ausprobieren

Das einmal von Hand zu schreiben, ist kein Problem. Bei 40 Typen lohnt sich ein Derive dann wirklich.

HelloMacro hat nur den Namen des Typs benutzt. Echte Derives wie die von serde schauen sich die Felder an. Bauen wir also Describe: Es listet die Feldnamen eines Structs auf. Das ist zum Beispiel praktisch für die Kopfzeile einer CSV-Datei. syn gibt dir die Felder über ast.data:

  • Data::Struct, Data::Enum oder Data::Union: welche Art von Typ es ist.
  • Bei einem Struct Fields::Named (struct Product { id: u32 }), Fields::Unnamed (struct Meters(f64)) oder Fields::Unit (struct Marker;).

Füge das unter dem ersten Makro in hello_macro_derive/src/lib.rs ein:

#[proc_macro_derive(Describe)]
pub fn describe_derive(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
expand_describe(&ast)
.unwrap_or_else(|err| err.to_compile_error())
.into()
}
fn expand_describe(ast: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
let name = &ast.ident;
let Data::Struct(data) = &ast.data else {
return Err(syn::Error::new_spanned(
name,
"Describe can only be derived for structs",
));
};
let field_names: Vec<String> = match &data.fields {
Fields::Named(fields) => fields
.named
.iter()
.map(|field| field.ident.as_ref().unwrap().to_string())
.collect(),
Fields::Unit => Vec::new(),
Fields::Unnamed(_) => {
return Err(syn::Error::new_spanned(
name,
"Describe needs named fields, like `struct Point { x: i32 }`",
));
}
};
Ok(quote! {
impl hello_macro::Describe for #name {
fn type_name() -> &'static str {
stringify!(#name)
}
fn field_names() -> &'static [&'static str] {
&[#(#field_names),*]
}
}
})
}

Das ist neu:

  • Die eigentliche Arbeit passiert in einer normalen Hilfsfunktion, die syn::Result zurückgibt. So kannst du ? und return Err(...) benutzen, wie bei jeder anderen Fehlerbehandlung. Die Makro-Funktion ganz oben macht aus einem Fehler nur noch einen Kompilierfehler.
  • let ... else erledigt den Fall „kein Struct“ sofort und hält den Normalfall schön flach.
  • #(#field_names),* ist die Wiederholung (Repetition) von quote. Sie fügt jeden String aus dem Vec ein, getrennt durch Kommas. Das ist dieselbe Idee wie $(...),* in macro_rules!. Jeder String wird zu einem String-Literal. Daraus wird also &["id", "name", "price_cents"].
  • field.ident.as_ref().unwrap() ist hier sicher: Bei Fields::Named hat jedes Feld einen Namen.

Jetzt benutzt du beide Derives in shop/src/main.rs. Achte darauf: use hello_macro::{Describe, HelloMacro} holt die Traits und die Derive-Makros. Sie haben denselben Namen, leben aber in verschiedenen Namensräumen. Genau so funktioniert auch serde::Serialize.

shop/src/main.rs
use hello_macro::{Describe, HelloMacro};
#[derive(HelloMacro, Describe)]
struct Product {
id: u32,
name: String,
price_cents: u64,
}
#[derive(HelloMacro, Describe)]
struct Customer {
email: String,
city: String,
}
fn main() {
Product::hello_macro();
Customer::hello_macro();
println!("{} has fields {:?}", Product::type_name(), Product::field_names());
// Eine CSV-Datei: Die Kopfzeile kommt vom Derive, die Zeilen aus echten Werten.
let ana = Customer { email: "[email protected]".to_string(), city: "Lisbon".to_string() };
println!("{}", Customer::field_names().join(","));
println!("{},{}", ana.email, ana.city);
let teapot = Product { id: 7, name: "Teapot".to_string(), price_cents: 1999 };
println!("{} #{}: {} costs {} cents", Product::type_name(), teapot.id, teapot.name, teapot.price_cents);
}

Starte es im Workspace-Ordner:

Terminal window
cargo run -p shop

Ausgabe (bei einem frischen Build):

Compiling proc-macro2 v1.0.107
Compiling unicode-ident v1.0.27
Compiling quote v1.0.47
Compiling syn v2.0.119
Compiling hello_macro_derive v0.1.0 (/home/you/procmacro-demo/hello_macro_derive)
Compiling hello_macro v0.1.0 (/home/you/procmacro-demo/hello_macro)
Compiling shop v0.1.0 (/home/you/procmacro-demo/shop)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.45s
Running `target/debug/shop`
Hello, Macro! My name is Product!
Hello, Macro! My name is Customer!
Product has fields ["id", "name", "price_cents"]
email,city
Product #7: Teapot costs 1999 cents

Die Zeilen Compiling, Finished und Running gibt Cargo aus, und zwar immer auf Englisch. Schau dir die Reihenfolge an: Cargo baut syn und hello_macro_derive vor shop. Der Compiler muss dein Makro ja ausführen, während er shop kompiliert. Wenn du Customer ein Feld hinzufügst, passt sich die CSV-Kopfzeile beim nächsten Build von selbst an.

Was passiert, wenn jemand #[derive(Describe)] über ein Enum schreibt? Weil expand_describe einen syn::Error zurückgibt, bekommt die Person einen ganz normal aussehenden Compilerfehler. (Die kaputten Beispiele auf dieser Seite stehen in shop/src/bin/broken.rs. Deshalb taucht dieser Pfad auf.)

use hello_macro::Describe;
#[derive(Describe)]
enum OrderStatus {
Pending,
Shipped,
}
error: Describe can only be derived for structs
--> shop/src/bin/broken.rs:4:6
|
4 | enum OrderStatus {
| ^^^^^^^^^^^

Die Meldung ist der Text, den wir ins Makro geschrieben haben („Describe kann nur für Structs abgeleitet werden“). Zwei Dinge machen diesen Fehler gut:

  • syn::Error::new_spanned(name, ...) merkt sich, wo der Name in der Datei der Nutzerin oder des Nutzers steht (sein Span, also die Stelle im Code). Deshalb zeigen die ^^^^^ auf OrderStatus und nicht auf irgendeine Zeile in deinem Makro.
  • to_compile_error() macht aus dem Fehler einen Aufruf von compile_error!("..."). Der Compiler meldet ihn dann wie jeden anderen Fehler. Das Makro compile_error! kannst du übrigens auch in normalem Code benutzen.

Ein Tuple Struct bekommt die andere Meldung („Describe braucht benannte Felder“). Auch sie zeigt auf die richtige Stelle:

error: Describe needs named fields, like `struct Point { x: i32 }`
--> shop/src/bin/broken.rs:4:8
|
4 | struct Meters(f64);
| ^^^^^^

Schreib deine Meldungen für die Person, die das Makro benutzt. Sag, was falsch ist und was stattdessen funktionieren würde.

Makros können sich wie Zauberei anfühlen. Und Zauberei ist schwer zu debuggen. cargo-expand zeigt dir den Code, nachdem alle Makros gelaufen sind. Installiere es einmal (weitere nützliche Werkzeuge findest du unter Rust-Tools und Einrichtung):

Terminal window
cargo install cargo-expand
cargo expand -p shop

Ein Teil davon, was es für unser Projekt ausgibt:

impl hello_macro::Describe for Product {
fn type_name() -> &'static str {
"Product"
}
fn field_names() -> &'static [&'static str] {
&["id", "name", "price_cents"]
}
}
impl hello_macro::Describe for Customer {
fn type_name() -> &'static str {
"Customer"
}
fn field_names() -> &'static [&'static str] {
&["email", "city"]
}
}

Da ist dein impl-Block, mit den eingesetzten Feldnamen, und stringify!(#name) ist schon zu "Product" geworden. Wenn ein Derive nicht tut, was du erwartest, starte zuerst cargo expand. Es expandiert auch println! und andere Makros. Such also nach deinen eigenen impl-Blöcken.

Ein Makro-Crate kann sich selbst nur schwer testen, weil es nur Makros exportieren darf. Die einfache Lösung: Schreib normale Tests in einem Crate, das das Makro benutzt. Leg das in hello_macro/tests/describe.rs ab und starte cargo test -p hello_macro:

use hello_macro::Describe;
#[allow(dead_code)]
#[derive(Describe)]
struct Product {
id: u32,
name: String,
}
#[test]
fn lists_field_names_in_order() {
assert_eq!(Product::type_name(), "Product");
assert_eq!(Product::field_names(), ["id", "name"]);
}
running 1 test
test lists_field_names_in_order ... ok

Aber wie testest du, dass der Enum-Fall einen guten Fehler liefert? Mit einem normalen Test geht das nicht, denn die Testdatei würde gar nicht kompilieren. Genau dafür gibt es das Crate trybuild. Du legst kleine Dateien, die „fehlschlagen sollen“, in einen Ordner. trybuild kompiliert jede davon und vergleicht den Fehler des Compilers mit einer gespeicherten .stderr-Datei:

#[test]
fn ui() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/ui/*.rs");
}

Die meisten beliebten Makro-Crates, auch serde und thiserror, testen ihre Fehlermeldungen auf diese Weise.

Prozedurale Makros sind mächtig, aber sie haben ihren Preis. Sei ehrlich zu dir, bevor du eins schreibst:

  • Kompilierzeit. syn ist ein großes Crate. Der erste Build jedes Projekts, das dein Makro benutzt, muss es kompilieren. (Für Derives reichen die Standard-Features von syn. features = ["full"] brauchst du nur, um zum Beispiel Funktionsrümpfe zu parsen, und das macht es langsamer.)
  • Komplexität. Du hast jetzt ein Crate mehr, Code, der Code schreibt, und Fehler, die schwerer nachzuvollziehen sind.
  • Werkzeuge. Editoren und rust-analyzer verstehen normalen Code besser als Code aus einem Makro.

Eine gute Reihenfolge zum Ausprobieren:

  1. Eine Funktion, ein Trait mit einer Default-Methode oder Generics. Die meisten Probleme mit „sich wiederholendem Code“ lösen sich schon hier.
  2. Ein macro_rules!-Makro (siehe Makros). Es braucht kein zusätzliches Crate.
  3. Ein prozedurales Makro, wenn der Code von der Struktur eines Typs abhängt (seinem Namen, seinen Feldern oder Varianten), viele Typen ihn brauchen und andere Leute das Makro benutzen werden. Deshalb setzen serde, thiserror und clap auf Derives.

Zum Vergleich hier Describe mit macro_rules!. Es funktioniert und braucht kein zusätzliches Crate. Aber das Struct muss innerhalb des Makroaufrufs stehen. Das wird schnell unhandlich, zum Beispiel wenn du auch Doku-Kommentare, pub-Felder oder andere Derives willst.

trait Describe {
fn type_name() -> &'static str;
fn field_names() -> &'static [&'static str];
}
macro_rules! describe_struct {
(struct $name:ident { $($field:ident : $ty:ty),* $(,)? }) => {
struct $name {
$($field: $ty),*
}
impl Describe for $name {
fn type_name() -> &'static str {
stringify!($name)
}
fn field_names() -> &'static [&'static str] {
&[$(stringify!($field)),*]
}
}
};
}
describe_struct!(struct Customer { email: String, city: String });
fn main() {
let ana = Customer { email: "[email protected]".to_string(), city: "Lisbon".to_string() };
println!("{} has fields {:?}", Customer::type_name(), Customer::field_names());
println!("{},{}", ana.email, ana.city);
}

Ausgabe:

Customer has fields ["email", "city"]
▶ Selbst ausprobieren

Für ein oder zwei Typen in deinem eigenen Programm ist das (oder einfach ein handgeschriebenes impl) oft die bessere Wahl. Ein Derive ist angenehmer für die Leute, die es benutzen: Sie behalten ihr normales Struct und fügen nur eine Zeile hinzu.

proc-macro = true vergessen. Ohne den Abschnitt [lib] in der Cargo.toml des Makro-Crates ist das Crate eine normale Bibliothek, und der Compiler weigert sich:

error: the `#[proc_macro_derive]` attribute is only usable with crates of the `proc-macro` crate type
--> hello_macro_derive/src/lib.rs:5:1
|
5 | #[proc_macro_derive(HelloMacro)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[E0432]: unresolved import `proc_macro`

Der Compiler sagt dir: #[proc_macro_derive] geht nur in Crates vom Typ proc-macro. Deshalb findet er auch das Crate proc_macro nicht.

panic! oder unwrap() für Fehler der Nutzer. Das „funktioniert“, aber der Fehler ist vage und zeigt auf das ganze #[derive] statt auf das eigentliche Problem:

error: proc-macro derive panicked
--> shop/src/bin/broken.rs:3:10
|
3 | #[derive(Describe)]
| ^^^^^^^^
|
= help: message: Describe can only be derived for structs

Hier sagt der Compiler nur, dass das Derive-Makro eine Panic hatte, und zeigt die Meldung als Hilfe an. Gib stattdessen einen syn::Error mit new_spanned zurück, wie in Schritt 2.

Kurze Pfade im erzeugten Code. Wenn du impl Describe for #name schreibst statt impl hello_macro::Describe for #name, klappt das nur, wenn die Nutzerin oder der Nutzer zufällig Describe importiert hat. Wer #[derive(hello_macro::Describe)] schreibt, bekommt das hier:

error[E0405]: cannot find trait `Describe` in this scope
--> shop/src/bin/broken.rs:1:10
|
1 | #[derive(hello_macro::Describe)]
| ^^^^^^^^^^^^^^^^^^^^^ not found in this scope
|
= note: this error originates in the derive macro `hello_macro::Describe`

Heißt: Der Trait Describe wird in diesem Gültigkeitsbereich (Scope) nicht gefunden. Benutze in quote! deshalb immer volle Pfade: hello_macro::Describe, ::std::fmt::Display, ::core::option::Option.

Diese Übungen verändern das echte Projekt. Deshalb gibt es hier keinen Ausprobieren-Button. Bau das Projekt von dieser Seite auf deinem eigenen Rechner nach und starte cargo run -p shop.

Füge dem Trait Describe und dem Derive eine dritte Methode hinzu: fn field_count() -> usize. Product::field_count() soll 3 zurückgeben. Tipp: Den Vektor field_names hast du schon. Speichere field_names.len() vor quote! in einer Variablen und füge sie mit # ein.

Lösung anzeigen

Ergänze in hello_macro/src/lib.rs die Methode im Trait:

pub trait Describe {
fn type_name() -> &'static str;
fn field_names() -> &'static [&'static str];
fn field_count() -> usize;
}

Zähle in expand_describe die Felder und füge die Methode zum erzeugten impl hinzu:

let count = field_names.len();
Ok(quote! {
impl hello_macro::Describe for #name {
fn type_name() -> &'static str {
stringify!(#name)
}
fn field_names() -> &'static [&'static str] {
&[#(#field_names),*]
}
fn field_count() -> usize {
#count
}
}
})

Dann gibt println!("{} has {} fields", Product::type_name(), Product::field_count()); in main Folgendes aus:

Product has 3 fields

Das Zählen passiert in deinem Makro, also beim Kompilieren. cargo expand zeigt: Das Ergebnis ist einfach fn field_count() -> usize { 3usize }, eine Konstante. Zur Laufzeit gibt es nichts mehr zu tun.

Probier #[derive(Describe)] an einem generischen Struct wie struct Page<T> { items: Vec<T>, number: u32 } aus. Das schlägt fehl mit error[E0107]: missing generics for struct Page (die Generics fehlen). Der erzeugte Code sagt nämlich impl hello_macro::Describe for Page und vergisst das <T>. Repariere das Derive, damit generische Structs funktionieren. Tipp: ast.generics.split_for_impl() liefert dir drei Teile, die du in die impl-Zeile einfügst. Unter Generics kannst du nachlesen, wie impl<T> aussieht.

Lösung anzeigen

Teile in expand_describe die Generics auf und benutze alle drei Teile in der impl-Zeile:

let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl();
Ok(quote! {
impl #impl_generics hello_macro::Describe for #name #ty_generics #where_clause {
fn type_name() -> &'static str {
stringify!(#name)
}
fn field_names() -> &'static [&'static str] {
&[#(#field_names),*]
}
}
})

Jetzt funktioniert das hier in shop:

#[derive(Describe)]
struct Page<T> {
items: Vec<T>,
number: u32,
}
fn main() {
let page = Page { items: vec!["a", "b"], number: 1 };
println!("{} {} {:?}", page.items.len(), page.number, Page::<String>::field_names());
}
2 1 ["items", "number"]

impl_generics ist <T> (mit allen Bounds), ty_generics ist <T>, und where_clause ist der where ...-Teil, falls es einen gibt. Jedes ernsthafte Derive macht das so. Merk dir also split_for_impl.

  • Ein prozedurales Makro ist eine Rust-Funktion, die beim Kompilieren läuft: TokenStream rein, TokenStream raus.
  • Es gibt drei Arten: Derive (#[derive(Serialize)]), Attribut (#[tokio::main]) und funktionsähnlich (sql!(...)).
  • Sie leben in einem eigenen Crate mit proc-macro = true. Ein Haupt-Crate exportiert sie meist zusammen mit den Traits weiter.
  • syn parst die Eingabe (DeriveInput, Data, Fields), und quote schreibt die Ausgabe (#name, #(...),*).
  • Melde Fehler der Nutzer mit syn::Error::new_spanned(...).to_compile_error(), prüf das Ergebnis mit cargo expand und teste Fehler mit trybuild.
  • Probier zuerst eine Funktion, einen Trait oder macro_rules!. Ein prozedurales Makro lohnt sich, wenn viele Typen Code brauchen, der auf ihrer Struktur basiert.

Kurzer Check

1. Wann läuft der Code eines prozeduralen Makros?

2. Welche Art von prozeduralem Makro ist #[tokio::main]?

3. Was machen syn und quote?

4. Dein Derive soll Enums ablehnen. Wie machst du das am besten?

Punkte: 0 / 4

  • Prozedurale Makros – Das Rust-Buch: der offizielle Rundgang durch ein HelloMacro-Derive, dazu attribut-ähnliche und funktionsähnliche Makros (deutsche Übersetzung).
  • Procedural Macros – The Rust Reference (auf Englisch): die genauen Regeln für die drei Arten, Crate-Typen, Spans und Hygiene.
  • Dokumentation von syn (auf Englisch): DeriveInput, Data, Fields, Error und die Parse-Hilfen, die auf dieser Seite vorkommen.
  • Dokumentation von quote (auf Englisch): wie die Interpolation mit #var und die Wiederholung mit #(...),* in quote! funktionieren.
  • proc-macro-workshop (auf Englisch): kostenlose Praxisprojekte von David Tolnay (ein Builder-Derive, ein Debug-Derive und mehr) mit Tests, die dich Schritt für Schritt anleiten.