Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Kapitel 17: Metaprogrammierung mit Makros

In vielen Programmiersprachen stoßen Entwickler irgendwann an eine Grenze: Sie möchten Code schreiben, der sich wiederholt, aber die normalen Sprachkonstrukte (wie Funktionen, Schleifen oder Klassen) reichen nicht aus, um diese Wiederholung elegant zu abstrahieren. Hier kommt die Metaprogrammierung ins Spiel – das Schreiben von Programmen, die andere Programme schreiben oder manipulieren.

In Rust geschieht Metaprogrammierung hauptsächlich durch Makros. Anders als in Sprachen wie C oder C++, in denen Makros einfache und fehleranfällige Textaustausch-Mechanismen des Präprozessors sind, sind Rust-Makros tief in den Compiler integriert. Sie arbeiten nicht auf reinem Text, sondern direkt auf der Ebene des abstrakten Syntaxbaums (Abstract Syntax Tree, AST). Das macht sie extrem mächtig, typsicher und robust vor unerwünschten Seiteneffekten.

In diesem Kapitel bieten wir Ihnen drei verschiedene Perspektiven auf das Thema an. Wählen Sie die Sicht, die am besten zu Ihrem Hintergrund passt:

  • Für Anfänger (Einfach): Konzentriert sich auf die Analogie der Druckerpresse, einfache deklarative Makros (macro_rules!), Platzhalter (expr & ident), Wiederholungen und das Prinzip der Hygiene.
  • für Profis (Architektur): Behandelt fortgeschrittene Designators, Sichtbarkeit und Exportregeln, prozedurale Makros (Derive, Attribut, funktionsartig) und deren Integration mit den Bibliotheken syn und quote.
  • Hardware-Sicht (CPU/RAM): Analysiert die Position der Makro-Expansion im Compiler-Ablauf (Lexing/Parsing), den Abstract Syntax Tree (AST), die Implementierung der Hygiene über Syntax-Kontexte und das Debuggen mittels cargo expand.

Begleitvideo zu Kapitel 17: Metaprogrammierung mit Makros


Kapitel 17: Metaprogrammierung mit Makros – Die automatische Druckerpresse

Stell dir vor, du lebst im späten Mittelalter und besitzt ein Skriptorium (eine Schreibstube). Deine Aufgabe ist es, 500 Einladungen für das königliche Ritterturnier zu schreiben. Jede Einladung sieht fast identisch aus: „Edler Herr [Name], wir laden euch herzlich ein zum Turnier am [Datum] zu [Ort]…“

Wenn du jeden Brief einzeln von Hand schreibst, wirst du verrückt. Es dauert Wochen, deine Hand verkrampft und du machst garantiert irgendwann einen Rechtschreibfehler.

Dann erfindet Johannes Gutenberg die Druckerpresse mit beweglichen Lettern. Jetzt musst du die Einladung nur ein einziges Mal als Schablone (als Druckplatte) setzen. An den Stellen für Name, Datum und Ort lässt du Lücken. Wenn du nun ein Buch oder ein Blatt druckst, schiebst du einfach die konkreten Namen in die Lücken. Die Druckerpresse erzeugt in Sekundenschnelle fertige Briefe.

In Rust sind Makros genau diese Druckerpresse. Normale Funktionen arbeiten mit Werten zur Laufzeit (wenn das Programm gestartet ist). Makros hingegen arbeiten mit dem Programmiercode selbst zur Kompilierzeit (wenn das Programm gebaut wird). Sie lesen deinen geschriebenen Code, setzen ihn in Schablonen ein und drucken neuen Code, bevor das eigentliche Programm übersetzt wird.


1. Lernziele – Das wirst du heute lernen

  • Funktion vs. Makro: Du verstehst den Unterschied zwischen Laufzeit- und Kompilierzeit-Code.
  • Deklarative Makros schreiben: Du lernst, eigene Schablonen mit macro_rules! zu erstellen.
  • Platzhalter nutzen: Du begreifst Fragment-Spezifizierer wie expr und ident.
  • Wiederholungen einbauen: Du baust Makros, die beliebig viele Argumente annehmen.
  • Das Prinzip der Hygiene: Du verstehst, warum sich Makro-Variablen nicht mit deinem restlichen Code beißen.

2. Der Unterschied zwischen Funktionen und Makros

