Kapitel 17: Metaprogrammierung mit Makros – Fortgeschrittene Code-Generierung und prozedurale Makros
Während deklarative Makros hervorragend für einfache syntaktische Ersetzungen geeignet sind, erfordern komplexe architektonische Aufgaben eine mächtigere Form der Metaprogrammierung. Wenn Sie beispielsweise Web-Routen annotieren, Datenbankabfragen zur Compilezeit validieren oder automatisch komplexe Serialisierungslogik generieren möchten, stoßen deklarative Makros an ihre Grenzen.
Rust bietet hierfür prozedurale Makros. Sie verhalten sich wie Plugins für den Compiler: Sie erhalten den Quellcode als abstrakte Datenstruktur (TokenStream), führen beliebigen Rust-Code darauf aus und geben einen modifizierten oder neuen TokenStream zurück.
1. Lernziele – Das wirst du heute lernen
- Fortgeschrittene Designators einsetzen: Sie beherrschen
stmt,pat,ty,blockundtt. - Makro-Exportregeln verstehen: Sie nutzen
#[macro_use]und#[macro_export]korrekt. - Das proc-macro Crate aufbauen: Sie konfigurieren ein Hilfs-Crate für prozedurale Makros.
- Derive-Makros implementieren: Sie schreiben Makros, die Traits automatisch ableiten.
- Attribut- und funktionsartige Makros entwerfen: Sie manipulieren Code auf AST-Ebene mit
synundquote.
2. Fortgeschrittene Meta-Syntax in deklarativen Makros
Neben expr und ident bietet macro_rules! spezifische Spezifizierer zur präzisen Steuerung des Syntax-Matchings:
stmt(Statement): Matcht eine einzelne Anweisung, z. B.let x = 5;.block(Block): Matcht einen in geschweifte Klammern gefassten Code-Block.ty(Type): Matcht einen Datentyp, z. B.i32oderVec<String>.tt(Token Tree): Das mächtigste Werkzeug. Matcht ein einzelnes syntaktisches Token oder eine Gruppe von Token in Klammern. Ideal für das Durchreichen von beliebigem Code.
3. Die Crate-Struktur für prozedurale Makros
Prozedurale Makros müssen zwingend in einem eigenen Bibliotheks-Crate (Library) liegen, da sie während der Kompilierung ausgeführt und dafür geladen werden müssen.
Die Cargo.toml des Makro-Crates deklariert den Bibliothekstyp:
[lib]
proc-macro = true
[dependencies]
# syn parst den rohen TokenStream in einen strukturierten AST
syn = { version = "2.0", features = ["full"] }
# quote wandelt Rust-Syntaxstrukturen wieder in einen TokenStream um
quote = "1.0"
4. Die drei Arten prozeduraler Makros
1. Derive-Makros (Benutzerdefinierte Ableitungen)
Sie implementieren Traits automatisch für Strukturen und Enums. Sie fügen Code hinzu, verändern aber das Originalelement nicht.
Aufruf:
#![allow(unused)]
fn main() {
#[derive(HalloWelt)]
struct Benutzer {
name: String,
}
}
Implementierung im Makro-Crate:
#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(HalloWelt)]
pub fn hallo_welt_derive(input: TokenStream) -> TokenStream {
// Eingabe als AST parsen
let input = parse_macro_input!(input as DeriveInput);
let name = input.ident; // Name der Struktur (z. B. "Benutzer")
// Neuen Code generieren
let expanded = quote! {
impl HalloWelt for #name {
fn hallo_welt() {
println!("Hallo, mein Name ist {}!", stringify!(#name));
}
}
};
TokenStream::from(expanded)
}
}
2. Attribut-Makros
Diese erstellen benutzerdefinierte Attribute, die fast allen Elementen angehängt werden können. Im Gegensatz zu Derive-Makros können sie das Element, an das sie angehängt sind, komplett verändern oder ersetzen.
Aufruf:
#![allow(unused)]
fn main() {
#[route(GET, "/profile")]
fn hole_profil() {
// ...
}
}
Implementierung:
#![allow(unused)]
fn main() {
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
// attr enthält "GET, \"/profile\""
// item enthält die Funktion "fn hole_profil() { ... }"
item // Hier können wir den Code manipulieren und modifiziert zurückgeben
}
}
3. Funktionsartige Makros
Sie werden wie deklarative Makros mit ! aufgerufen, verarbeiten die Eingabe aber völlig frei als TokenStream.
Aufruf:
#![allow(unused)]
fn main() {
let sql_abfrage = sql!("SELECT * FROM users WHERE age > 18");
}
5. Die goldene Regel der Metaprogrammierung
Obwohl Makros extrem mächtig sind, gilt im Software-Engineering: Nutzen Sie Makros nur als letztes Mittel.
- Nachteile: Makros verlängern die Kompilierzeit spürbar. Editoren und IDEs haben Schwierigkeiten beim Parsen, wodurch Autovervollständigung und Refactoring-Tools schlechter funktionieren. Zudem sind Compiler-Fehlermeldungen innerhalb von Makros oft schwer zu lesen.
- Empfehlung: Verwenden Sie normale Funktionen, Generics und Traits, wann immer es möglich ist.