Bevor wir Code schreiben, klären wir, warum wir überhaupt Makros brauchen:

  1. Beliebig viele Argumente: Eine normale Funktion in Rust muss immer eine feste Anzahl an Argumenten haben. Ein Makro wie println! oder vec! kann so viele Argumente annehmen, wie du willst.
  2. Code erzeugen: Ein Makro kann neuen Rust-Code schreiben, z. B. Strukturen oder Funktionen für dich definieren. Eine Funktion kann das nicht.
  3. Kompilierzeit: Makros kosten zur Laufzeit absolut keine Leistung. Der Code wird vorab generiert.

3. Dein erstes deklaratives Makro: macro_rules!

Deklarative Makros sind die am häufigsten genutzten Makros in Rust. Wir definieren sie mit dem Schlüsselwort macro_rules!.

Schauen wir uns ein einfaches Makro an, das uns eine Begrüßung ausgibt:

// Wir deklarieren das Makro mit dem Namen "begruesse"
macro_rules! begruesse {
    // 1. Muster: Wenn das Makro ohne Argumente gerufen wird: begruesse!()
    () => {
        println!("Hallo, mein Freund!");
    };

    // 2. Muster: Wenn das Makro mit einem Namen gerufen wird: begruesse!("Thorsten")
    // $name ist ein Platzhalter. :expr sagt, dass ein Ausdruck erwartet wird.
    ($name:expr) => {
        println!("Hallo, {}!", $name);
    };
}

fn main() {
    // Makros werden immer mit einem Ausrufezeichen (!) aufgerufen!
    begruesse!();          // Gibt aus: Hallo, mein Freund!
    begruesse!("Thorsten"); // Gibt aus: Hallo, Thorsten!
}

Der Aufbau der Platzhalter

In der Zeile ($name:expr) deklarieren wir eine Variable im Makro.

  • $name: Das Dollarzeichen sagt dem Compiler, dass dies eine Makro-Variable ist.
  • :expr: Dies ist der Fragment-Spezifizierer (Designator). Er sagt: “Hier erwarte ich einen Ausdruck (Expression), z. B. eine Zahl, einen String oder eine Berechnung.”

Ein weiterer wichtiger Designator ist :ident (Identifier). Er steht für einen Bezeichner, also den physischen Namen einer Variable, Struktur oder Funktion:

macro_rules! erstelle_variable {
    // Wir übergeben den NAMEN der neuen Variable ($name) und den WERT ($wert)
    ($name:ident, $wert:expr) => {
        let $name = $wert;
    };
}

fn main() {
    // Wir erstellen eine Variable namens 'temperatur' mit dem Wert 22
    erstelle_variable!(temperatur, 22);
    
    // Nun existiert die Variable 'temperatur' in unserem Code!
    println!("Temperatur: {}°C", temperatur);
}

4. Wiederholungen in Makros: Das eigene vec!

Oft möchtest du eine Liste von Elementen übergeben (wie bei vec![1, 2, 3]). Dazu bietet Rust eine Wiederholungs-Syntax:

$( ... ),* oder $( ... ),+

  • $( ... ): Der Teil, der wiederholt werden soll.
  • ,: Das Trennzeichen (hier ein Komma).
  • * oder +: * bedeutet “0-mal oder beliebig oft”, + bedeutet “mindestens 1-mal”.

Lass uns ein eigenes Makro schreiben, das Elemente in einen Vektor schiebt:

macro_rules! mein_vektor {
    // $( $x:expr ),* bedeutet:
    // Beliebig viele Ausdrücke ($x), getrennt durch Kommas.
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            // Dieser Block $( ... )* wird für jeden gematchten Ausdruck wiederholt!
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}

fn main() {
    let liste = mein_vektor![10, 20, 30];
    println!("Liste: {:?}", liste); // [10, 20, 30]
}

5. Makro-Hygiene: Schutz vor Variablen-Kollisionen

In vielen älteren Sprachen (wie C) sind Makros gefährlich, weil sie reiner Textaustausch sind. Wenn ein Makro intern eine Variable deklariert, die denselben Namen hat wie eine Variable in deiner main-Funktion, kommt es zu Fehlern.

In Rust sind Makros hygienisch. Das bedeutet, dass Variablen, die im Makro deklariert werden, in einem eigenen unsichtbaren Namensraum leben:

macro_rules! setze_geheimnis {
    () => {
        let x = 42; // Variable x im Makro deklariert
    };
}

fn main() {
    let x = 100;
    
    setze_geheimnis!();
    
    // Welches x wird hier gedruckt?
    println!("x ist: {}", x); // Gibt immer noch 100 aus!
}

Obwohl das Makro intern let x = 42 ausgeführt hat, bleibt das x in der main-Funktion unberührt. Der Compiler trennt die beiden Variablen strikt voneinander.


6. Compilerfehler-Show: Typische Fehler verstehen

Ein klassischer Fehler bei Anfängern betrifft die Reihenfolge der Definition.

fn main() {
    hallo!(); // Compilerfehler!
}

macro_rules! hallo {
    () => {
        println!("Hallo!");
    };
}

Die Fehlermeldung des Compilers:

error: cannot find macro `hallo` in this scope
 --> src/main.rs:2:5
  |
2 |     hallo!();
  |     ^^^^^

Die Erklärung:

Der Rust-Compiler liest Dateien von oben nach unten. Da Makros den Code zur Kompilierzeit manipulieren, müssen sie vor ihrer ersten Verwendung definiert sein.

Die Lösung: Schiebe die Definition des Makros über die main-Funktion!


7. Zusammenfassung

  1. Makros erzeugen Code zur Kompilierzeit und sparen uns lästige Schreibarbeit.
  2. macro_rules! definiert ein deklaratives Makro mit Pattern Matching.
  3. Designators bestimmen den Typ der Lücken: :expr für Ausdrücke, :ident für Bezeichner/Namen.
  4. Mit der Syntax $( ... ),* verarbeiten wir beliebig viele Argumente.
  5. Dank Makro-Hygiene kommen sich interne Makro-Variablen und dein restlicher Code niemals in die Quere.

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, block und tt.
  • 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 syn und quote.

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. i32 oder Vec<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.

Kapitel 17 - Hardware-Sicht: Makros unter der Lupe von AST, Lexer und Compiler

Hallo Thorsten! Nachdem wir uns mit der syntaktischen Abstraktion deklarativer Makros und der prozeduralen Transformation beschäftigt haben, reißen wir jetzt die Motorhaube auf und analysieren, wie Makros physisch während der Kompilierung verarbeitet werden.

Als Systemprogrammierer gibst du dich nicht mit der Erklärung „Es generiert Code“ zufrieden. Du willst wissen: In welcher Phase der Kompilierung werden Makros expandiert? Wie arbeitet der Compiler auf dem Abstract Syntax Tree (AST)? Und wie wird die Magie der Hygiene auf Bit-Ebene aufgelöst?

Schnapp dir einen Kaffee – wir steigen tief in die Compiler-Architektur ein!


1. Die Phasen der Kompilierung: Wo leben Makros?

Um zu verstehen, warum Makros zur Laufzeit auf der Hardware absolut null Performance-Kosten verursachen, müssen wir uns den Ablauf des Rust-Compilers (rustc) ansehen:

graph TD
    Code[1. Quellcode .rs] --> Lexer[2. Lexer / Tokenisierung]
    Lexer --> Parser[3. Parser / AST-Erstellung]
    Parser --> Expansion[4. Makro-Expansion]
    Expansion --> AST_Flat[5. Expandierter AST]
    AST_Flat --> TypeCheck[6. Typprüfung & Borrow Checker]
    TypeCheck --> MIR[7. Mid-level IR / Optimierung]
    MIR --> LLVM[8. LLVM-Codegenerierung]
    LLVM --> Binary[9. Binärdatei / Maschinencode]

Die Erkenntnis:

  • Frühe Expansion: Die Makro-Expansion findet in Phase 4 statt – direkt nach dem Einlesen und Parsen des Codes, aber noch vor der Typprüfung, dem Borrow Checker oder der Generierung von Zwischenrepräsentationen (MIR/HIR).
  • Kein Text-Ersatz: Im Gegensatz zum C-Präprozessor, der Dateien vor dem Kompilieren als reinen Text manipuliert, arbeitet der Rust-Compiler auf dem Abstract Syntax Tree (AST). Ein Makro erhält syntaktische Strukturen (Knoten des Baums) und fügt neue Äste in den Baum ein.
  • Null-Kosten-Garantie: Da nach der Makro-Expansion nur noch „flacher“ Standard-Rust-Code existiert, läuft die Optimierung (z. B. durch LLVM) genauso effizient ab, als hättest du den generierten Code manuell geschrieben.

2. Wie Makro-Hygiene auf Compiler-Ebene funktioniert

Wie schafft es der Compiler, Variablen in deklarativen Makros (wie unser x in der Analogie) so zu isolieren, dass sie sich nicht mit gleichnamigen Variablen am Aufrufort beißen?

Das Konzept basiert auf Syntax-Kontexten (Syntax Contexts): Jeder Bezeichner (Identifier) im AST von Rust besteht nicht nur aus seinem Namen als String (z. B. "x"), sondern aus einem Tupel:

Identifier = (Name, SyntaxContext)

  • SyntaxContext 0: Repräsentiert den normalen, handgeschriebenen Code.
  • SyntaxContext N: Jedes Mal, wenn ein Makro expandiert wird, erzeugt der Compiler einen neuen, eindeutigen Syntax-Kontext (eine ID).

Der Namensauflösungs-Schritt:

Wenn das Makro intern let x = 42; deklariert, speichert der AST: x_intern = ("x", SyntaxContext(42))

In deiner main-Funktion steht: x_main = ("x", SyntaxContext(0))

Obwohl beide Variablen "x" heißen, vergleicht der Compiler sie beim Auflösen der Namen anhand des gesamten Tupels. Da die Syntax-Kontexte unterschiedlich sind, werden sie als zwei völlig getrennte Speicherorte im Stack-Frame deklariert.


3. Der Compiler-Overhead prozeduraler Makros

Obwohl prozedurale Makros zur Laufzeit kostenlos sind, zahlen Sie den Preis dafür während der Kompilierzeit (Build-Performance).

Was passiert im Hintergrund?

Wenn Cargo ein Projekt mit prozeduralen Makros (z. B. serde oder tokio) baut:

  1. Der Compiler muss zuerst das Makro-Crate (proc-macro = true) vollständig zu einer dynamischen Bibliothek (.so, .dll oder .dylib) kompilieren.
  2. Dieses Bibliotheks-Crate wird dann in den laufenden Compilerprozess rustc geladen.
  3. Für jedes Element, das mit dem Makro annotiert ist, muss der Compiler den AST in einen TokenStream konvertieren, die Funktion des Makros aufrufen (was teuren Code-Parsing-Overhead in syn und Codegenerierung in quote bedeutet) und das Ergebnis zurückparsen.
  4. Dies erklärt, warum Bibliotheken mit exzessiver Makro-Nutzung die Compilezeiten dramatisch verlängern können.

4. Debugging auf AST-Ebene: cargo expand

Wenn Sie Fehler in komplexen Makros suchen, hilft der Compiler-Output oft nicht weiter, da die Zeilennummern auf den Code verweisen, der vor der Expansion existierte.

Das Tool cargo-expand zeigt Ihnen den Code an, nachdem der Compiler die Expansion (Phase 5 im Diagramm) abgeschlossen hat.

Verwendung:

Navigieren Sie in Ihr Projekt und führen Sie aus:

cargo expand

Sie sehen nun den nackten, expandierten Rust-Code, in dem alle Aufrufe von println!, vec! oder Ihren eigenen Makros durch das ersetzt wurden, was tatsächlich an LLVM und die CPU weitergereicht wird. Das ist das mächtigste Werkzeug für jeden Systemprogrammierer bei der Fehlersuche in Makros.


4. Verweis auf Übungen

Sie haben nun gelernt, wie deklarative und prozedurale Makros funktionieren und wie der Compiler diese auf AST-Ebene verarbeitet. Jetzt ist es an der Zeit, diese Konzepte praktisch anzuwenden.

Wechseln Sie in das Verzeichnis: exercises/04_collections/ (oder ein entsprechendes Makro-Verzeichnis Ihres Übungs-Workspaces).

Dort finden Sie praktische Aufgaben, bei denen Sie:

  1. Ein eigenes deklaratives Makro zur bequemen Initialisierung von Structs schreiben.
  2. Fragment-Spezifizierer reparieren müssen, um Syntax-Fehler zu beheben.
  3. Die Funktionsweise von Wiederholungsmustern ($(...),*) in der Praxis erproben.