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 07: Funktionen und Closures

In den bisherigen Kapiteln haben wir Variablen, Datentypen, Ownership und Datenstrukturen kennengelernt. Nun betreten wir ein weiteres Fundament jeder Programmiersprache: die Abstraktion von wiederverwendbarem Code. In Rust geschieht dies primär über Funktionen (fn) und Closures (anonyme Funktionen).

Auf den ersten Blick wirken Funktionen in Rust vertraut. Rust stellt jedoch durch sein exklusives Ownership- und Speichermodell besondere Anforderungen an Funktionsparameter, Rückgabewerte und Lebensdauern. Closures gehen noch einen Schritt weiter: Sie können Variablen aus ihrer Umgebung “einfangen” (capturing), was tiefgreifende Auswirkungen darauf hat, wie der Rust-Compiler sie im Speicher verwaltet.

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: Konzentriert sich auf Funktionen als Backrezepte, den Semikolon-Trick für Ausdrücke und die Funktionsweise von Closures (Miniköche) sowie deren Typen (Fn, FnMut, FnOnce) mittels Kühlschrank- und Koch-Analogien.
  • Für Profis: Behandelt Zustands-Kapselung mit Closures, die Trait-Hierarchie (Fn, FnMut, FnOnce), generische Lebensdauern und Varianz in Signaturen, compile-time Auswertung mittels const fn und statischen vs. dynamischen Dispatch.
  • Hardware-Sicht: Analysiert Calling Conventions (System V AMD64 ABI) auf x86_64, Stack Frames bei Funktionsaufrufen, das anonyme Struct-Speicherlayout von Closures (Capturing by ref vs. by value) und Inlining-Optimierungen durch LLVM.

Begleitvideo zu Kapitel 7: Funktionen & Closures


Kapitel 07 - Funktionen & Closures: Deine Backstube und die magischen Miniköche

Willkommen in deiner Programmier-Backstube! Bis jetzt haben wir in Rust gelernt, wie man Variablen erstellt, Daten speichert und Entscheidungen trifft. Aber wenn wir immer mehr Code schreiben, wird unser Programm schnell unübersichtlich. Stell dir vor, du müsstest jedes Mal, wenn du einen Kuchen backen willst, die komplette Anleitung von vorne aufschreiben. Das wäre extrem anstrengend!

In diesem Kapitel lernen wir zwei mächtige Werkzeuge kennen, die uns das Leben leichter machen:

  1. Funktionen – unsere festen Backrezepte.
  2. Closures – unsere magischen Miniköche, die sich flexibel anpassen können.

1. Was ist eine Funktion? (Die Analogie des Backrezepts)

Eine Funktion ist im Grunde nichts anderes als ein festes Backrezept, das an einer zentralen Stelle in deinem Backbuch steht. Jedes Mal, wenn du diesen bestimmten Kuchen backen möchtest, schlägst du einfach das Rezept auf und rufst: “Ofen an, backe Kuchen!”

Ein Rezept hat meistens drei Teile:

  1. Die Zutaten (Eingaben / Parameter): Was stecken wir in die Funktion hinein? (Zum Beispiel: Mehl, Eier, Zucker).
  2. Die Zubereitung (Der Rumpf der Funktion): Was passiert in der Küche? (Der Teig wird gerührt, der Ofen heizt).
  3. Das Ergebnis (Die Ausgabe / Rückgabewert): Was kommt am Ende heraus? (Ein leckerer Schokoladenkuchen).

So sieht ein Rezept in Rust aus

Lass uns ein echtes Backrezept in Rust-Code schreiben. Wir wollen eine Funktion bauen, die aus zwei Zutaten (Mehl in Gramm und Eier als Anzahl) einen Teig mischt.

// Das ist unser Backrezept (die Funktion)
fn mache_teig(mehl_gramm: i32, anzahl_eier: i32) -> String {
    println!("Mische {}g Mehl mit {} Eiern...", mehl_gramm, anzahl_eier);
    
    // Das ist das fertige Ergebnis, das wir zurückgeben
    let ergebnis = String::from("Ein klebriger Kuchenteig");
    ergebnis
}

fn main() {
    println!("Starten wir unsere Backstube!");
    
    // Hier rufen wir das Rezept auf und geben die Zutaten hinein
    let mein_teig = mache_teig(500, 4);
    
    println!("In unserer Schüssel liegt jetzt: {}", mein_teig);
}

Lass uns den Code Zeile für Zeile unter die Lupe nehmen:

  • fn mache_teig(...): Mit dem Wörtchen fn (kurz für function) sagen wir Rust: “Achtung, jetzt definiere ich ein neues Rezept!” Danach folgt der Name der Funktion: mache_teig. Wir schreiben Funktionsnamen in Rust immer in Kleinbuchstaben mit Unterstrichen (snake_case).
  • mehl_gramm: i32, anzahl_eier: i32: Das sind unsere Parameter (die Zutaten). In Rust müssen wir bei Funktionen immer ganz genau sagen, welchen Datentyp die Zutaten haben. i32 bedeutet eine ganze Zahl. Rust ist hier sehr streng, damit in der Küche nichts schiefgehen kann (wir wollen ja keine Schrauben statt Eier in den Teig werfen!).
  • -> String: Der Pfeil -> zeigt uns, was am Ende aus dem Ofen herauskommt (der Rückgabetyp). In diesem Fall gibt unsere Funktion einen Text (String) zurück.
  • Die geschweiften Klammern { ... }: Sie bilden den Arbeitsbereich unserer Küche (den Funktionskörper). Alles, was hier drin steht, wird ausgeführt, wenn wir die Funktion aufrufen.
  • let mein_teig = mache_teig(500, 4);: In der main-Funktion rufen wir unser Rezept auf. Wir übergeben die konkreten Werte 500 und 4 (das nennt man Argumente) und fangen das fertige Ergebnis in der Variablen mein_teig auf.

2. Der Semikolon-Trick: Ausdrücke vs. Anweisungen

Hast du dich in unserem Beispiel oben gewundert, warum in der Zeile ergebnis kein Semikolon ; am Ende steht? Das ist kein Tippfehler, sondern einer der wichtigsten Tricks in Rust!

Rust unterscheidet ganz streng zwischen zwei Dingen:

  1. Anweisungen (Statements): Sie tun etwas, geben aber nichts zurück. Sie enden immer mit einem Semikolon ;. Stell dir vor, du stellst eine Schüssel auf den Tisch. Das ist eine Aktion, aber es kommt kein neuer Wert dabei heraus.
  2. Ausdrücke (Expressions): Sie berechnen einen Wert und geben ihn zurück. Sie haben kein Semikolon ; am Ende. Stell dir vor, du reichst jemandem den fertigen Kuchen.

Die Analogie des Stoppschilds

  • Ein Semikolon ; wirkt wie ein Stoppschild für Werte. Es sagt Rust: “Führe diese Aktion aus, aber wirf den Wert danach weg!”
  • Wenn du das Semikolon in der letzten Zeile einer Funktion weglässt, wird diese Zeile zu einem Ausdruck. Rust nimmt das Ergebnis dieser Zeile und wirft es automatisch aus der Funktion heraus – direkt zu demjenigen, der die Funktion aufgerufen hat.

Lass uns das an einem ganz einfachen Beispiel anschauen:

#![allow(unused)]
fn main() {
fn addiere_fünf(zahl: i32) -> i32 {
    zahl + 5 // KEIN Semikolon! Das bedeutet: Gib das Ergebnis von (zahl + 5) zurück.
}
}

Was passiert, wenn wir aus Versehen ein Semikolon setzen?

#![allow(unused)]
fn main() {
// ACHTUNG: Das wird einen Compilerfehler erzeugen!
fn addiere_fünf_fehlerhaft(zahl: i32) -> i32 {
    zahl + 5; // HIER steht ein Semikolon!
}
}

Wenn du diesen Code kompilieren willst, schimpft der Rust-Compiler sofort mit dir:

error[E0308]: mismatched types
 --> src/main.rs:1:38
  |
1 | fn addiere_fünf_fehlerhaft(zahl: i32) -> i32 {
  |    -----------------------               ^^^ expected `i32`, found `()`
2 |     zahl + 5;
  |             - help: remove this semicolon to return this value

Was will uns der Compiler damit sagen? Durch das Semikolon am Ende von zahl + 5; hast du den Rückgabewert blockiert. Rust denkt nun, die Funktion gibt gar nichts zurück (den sogenannten Unit-Typ (), was man sich wie eine leere Schachtel vorstellen kann). Oben in der Signatur (-> i32) hast du aber versprochen, eine Zahl zurückzugeben. Der Compiler merkt, dass das Versprechen gebrochen wurde, und gibt dir direkt den Tipp: “Entferne dieses Semikolon, um diesen Wert zurückzugeben!”


3. Closures: Die magischen Miniköche

Jetzt wird es richtig spannend! Neben den festen Backrezepten (Funktionen) gibt es in Rust noch Closures (sprich: “Kloschurs”).

Eine Closure ist wie ein anonymer Minikoch, den du direkt an deiner Arbeitsplatte einstellst. Dieser Koch hat keinen festen Namen im Backbuch (deshalb nennt man sie auch anonyme Funktionen), aber er kann blitzschnell Aufgaben für dich erledigen.

Das Besondere an unserem Minikoch: Er kann sich einfach Zutaten schnappen, die schon auf der Arbeitsplatte herumstehen, selbst wenn sie gar nicht offiziell als Parameter an ihn übergeben wurden! Diesen Vorgang nennt man Capturing (Einfangen der Umgebung).

Die Syntax des Minikochs

Stell dir vor, die Parameter einer Closure sind wie die Hände des Kochs. Statt runden Klammern () benutzen wir bei Closures zwei gerade Striche || (das sieht ein bisschen aus wie ein kleiner Kühlergrill oder zwei Kochlöffel).

Hier ist ein einfaches Beispiel:

fn main() {
    // 1. Eine normale Variable auf unserer Küchenzeile
    let extra_zucker = 50; 

    // 2. Wir definieren unseren Minikoch (die Closure)
    // Er nimmt eine Zutat (mehl) entgegen und schnappt sich heimlich den extra_zucker!
    let minikoch = |mehl: i32| {
        println!("Ich mische {}g Mehl...", mehl);
        println!("Und ich nehme mir heimlich {}g Zucker von der Arbeitsplatte!", extra_zucker);
        mehl + extra_zucker
    };

    // 3. Wir lassen den Minikoch arbeiten
    let gesamtgewicht = minikoch(200);
    println!("Das Gesamtgewicht der Zutaten ist: {}g", gesamtgewicht);
}

Siehst du, wie der minikoch auf die Variable extra_zucker zugreifen konnte, obwohl wir sie ihm gar nicht beim Aufruf übergeben haben? Eine normale Funktion fn darf das niemals! Eine Funktion darf nur benutzen, was man ihr direkt als Argument hineinreicht. Der Minikoch (die Closure) dagegen hat ein gutes Gedächmisse und merkt sich die Umgebung, in der er erschaffen wurde.


4. Die drei Arten von Miniköchen (Die Essens-Analogie)

Weil Rust extrem vorsichtig mit dem Speicher deines Computers umgeht, muss der Compiler genau wissen, wie ein Minikoch mit den Zutaten aus der Umgebung umgeht. Es gibt drei Arten von Zugriffen, und Rust hat für jede Art einen eigenen Fachbegriff (einen sogenannten Trait).

Wir können uns diese drei Typen hervorragend mit einer Kühlschrank- und Essens-Analogie merken!

graph TD
    A[Die 3 Closure-Typen] --> B[Fn: Nur Gucken]
    A --> C[FnMut: Topf verrühren]
    A --> D[FnOnce: Aufessen]
    
    B --> B1["Kühlschrank ansehen<br>(Lesezugriff / &T)"]
    C --> C1["Zutaten verändern<br>(Schreibzugriff / &mut T)"]
    D --> D1["Zutat komplett essen<br>(Ownership / T)"]

1. Fn – Der “Gucker” (Nur ansehen)

Analogie: Der Minikoch macht die Kühlschranktür auf und schaut sich die Zutaten an. Er nimmt nichts heraus, er verändert nichts, er guckt einfach nur. Weil sich nichts ändert, können auch andere Köche gleichzeitig in den Kühlschrank schauen.

  • In Rust: Das ist ein Lesezugriff (&T). Die Umgebung wird nur ausgeliehen.
  • Häufigkeit: Da dies der friedlichste Zugriff ist, kann diese Closure beliebig oft aufgerufen werden.
fn main() {
    let rezept_name = String::from("Apfelkuchen");

    // Der Minikoch liest nur die Variable 'rezept_name'
    let zeige_rezept = || {
        println!("Ich lese das Rezept für: {}", rezept_name);
    };

    // Wir können ihn mehrmals aufrufen!
    zeige_rezept();
    zeige_rezept();
    
    // Die Variable 'rezept_name' ist danach immer noch da und benutzbar
    println!("Wir lieben {}", rezept_name);
}

2. FnMut – Der “Rührer” (Verändern / Mutable)

Analogie: Der Minikoch nimmt einen Kochlöffel und verrührt die Zutaten im Topf. Er fügt Gewürze hinzu und verändert den Zustand des Essens. Die Zutaten bleiben in der Küche, aber sie sehen danach anders aus als vorher.

  • In Rust: Das ist ein veränderbarer Lesezugriff (&mut T). Die Closure verändert Variablen aus ihrer Umgebung.
  • Wichtig: Weil sich Dinge ändern, muss die Closure selbst als veränderbar (mut) markiert werden.
fn main() {
    let mut anzahl_kekse = 10;

    // Der Minikoch verändert 'anzahl_kekse' direkt auf der Arbeitsplatte
    // Weil er etwas verändert, müssen wir 'mut keks_dieb' schreiben!
    let mut keks_dieb = || {
        anzahl_kekse -= 1; // Ein Keks wird stibitzt!
        println!("Mampf! Es sind nur noch {} Kekse da.", anzahl_kekse);
    };

    keks_dieb();
    keks_dieb();

    // Am Ende hat sich der Wert der Originalvariable verändert:
    println!("In der Keksbox sind am Ende: {} Kekse.", anzahl_kekse); // 8
}

3. FnOnce – Der “Vielfraß” (Aufessen)

Analogie: Der Minikoch schnappt sich eine exklusive, seltene Zutat (zum Beispiel eine goldene Erdbeere) und isst sie komplett auf. Die Erdbeere ist danach weg! Sie existiert nicht mehr. Weil die Zutat weg ist, kann der Koch dieses Rezept nur ein einziges Mal ausführen. Wenn er es ein zweites Mal versuchen würde, gäbe es keine Erdbeere mehr zum Essen.

  • In Rust: Die Closure übernimmt den Besitz (Ownership) der Variable (T).
  • Wichtig: Diese Closure kann nur ein einziges Mal aufgerufen werden (daher der Name Once = einmal).
fn main() {
    // Eine Zutat, die nicht kopiert werden kann (ein String auf dem Heap)
    let seltene_erdbeere = String::from("Goldene Erdbeere");

    // Der Minikoch verbraucht die Erdbeere (er nimmt das Ownership)
    // Das 'move'-Schlüsselwort zwingt die Closure dazu, die Zutat komplett einzusacken.
    let erdbeer_esser = move || {
        println!("Ich esse die {} auf! Mmh, lecker!", seltene_erdbeere);
        // Hier endet das Leben der seltenen Erdbeere, sie wird zerstört (dropped)
    };

    // Wir rufen die Closure auf
    erdbeer_esser();

    // Wenn wir versuchen würden, 'erdbeer_esser()' ein zweites Mal aufzurufen,
    // würde uns Rust einen Fehler melden, da die Erdbeere bereits gegessen wurde!
    
    // Auch hier können wir nicht mehr auf die Erdbeere zugreifen:
    // println!("{}", seltene_erdbeere); // FEHLER! Erdbeere existiert nicht mehr.
}

5. Typische Stolpersteine und Compilerfehler

Der Rust-Compiler ist wie ein sehr genauer Küchenchef. Er passt auf, dass kein Chaos entsteht. Lass uns zwei typische Fehler anschauen, die Anfängern oft passieren, und lernen, wie wir sie beheben.

Fehler 1: Der doppelte Diebstahl (FnOnce mehrfach aufrufen)

Stell dir vor, du versuchst, den “Vielfraß”-Koch zweimal nacheinander essen zu lassen:

// ACHTUNG: Dieser Code kompiliert nicht!
fn main() {
    let zutat = String::from("Schokolade");
    
    let koch = move || {
        let _aufgegessen = zutat; // Hier wandert die Zutat in den Koch
        println!("Schokolade gegessen!");
    };
    
    koch(); 
    koch(); // FEHLER! Wir rufen den Koch ein zweites Mal auf
}

Der Compiler wird dir folgendes sagen:

error[E0382]: use of moved value: `koch`
  --> src/main.rs:11:5
   |
10 |     koch();
   |     ------ `koch` moved due to this call
11 |     koch();
   |     ^^^^ value used here after move

Die Lösung: Wenn eine Closure Ownership übernimmt (durch move oder weil sie die Variable im Inneren verbraucht), darfst du sie nicht mehrmals aufrufen. Wenn du den Code mehrmals ausführen willst, darfst du die Zutat im Inneren nicht aufbrauchen, sondern solltest sie nur als Referenz (&zutat) ausleihen!

Fehler 2: Das vergessene Semikolon bei Funktionen ohne Rückgabe

Manchmal schreiben wir eine funktion, die einfach nur etwas auf dem Bildschirm ausgeben soll, setzen aber aus Versehen am Ende keinen Wert oder bauen verwirrende Semikolons ein:

#![allow(unused)]
fn main() {
// Was ist hier falsch?
fn begruessung() -> String {
    println!("Hallo in der Backstube!");
    // Huch, wo ist der Rückgabewert?
}
}

Hier hast du versprochen, einen String zurückzugeben (-> String), hast aber gar keinen String am Ende der Funktion hingeschrieben. Die Lösung: Entweder entfernst du das -> String, weil die Funktion gar nichts zurückgeben muss:

#![allow(unused)]
fn main() {
fn begruessung() { // Kein Pfeil nötig!
    println!("Hallo in der Backstube!");
}
}

Oder du gibst tatsächlich einen String zurück:

#![allow(unused)]
fn main() {
fn begruessung() -> String {
    println!("Hallo in der Backstube!");
    String::from("Hallo!") // Ohne Semikolon!
}
}

Zusammenfassung für deine Kochmütze

  • Funktionen (fn) sind wie feste, beschriftete Rezepte im Backbuch. Sie können keine Variablen aus ihrer Umgebung einfach so mopsen.
  • Ausdrücke (ohne ;) geben Werte zurück; Anweisungen (mit ;) tun nur etwas und blockieren die Rückgabe.
  • Closures (|| {}) sind Miniköche auf Abruf, die sich Variablen von der Arbeitsplatte schnappen können.
  • Es gibt drei Closure-Typen:
    • Fn: Schaut sich die Zutaten nur an (Lesezugriff).
    • FnMut: Verrührt und verändert die Zutaten (Schreibzugriff).
    • FnOnce: Isst die Zutaten komplett auf (Besitz/Ownership wird verbraucht, nur 1x ausführbar).

Herzlichen Glückwunsch! Du hast jetzt das Rüstzeug, um deine eigenen Programme modular und übersichtlich zu gestalten. Schnapp dir deine Kochschürze und probiere die Übungen im nächsten Abschnitt aus!


Kapitel 07 (Fortgeschritten): Fortgeschrittene Funktionsarchitektur, Closures und Lifetime-Varianz

Willkommen im Profi-Bereich von Kapitel 7! Dieser Abschnitt richtet sich an Entwickler, die Rust auf System- und Bibliotheksebene einsetzen. Wenn Sie wiederverwendbare APIs entwerfen, hochperformanten Code schreiben oder komplexe Datenflüsse strukturieren, reicht das grundlegende Verständnis von Funktionen nicht aus.

In diesem Kapitel tauchen wir tief in die Mechanik von Closures ein, entschlüsseln die Trait-Hierarchie des Compilers, bändigen komplexe Lebensdauer-Beziehungen (Lifetimes) und optimieren die Performance durch statischen Dispatch und Compile-Time-Auswertungen.


Item 16: Nutze Closures zur Kapselung von lokalem Zustand und Verhaltensparametrisierung

Closures (in anderen Sprachen auch Lambdas oder anonyme Funktionen genannt) sind in Rust weit mehr als nur syntaktischer Zucker für Funktionszeiger. Sie sind die primäre Methode, um Verhalten zur Laufzeit mit Daten zu verknüpfen, ohne explizit eigene Strukturen (Structs) definieren zu müssen.

Die Alltagsanalogie: Der Rucksack

Stellen Sie sich eine normale Funktion vor wie einen Handwerker, der nur mit dem Werkzeug arbeiten kann, das sich bereits in der Werkstatt befindet (seine Parameter) oder das global verfügbar ist. Eine Closure hingegen ist wie ein Wanderer mit einem Rucksack. Bevor der Wanderer die Werkstatt verlässt, packt er ausgewählte Gegenstände aus der Umgebung in seinen Rucksack (er “capturt” Variablen aus dem aktuellen Gültigkeitsbereich). Überall, wo der Wanderer später hingeht, hat er Zugriff auf diesen Rucksack und kann dessen Inhalt lesen, verändern oder sogar aufbrauchen.

Wie Rust Closures im Hintergrund übersetzt

Um zu verstehen, wie Closures arbeiten, müssen wir den Schleier des Compilers lüften. Wenn Sie eine Closure schreiben:

#![allow(unused)]
fn main() {
let offset = 10;
let add_offset = |x: i32| x + offset;
}

erzeugt der Rust-Compiler im Hintergrund eine anonyme Struktur und implementiert für sie einen der Closure-Traits (Fn, FnMut oder FnOnce):

#![allow(unused)]
fn main() {
// Pseudocode der Compiler-Generierung:
struct __AnonymeClosure<'a> {
    offset: &'a i32, // Referenz auf den umgebenden Scope
}

impl<'a> Fn<(i32,)> for __AnonymeClosure<'a> {
    extern "rust-call" fn call(&self, args: (i32,)) -> i32 {
        args.0 + *self.offset
    }
}
}

Rust analysiert den Body der Closure und entscheidet automatisch, wie die Umgebungsvariablen erfasst werden:

  1. Als unveränderliche Referenz (&T): Wenn der Body die Variable nur liest.
  2. Als veränderliche Referenz (&mut T): Wenn der Body die Variable verändert.
  3. Durch Wertübergabe (Ownership-Transfer, T): Wenn der Body die Variable konsumiert (z. B. durch Übergabe an eine andere Funktion, die Ownership verlangt) oder wenn das Schlüsselwort move erzwungen wird.

Praxisbeispiel: Kapselung in einem Event-System

Das folgende vollständige und kompilierbare Beispiel zeigt, wie Closures verwendet werden, um eine zustandsbehaftete Filterung von Transaktionen durchzuführen, ohne den Filterzustand global speichern zu müssen.

/// Eine Struktur, die Finanztransaktionen repräsentiert.
#[derive(Debug, Clone)]
pub struct Transaction {
    pub id: u64,
    pub amount: f64,
    pub category: String,
}

/// Ein Prozessor, der Transaktionen filtert und verarbeitet.
pub struct TransactionProcessor {
    transactions: Vec<Transaction>,
}

impl TransactionProcessor {
    /// Erstellt einen neuen Prozessor mit einigen Standarddaten.
    pub fn new(transactions: Vec<Transaction>) -> Self {
        Self { transactions }
    }

    /// Filtert Transaktionen basierend auf einer benutzerdefinierten Bedingung (Closure).
    /// Wir nutzen hier statischen Dispatch (`impl Fn`), um maximale Performance zu sichern.
    pub fn filter_transactions<F>(&self, filter_rule: F) -> Vec<Transaction>
    where
        F: Fn(&Transaction) -> bool,
    {
        self.transactions
            .iter()
            .filter(|tx| filter_rule(tx))
            .cloned()
            .collect()
    }
}

fn main() {
    let dataset = vec![
        Transaction { id: 1, amount: 150.50, category: String::from("Software") },
        Transaction { id: 2, amount: 45.00, category: String::from("Bücher") },
        Transaction { id: 3, amount: 1200.00, category: String::from("Hardware") },
    ];

    let processor = TransactionProcessor::new(dataset);

    // Lokaler Zustand, den wir in die Closure einbinden wollen
    let budget_limit = 100.00;
    let target_category = String::from("Software");

    // Die Closure kapselt `budget_limit` und `target_category` per Referenz.
    // Dies entspricht dem automatischen Capturing von `&T`.
    let is_expensive_software = |tx: &Transaction| {
        tx.amount > budget_limit && tx.category == target_category
    };

    let matches = processor.filter_transactions(is_expensive_software);

    println!("Gefundene Transaktionen: {:?}", matches);
}

Schritt-für-Schritt-Code-Erklärung:

  • Zeilen 4–8: Wir definieren die Struktur Transaction. Sie leitet Clone ab, um die Rückgabe gefilterter Listen zu vereinfachen.
  • Zeilen 22–31: Die Methode filter_transactions akzeptiert einen generischen Parameter F, der an das Trait-Bound Fn(&Transaction) -> bool gebunden ist. Da es sich um ein Fn-Bound handelt, darf die Closure beliebig oft aufgerufen werden, ohne ihren eigenen Zustand zu zerstören oder zu verändern.
  • Zeile 44–47: Die Closure is_expensive_software greift auf budget_limit und target_category aus dem übergeordneten Frame von main zu. Rust erkennt dies und speichert im generierten Compiler-Struct Referenzen auf diese beiden Variablen.

Typischer Compilerfehler: Dangling References durch asynchronen Transfer

Ein häufiger Fehler tritt auf, wenn Closures an Threads übergeben oder aus einer Funktion zurückgegeben werden, die lokalen Variablen jedoch am Ende des aktuellen Scopes zerstört werden.

#![allow(unused)]
fn main() {
// FEHLERHAFTER CODE:
fn spawn_transaction_logger(limit: f64) -> impl Fn() {
    // Der Compiler weigert sich, diese Closure zurückzugeben.
    // Warum? Weil `limit` auf dem Stack liegt und am Ende dieser Funktion stirbt.
    // Die Closure würde eine ungültige Referenz (Dangling Pointer) auf `limit` halten.
    || println!("Limit beträgt: {}", limit)
}
}

Wenn Sie versuchen, diesen Code zu kompilieren, gibt der Compiler folgende Fehlermeldung aus:

error[E0373]: closure may outlive the current function, but it borrows `limit`, which is owned by the current function
  --> src/main.rs:5:5
   |
5  |     || println!("Limit beträgt: {}", limit)
   |     ^^                               ----- `limit` is borrowed here
   |     |
   |     may outlive borrowed value `limit`
   |
help: to force the closure to take ownership of `limit` (and any other referenced variables), use the `move` keyword
   |
5  |     move || println!("Limit beträgt: {}", limit)
   |     ++++

Die Behebung:

Wir müssen dem Compiler mitteilen, dass die Closure den Besitz der erfassten Variablen übernehmen soll. Dies geschieht über das Schlüsselwort move:

#![allow(unused)]
fn main() {
// KORREKTER CODE:
fn spawn_transaction_logger(limit: f64) -> impl Fn() {
    // Durch `move` wird `limit` per Wert (Kopie, da f64 Copy ist) in die Closure verschoben.
    move || println!("Limit beträgt: {}", limit)
}
}

Item 17: Verstehe die Trait-Hierarchie von Fn, FnMut und FnOnce

Rust unterscheidet Closures anhand der Art und Weise, wie sie auf ihre erfassten Werte zugreifen. Es gibt drei Kern-Traits in der Standardbibliothek:

  1. FnOnce: Konsumiert die erfassten Variablen. Die Closure kann nur ein einziges Mal aufgerufen werden, da sie beim Aufruf Ownership der erfassten Werte übernimmt.
  2. FnMut: Kann die erfassten Variablen verändern. Sie kann mehrfach aufgerufen werden, benötigt aber exklusiven (veränderlichen) Zugriff auf sich selbst (&mut self).
  3. Fn: Liest die erfassten Variablen nur unveränderlich. Sie kann mehrfach und parallel aufgerufen werden (&self).

Die Trait-Hierarchie und Vererbung

In Rust sind diese drei Traits hierarchisch miteinander verknüpft:

classDiagram
    FnOnce <|-- FnMut : impliziert
    FnMut <|-- Fn : impliziert
    
    class FnOnce {
        +call_once(self)
    }
    class FnMut {
        +call_mut(&mut self)
    }
    class Fn {
        +call(&self)
    }

Das bedeutet konkret:

  • Jede Closure, die Fn implementiert, implementiert auch automatisch FnMut und FnOnce.
  • Jede Closure, die FnMut implementiert, implementiert auch automatisch FnOnce.
  • Aber: Eine Closure, die nur FnOnce implementiert, ist weder FnMut noch Fn.

Warum ist das logisch? Wenn eine Closure in der Lage ist, ihre Arbeit zu erledigen, ohne Werte zu konsumieren oder zu verändern (Fn), kann sie logischerweise auch aufgerufen werden, wenn man ihr veränderlichen Zugriff gewährt (FnMut) oder wenn man sie nur einmal ausführt und danach verwirft (FnOnce). Das Spezifische schließt das Allgemeine ein.

Die Alltagsanalogien für die drei Stufen:

  • Fn (Das Bibliotheksbuch): Sie können ein Buch im Lesesaal beliebig oft aufschlagen und lesen. Mehrere Personen können gleichzeitig hineinschauen. Es verändert sich nichts.
  • FnMut (Das Notizbuch): Sie dürfen Einträge hinzufügen oder überschreiben. Das Buch ist danach in einem anderen Zustand. Sie können dies beliebig oft tun, aber es darf immer nur eine Person gleichzeitig schreiben (Borrow-Checker-Garantie für &mut self).
  • FnOnce (Die Silvesterrakete): Sie können sie nur ein einziges Mal anzünden. Beim Start wird die Rakete physikalisch verbrannt (konsumiert). Danach existiert sie nicht mehr.

Praxisbeispiel: Demonstration der drei Typen

Das folgende Beispiel verdeutlicht die unterschiedlichen Anforderungen an den Aufrufer und die Syntax der Implementierung.

/// Funktion, die eine einmalige Operation ausführt (FnOnce)
fn run_once<F>(f: F)
where
    F: FnOnce(),
{
    f(); // Konsumiert die Closure. Ein zweiter Aufruf `f()` hier wäre ein Compilerfehler!
}

/// Funktion, die eine mutierende Operation mehrfach ausführen kann (FnMut)
fn run_mut_twice<F>(mut f: F)
where
    F: FnMut(),
{
    f(); // Erster Aufruf (Zustand mutiert)
    f(); // Zweiter Aufruf (Zustand mutiert erneut)
}

/// Funktion, die eine reine Lese-Operation beliebig oft ausführen kann (Fn)
fn run_pure_thrice<F>(f: F)
where
    F: Fn(),
{
    f();
    f();
    f();
}

fn main() {
    // --- 1. FnOnce Demonstration ---
    let consumption_target = String::from("Wichtige Ressource");
    // Diese Closure verbraucht `consumption_target`, indem sie Ownership übernimmt.
    let closure_once = move || {
        let _temp = consumption_target; // Ownership geht an `_temp` und stirbt hier.
        println!("Ressource erfolgreich verbraucht!");
    };
    run_once(closure_once);

    // --- 2. FnMut Demonstration ---
    let mut counter = 0;
    // Diese Closure mutiert den äußeren Zustand `counter`.
    let closure_mut = || {
        counter += 1;
        println!("Zähler erhöht auf: {}", counter);
    };
    run_mut_twice(closure_mut);

    // --- 3. Fn Demonstration ---
    let value = 42;
    // Diese Closure liest nur `value` über eine unveränderliche Referenz.
    let closure_pure = || {
        println!("Wert gelesen: {}", value);
    };
    run_pure_thrice(closure_pure);
}

Typischer Compilerfehler: Mehrfachnutzung einer FnOnce-Closure

Ein klassischer Fehler für Fortgeschrittene besteht darin, eine Closure, die Ownership abgibt, mehrfach aufzurufen oder innerhalb eines Fn-Kontexts zu verwenden.

#![allow(unused)]
fn main() {
// FEHLERHAFTER CODE:
fn execute_twice<F>(f: F)
where
    F: FnOnce(), // Wir deklarieren, dass wir eine FnOnce erwarten
{
    f();
    f(); // FEHLER: f wurde bereits im ersten Aufruf konsumiert!
}
}

Der Compiler weist uns unmissverständlich darauf hin:

error[E0382]: use of moved value: `f`
 --> src/main.rs:6:5
  |
2 | fn execute_twice<F>(f: F)
  |                     - move occurs because `f` has type `F`, which does not implement the `Copy` trait
...
5 |     f();
  |     --- `f` called, moving it
6 |     f();
  |     ^ value used here after move

Die Behebung:

Überlegen Sie genau, welche Bedingungen Ihre API stellt. Wenn ein Callback mehrfach ausgeführt werden muss, darf das Bound nicht FnOnce sein, sondern muss auf FnMut oder Fn angehoben werden. Die übergebene Closure darf dann keine Werte aus ihrem Scope herausbewegen (konsumieren).


Item 18: Beherrsche generische Lebensdauern, Lifetime Bounds und das Konzept der Varianz in Funktionen

Wenn Sie Funktionen entwerfen, die Referenzen entgegennehmen und zurückgeben, müssen Sie dem Compiler mitteilen, wie lange diese Referenzen gültig sein müssen. Dies geschieht über generische Lebensdauern (Lifetimes).

Generische Lebensdauern und Lifetime Bounds

Ein Lifetime Bound der Form 'a: 'b (gelesen als: “'a outlives 'b” / “'a überlebt 'b”) besagt, dass die Lebensdauer 'a mindestens so lange existieren muss wie 'b.

Die Alltagsanalogie: Der Mietvertrag

Denken Sie an einen Hauptmieter und einen Untermieter. Der Mietvertrag des Hauptmieters hat die Lebensdauer 'a. Der Untermietvertrag hat die Lebensdauer 'b. Damit der Untermietvertrag legal ist, muss der Hauptmietvertrag mindestens genauso lange laufen wie der Untermietvertrag. Es gilt: 'a: 'b (Hauptmieter 'a überlebt Untermieter 'b). Endet der Hauptmietvertrag früher, sitzt der Untermieter auf der Straße (Dangling Pointer!).

#![allow(unused)]
fn main() {
// Ein Beispiel für Lifetime Bounds in einer Funktion:
pub fn select_longer_lifetime<'a, 'b>(x: &'a str, y: &'b str) -> &'b str
where
    'a: 'b, // 'a muss mindestens so lange leben wie 'b.
{
    // Da 'a mindestens so lange lebt wie 'b, können wir sicher 'x' (das &'a str ist)
    // auf die kürzere Lebensdauer 'b "herabstufen" (Kovarianz!) und zurückgeben.
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
}

Das fortgeschrittene Konzept: Varianz

Varianz beschreibt, wie die Subtyp-Beziehung von Typen sich auf die Subtyp-Beziehung von komplexeren Typen auswirkt, die diese Typen enthalten. In Rust gibt es zwar keine Klassenvererbung, aber Lifetimes bilden eine Subtyp-Hierarchie:

  • Wenn eine Lebensdauer 'a länger lebt als 'b ('a: 'b), dann ist 'a ein Subtyp von 'b (geschrieben: 'a <: 'b). Das bedeutet: Eine längere Lebensdauer kann überall dort eingesetzt werden, wo eine kürzere erwartet wird.

Es gibt drei Arten von Varianz in Rust:

VarianztypDefinitionBeispiel in Rust
Kovarianz (Covariant)Wenn 'a <: 'b, dann gilt auch F<'a> <: F<'b>Unveränderliche Referenz &'a T
Kontravarianz (Contravariant)Wenn 'a <: 'b, dann gilt F<'b> <: F<'a> (Beziehung dreht sich um)Funktionsargumente
Invarianz (Invariant)Keine Beziehung zwischen F<'a> und F<'b> möglichVeränderliche Referenz &mut T

Warum veränderliche Referenzen &mut T invariant sein müssen

Stellen wir uns vor, veränderliche Referenzen wären kovariant. Das würde bedeuten, wir könnten ein &mut &'a str (wobei 'a sehr lange lebt, z.B. 'static) als ein &mut &'b str (wobei 'b sehr kurz lebt) behandeln. Dies würde es uns erlauben, eine kurzlebige Referenz in eine Variable zu schreiben, die eigentlich eine langlebige Referenz erwartet.

Praxisbeispiel: Warum Invarianz uns vor Speicherfehlern schützt

Das folgende Codebeispiel zeigt, wie Rusts Invarianz bei veränderlichen Referenzen verhindert, dass wir versehentlich Speicher korrumpieren.

fn overwrite_reference<'a>(destination: &mut &'a str, source: &'a str) {
    *destination = source;
}

fn main() {
    let mut static_string: &'static str = "Ich bin statisch und lebe ewig.";
    
    {
        let short_lived_string = String::from("Ich lebe nur kurz.");
        
        // Versuchen wir, die Adresse von `static_string` an eine Funktion zu übergeben,
        // die ihre Lebensdauer herabstuft, um die kurzlebige Referenz hineinzuschreiben.
        // `destination` hat den Typ `&mut &'static str`.
        // Wenn &mut T kovariant wäre, könnten wir dies als &mut &'b str aufrufen.
        overwrite_reference(&mut static_string, &short_lived_string);
    } // `short_lived_string` wird hier gelöscht!

    // Wäre das obige erlaubt, würde `static_string` nun auf gelöschten Speicher zeigen!
    println!("Inhalt von static_string: {}", static_string);
}

Der Compilerfehler:

Wenn Sie versuchen, diesen Code zu kompilieren, greift der Borrow Checker sofort ein und lehnt das Programm ab:

error[E0597]: `short_lived_string` does not live long enough
  --> src/main.rs:15:49
   |
7  |     let mut static_string: &'static str = "Ich bin statisch und lebe ewig.";
   |                            ------------ type annotation requires that `short_lived_string` is borrowed for `'static`
...
10 |         let short_lived_string = String::from("Ich lebe nur kurz.");
   |             ------------------ binding `short_lived_string` declared here
...
15 |         overwrite_reference(&mut static_string, &short_lived_string);
   |                                                 ^^^^^^^^^^^^^^^^^^^ borrowed value does not live long enough
16 |     } // `short_lived_string` wird hier gelöscht!
   |     - `short_lived_string` dropped here while still borrowed

Erklärung des Fehlers:

Weil &mut T invariant bezüglich T ist, kann der Typ &mut &'static str nicht auf &mut &'a str (mit einer kürzeren Lebensdauer) gecastet werden. Beide Typen müssen exakt übereinstimmen. Da static_string jedoch die Lebensdauer 'static besitzt, zwingt der Compiler auch das Argument source dazu, 'static zu sein. Da short_lived_string dies nicht erfüllt, schlägt die Kompilierung fehl. Rust hat somit erfolgreich einen “Use-After-Free”-Laufzeitfehler verhindert!


Item 19: Optimiere die Performance durch Compile-Time-Auswertung mit const fn

Eine der mächtigsten Optimierungsmethoden in modernem Rust ist die Verlagerung von Berechnungen aus der Laufzeit (Runtime) in die Kompilierzeit (Compile-time). Dies geschieht mithilfe von const fn.

Was ist eine const fn?

Eine const fn ist eine Funktion, die vom Compiler direkt während des Build-Prozesses interpretiert werden kann. Wenn eine solche Funktion mit konstanten Argumenten aufgerufen wird, berechnet der Compiler das Ergebnis vorab und setzt den fertigen Wert direkt in die Binärdatei ein. Wird dieselbe Funktion jedoch zur Laufzeit mit dynamischen Werten aufgerufen, verhält sie sich wie eine ganz normale, reguläre Funktion. Sie erhalten also zwei Funktionen zum Preis von einer – ohne jeglichen Overhead!

Die Alltagsanalogie: Der Bäcker und die Backmischung

Stellen Sie sich vor, Sie betreiben eine Bäckerei.

  • Laufzeit-Berechnung (Runtime): Ein Kunde kommt rein, bestellt ein Brot, und Sie fangen erst an, das Mehl abzuwiegen, den Teig zu kneten und das Brot zu backen. Der Kunde muss warten (Laufzeit-Latenz).
  • Compile-Time-Berechnung (const fn): Sie wiegen das Mehl ab und mischen die Zutaten bereits am Vorabend in Ruhe zusammen. Am Morgen müssen Sie die Mischung nur noch in den Ofen schieben. Die Arbeit wurde vorab erledigt, die Auslieferung erfolgt sofort (Null Wartezeit für den Kunden).

Praxisbeispiel: Lookup-Table zur Kompilierzeit generieren

Ein typischer Anwendungsfall für const fn ist das Berechnen von Lookup-Tables (Nachschlagetabellen) für mathematische Funktionen oder Verschlüsselungs-Algorithmen.

/// Berechnet den FNV-1a non-cryptographic Hash eines Strings zur Kompilierzeit.
/// Dies ermöglicht es uns, String-Hashes ohne Laufzeitkosten zu vergleichen.
pub const fn fnv1a_hash(s: &str) -> u64 {
    let bytes = s.as_bytes();
    let mut hash = 0xcbf29ce484222325; // FNV-Offset-Basis
    let prime = 0x100000001b3;          // FNV-Primzahl
    
    let mut i = 0;
    while i < bytes.len() {
        hash ^= bytes[i] as u64;
        hash = hash.wrapping_mul(prime);
        i += 1;
    }
    
    hash
}

// Wir initialisieren eine globale Konstante zur Kompilierzeit.
// Die Funktion `fnv1a_hash` wird komplett vom Compiler ausgeführt!
const DATABASE_KEY_HASH: u64 = fnv1a_hash("BenutzerDatenKey_2026");

fn main() {
    let input = "BenutzerDatenKey_2026";
    
    // Dieser Vergleich ist extrem schnell, da `DATABASE_KEY_HASH` ein nackter u64-Literal in der Binärdatei ist
    // und der Hash von `input` zur Laufzeit berechnet wird (oder ebenfalls optimiert wird).
    if fnv1a_hash(input) == DATABASE_KEY_HASH {
        println!("Zugriff gewährt! Hash: {:x}", DATABASE_KEY_HASH);
    } else {
        println!("Zugriff verweigert!");
    }
}

Schritt-für-Schritt-Code-Erklärung:

  • Zeile 3: Die Funktion fnv1a_hash wird mit dem Schlüsselwort const deklariert.
  • Zeilen 9–14: In einer const fn sind reguläre Kontrollstrukturen wie while-Schleifen, if-Abfragen und Zuweisungen uneingeschränkt erlaubt. (Einschränkungen betreffen vor allem dynamischen Dispatch, Heap-Allokationen oder I/O-Operationen, da diese zur Kompilierzeit physikalisch nicht existieren).
  • Zeile 20: DATABASE_KEY_HASH wird als const definiert. Der Wert wird während des Kompilierens berechnet. In der fertigen Binärdatei steht an dieser Stelle nur noch die berechnete Zahl 12984501254388147237 (oder der entsprechende FNV-Hash). Es findet kein String-Parsing oder Schleifendurchlauf zur Laufzeit statt!

Typischer Compilerfehler: Verletzung der deterministischen Kompilierung

Da const fn zur Kompilierzeit ausgeführt wird, darf sie keine Operationen enthalten, deren Ergebnis vom Systemzustand zur Laufzeit abhängt oder die nicht deterministisch sind (z. B. Speicherallokation auf dem Heap, Systemzeit abfragen oder Netzwerkanfragen).

#![allow(unused)]
fn main() {
// FEHLERHAFTER CODE:
const fn get_system_time_hash() -> u64 {
    // FEHLER: std::time::SystemTime ist zur Kompilierzeit nicht verfügbar!
    let now = std::time::SystemTime::now(); 
    42
}
}

Der Compiler bricht sofort ab:

error[E0015]: cannot call non-const fn `SystemTime::now` in constant functions
 --> src/main.rs:3:15
  |
3 |     let now = std::time::SystemTime::now();
  |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: calls in constant functions are limited to constant functions, tuple structs and tuple variants

Die Behebung:

Halten Sie const fn rein und frei von jeglichen Nebeneffekten. Sie dürfen nur Berechnungen auf den übergebenen Argumenten ausführen. Wenn Sie Plattform- oder Laufzeitdaten benötigen, müssen Sie diese Berechnungen in normale, nicht-konstante Funktionen auslagern.


Item 20: Wäge ab zwischen statischem Dispatch (impl Fn) und dynamischem Dispatch (Box<dyn Fn>) bei Closures

Wenn Sie Closures als Parameter an Funktionen übergeben oder als Rückgabewerte definieren, haben Sie die Wahl zwischen zwei grundlegend verschiedenen Dispatch-Mechanismen: statischem und dynamischem Dispatch.

Statisch:  [Aufrufer] -------> [Spezifische Monomorphisierte Funktion] (Inlined!)
Dynamisch: [Aufrufer] -------> [Box-Zeiger] -------> [vtable (Virtuelle Tabelle)] -------> [Ziel-Closure]

1. Statischer Dispatch (impl Fn / Generics)

Der Compiler nutzt standardmäßig den statischen Dispatch über Generics. Er analysiert jede Stelle, an der die Funktion aufgerufen wird, und generiert für jede übergebene Closure-Definition eine eigene Kopie des Maschinencodes. Dieser Prozess heißt Monomorphisierung.

  • Vorteile:
    • Maximale Performance: Da der Compiler den genauen Typ der Closure kennt, kann er den Aufruf oft direkt inlinen (den Funktionsaufruf durch den eigentlichen Code ersetzen). Es gibt keinen Laufzeit-Overhead.
  • Nachteile:
    • Code Bloat: Wenn Sie dieselbe Funktion mit vielen verschiedenen Closures aufrufen, bläht sich die Binärdatei auf.
    • Längere Kompilierzeiten: Der Compiler muss deutlich mehr Maschinencode generieren und optimieren.

2. Dynamischer Dispatch (dyn Fn / Trait Objects)

Beim dynamischen Dispatch wird die Closure hinter einem Zeiger (z. B. Box<dyn Fn()> oder &dyn Fn()) versteckt. Der Compiler generiert nur eine einzige Version der Funktion. Zur Laufzeit wird über eine virtuelle Methodentabelle (vtable) ermittelt, welcher Code ausgeführt werden muss.

  • Vorteile:
    • Flexibilität: Sie können verschiedene Closures in derselben Collection speichern (z. B. Vec<Box<dyn Fn()>> für ein Event-Listener-System).
    • Schnellere Kompilierzeiten & kleinere Binärdateien: Keine Monomorphisierung nötig.
  • Nachteile:
    • Laufzeitkosten: Der indirekte Aufruf über die vtable verhindert Inlining-Optimierungen und führt zu einem minimalen Overhead durch Zeiger-Dereferenzierung.

Praxisbeispiel: Statischer vs. Dynamischer Dispatch im Vergleich

Das folgende Beispiel zeigt beide Varianten im direkten architektonischen Vergleich.

/// --- STATISCHER DISPATCH (Monomorphisierung) ---
/// Der Compiler erzeugt für jede genutzte Closure eine eigene Version dieser Funktion.
/// Ideal für mathematische Berechnungen im Hot-Path.
pub fn execute_static<F>(action: F)
where
    F: Fn(),
{
    // Durch Inlining kann dieser Aufruf komplett wegoptimiert werden!
    action(); 
}

/// --- DYNAMISCHER DISPATCH (Trait Object) ---
/// Es gibt nur eine einzige Version dieser Funktion. Die Closure wird auf dem Heap allokiert.
/// Perfekt für Benutzeroberflächen (GUI-Events) oder Plugin-Systeme.
pub struct EventRegistry {
    listeners: Vec<Box<dyn Fn()>>,
}

impl EventRegistry {
    pub fn new() -> Self {
        Self { listeners: Vec::new() }
    }

    pub fn register_listener(&mut self, listener: Box<dyn Fn()>) {
        self.listeners.push(listener);
    }

    pub fn trigger_events(&self) {
        for listener in &self.listeners {
            // Indirekter Aufruf über die vtable zur Laufzeit
            listener(); 
        }
    }
}

fn main() {
    // 1. Statischer Dispatch
    let x = 10;
    execute_static(|| println!("Statischer Wert: {}", x));

    // 2. Dynamischer Dispatch
    let mut registry = EventRegistry::new();
    
    registry.register_listener(Box::new(|| {
        println!("Event A gefeuert!");
    }));
    
    registry.register_listener(Box::new(move || {
        println!("Event B gefeuert mit statischem Wert: {}", x);
    }));

    registry.trigger_events();
}

Wann sollte man welchen Ansatz wählen?

Nutzen Sie die folgende Tabelle als Entscheidungshilfe für Ihre Systemarchitektur:

AnforderungEmpfohlener AnsatzBegründung
Hot Path / Performance-kritischStatischer Dispatch (impl Fn)Ermöglicht Inlining und CPU-Register-Optimierungen.
Heterogene CollectionsDynamischer Dispatch (Box<dyn Fn>)Erlaubt das Speichern unterschiedlicher Closures in einem Vec.
Bibliotheks-APIs (Library APIs)Statischer Dispatch (Generics)Bietet dem Aufrufer der Bibliothek die maximale Performance und Flexibilität.
Kompilierzeit minimierenDynamischer DispatchVerhindert exzessive Code-Generierung bei sehr großen Projekten.

Zusammenfassung für Ihre Architektur

  1. Kapselung: Nutzen Sie Closures mit automatischem Erfassen für lokale, kurzlebige Callbacks. Verwenden Sie move, um Ownership sicher zu übertragen, wenn die Closure die Funktion überlebt (z.B. bei Threads).
  2. Traits: Programmieren Sie gegen das am wenigsten restriktive Trait-Bound. Wenn Fn ausreicht, fordern Sie kein FnMut.
  3. Lifetimes & Varianz: Erinnern Sie sich daran, dass veränderliche Referenzen &mut T invariant sind, um Memory Corruption zu verhindern. Lebensdauern verhalten sich wie Verträge – die übergeordnete Lebensdauer muss die untergeordnete überleben.
  4. Compile-Time: Lagern Sie rechenintensive Tabellenberechnungen und Filter über const fn in die Kompilierzeit aus, um die Startzeit Ihrer Applikation auf Null zu senken.
  5. Dispatch: Starten Sie standardmäßig mit statischem Dispatch (impl Fn). Wechseln Sie zu dynamischem Dispatch (Box<dyn Fn>), sobald Sie eine variable Anzahl unterschiedlicher Closures verwalten müssen.

Kapitel 07 - Hardware-Sicht: Was CPU und RAM bei Funktionen und Closures treiben

Hallo! Schön, dass du den Weg in die Maschinenhalle des Buches gefunden hast. Wenn du zu den Leuten gehörst, die bei Code-Abstraktionen sofort unruhig werden und wissen wollen, welche Bits und Bytes die CPU eigentlich hin- und herschiebt, dann bist du hier goldrichtig.

Wir lassen in diesem Abschnitt die komfortable Welt der High-Level-Semantik hinter uns und steigen hinab ins Silizium. Wir schauen uns an, wie Rust Funktionen auf Assembler-Ebene abwickelt und warum Closures unter der Haube nichts anderes als stinknormale Strukturen sind, die der Compiler für uns zusammenzimmert. Legen wir die Sicherheitsgurte an und werfen einen Blick auf die nackte Hardware!


1. Lernziele für die Hardware-Sicht

In diesem Tiefenabschnitt wirst du lernen:

  • Wie die CPU einen Stack-Rahmen (Stack Frame) aufbaut und wieder abbaut.
  • Welche Rolle die calling conventions (Aufrufkonventionen) der System V AMD64 ABI auf x86_64-Systemen bei der Übergabe von Argumenten und Rückgabewerten spielen.
  • Warum Funktionszeiger (fn) indirekte Sprünge erfordern und was das für die Pipeline-Vorhersage der CPU bedeutet.
  • Wie der Compiler Closures in anonyme Structs übersetzt.
  • Welches exakte Speicherlayout entsteht, wenn du Variablen per Referenz (&T), per veränderlicher Referenz (&mut T) oder per Move (T) einfängst.
  • Wie das Schlüsselwort move das Stack- und Heap-Verhalten von Closures beeinflusst.
  • Wie LLVM Closures mittels Inlining und SROA (Scalar Replacement of Aggregates) so optimiert, dass am Ende absolut null Laufzeit-Overhead übrig bleibt.

2. Die Hardware-Abwicklung von Funktionsaufrufen

Um zu verstehen, was bei einem Funktionsaufruf passiert, müssen wir uns die CPU wie eine extrem schnelle, aber auch extrem stupide Arbeitskraft vorstellen. Sie arbeitet Befehl für Befehl ab, die im Speicher (dem .text-Segment) liegen. Wenn wir eine Funktion aufrufen, müssen wir drei Probleme lösen:

  1. Wie merkt sich die CPU, wo sie nach der Funktion weitermachen muss?
  2. Wo lagert die Funktion ihre lokalen Variablen, damit sie sich nicht mit anderen Funktionen ins Gehege kommt?
  3. Wie werden Argumente hinein- und Ergebnisse herausgereicht?

Die Schreibtisch-Analogie

Analogie: Stell dir vor, du sitzt an deinem Schreibtisch und bearbeitest deine Steuererklärung. Mitten in der Arbeit fällt dir ein, dass du den Benzinverbrauch deines Autos berechnen musst. Du nimmst einen leeren Notizzettel (einen Stack-Rahmen), schreibst die Rohdaten darauf (die Parameter) und legst diesen Zettel auf deinen aktuellen Arbeitsstapel. Dann holst du dir einen kleinen Taschenrechner (Register), tippst die Werte ein und rechnest. Sobald du fertig bist, schreibst du das Endergebnis auf einen kleinen Klebezettel (das RAX-Register), wirfst den Notizzettel in den Papierkorb (Stack-Rahmen abbauen) und makelst exakt an der Zeile deiner Steuererklärung weiter, an der du vorhin gestoppt hast (die Rücksprungadresse).

Der Stack-Rahmen (Stack Frame) im Detail

Jeder Thread in einem laufenden Programm besitzt einen eigenen Speicherbereich namens Stack. Dieser wächst auf fast allen modernen Architekturen (einschließlich x86_64) von hohen Speicheradressen hin zu niedrigeren Speicheradressen.

Wenn wir eine Funktion aufrufen, reserviert die CPU einen neuen Abschnitt auf diesem Stack: den Stack-Rahmen. Hier ist eine schematische Skizze, wie so ein Rahmen im RAM aussieht:

                  Adresse (hoch)
                  +-----------------------------------+
                  | ... Vorheriger Stack-Rahmen ...   |
                  +-----------------------------------+
        RBP ----> | Gesicherter alter Frame Pointer   | <- Start des aktuellen Rahmens
                  +-----------------------------------+
                  | Rücksprungadresse (Return Addr)   | <- Wo geht es nach 'ret' weiter?
                  +-----------------------------------+
                  | Lokale Variablen der Funktion     |
                  | z. B. let x: i32 = 42;            |
                  +-----------------------------------+
                  | Temporäre Zwischenspeicher        |
        RSP ----> | Aktuelles Ende des Stacks         | <- Zeigt auf das letzte genutzte Byte
                  +-----------------------------------+
                  Adresse (niedrig)

Zwei CPU-Register steuern diesen Tanz auf dem Stack:

  • RSP (Stack Pointer): Zeigt immer auf die niedrigste belegte Adresse des Stacks. Wenn wir Daten auf den Stack schieben (push), dekrementiert die CPU den Wert von RSP und schreibt die Daten an diese Adresse.
  • RBP (Base / Frame Pointer): Zeigt auf den Anfang des aktuellen Stack-Rahmens. Er dient als stabiler Ankerpunkt, um auf lokale Variablen und Argumente über relative Offsets (z. B. [RBP - 8]) zuzugreifen, selbst wenn sich RSP während der Berechnungen ständig hin- und herbewegt. (Hinweis für Profiler: Bei optimierten Builds wird der Frame Pointer oft weggelassen, um ein weiteres Register für Berechnungen freizuschaufeln. Man spricht dann von -fomit-frame-pointer. Der Compiler berechnet die Offsets dann einfach relativ zu RSP.)

Die Calling Convention: System V AMD64 ABI

Wenn eine Funktion eine andere aufruft, müssen sich beide an ein Protokoll halten, das festlegt, wer welche CPU-Register verwenden darf. Unter Linux (und macOS) auf x86_64-Prozessoren ist dies in der System V AMD64 ABI geregelt.

Die Regeln für die Übergabe von Argumenten und Rückgabewerten sind extrem effizient:

  1. Ganzzahlen und Zeiger (bis zu 64 Bit): Die ersten sechs Argumente werden nicht über den langsamen RAM (Stack) übergeben, sondern direkt in superschnelle CPU-Register geschrieben:
      1. Argument: RDI
      1. Argument: RSI
      1. Argument: RDX
      1. Argument: RCX
      1. Argument: R8
      1. Argument: R9
  2. Fließkommazahlen: Die ersten acht Argumente landen in den SSE-Registern XMM0 bis XMM7.
  3. Weitere Argumente: Erst wenn du sieben oder mehr Argumente übergibst, werden die überschüssigen Argumente auf den Stack geschoben. (Deshalb lautet eine goldene Regel der Systemprogrammierung: Halte die Anzahl der Funktionsparameter klein!)
  4. Rückgabewerte: Das Ergebnis einer Funktion wird im Register RAX abgelegt. Ist das Ergebnis 128 Bit groß, wird zusätzlich RDX verwendet. Größere Strukturen werden meist über einen versteckten Zeiger zurückgegeben, den der Aufrufer in RDI bereitstellt.

Schauen wir uns ein einfaches, kompilierbares Rust-Beispiel an:

// Wir markieren die Funktion mit #[no_mangle], damit der Compiler
// den Funktionsnamen im Maschinencode nicht kryptisch verändert.
// So können wir den Assembly-Code leichter lesen.
#[no_mangle]
pub fn berechne_wert(a: i64, b: i64) -> i64 {
    let summe = a + b;
    summe * 2
}

fn main() {
    let ergebnis = berechne_wert(10, 20);
    println!("Ergebnis: {}", ergebnis);
}

Wenn wir diesen Code kompilieren (z. B. auf einem Linux x86_64 System), übersetzt der Rust-Compiler die Funktion berechne_wert in folgenden Assembler-Code (stark vereinfacht dargestellt):

berechne_wert:
    # 1. Parameter 'a' liegt laut ABI im Register RDI
    # 2. Parameter 'b' liegt im Register RSI
    
    mov rax, rdi    # Kopiere 'a' (RDI) nach RAX
    add rax, rsi    # Addiere 'b' (RSI) auf RAX. RAX enthält nun 'summe' (a + b)
    shl rax, 1      # Bitweise Linksverschiebung um 1. Das entspricht einer Multiplikation mit 2!
    
    # Der Rückgabewert muss laut ABI in RAX liegen. 
    # Da unser Ergebnis bereits in RAX liegt, sind wir fertig!
    ret             # Springe zurück zur Adresse, die auf dem Stack liegt

Beachte, wie extrem effizient Rust und LLVM das gelöst haben: Es wurde für berechne_wert kein einziger Byte auf dem Stack reserviert! Die CPU arbeitet ausschließlich im Register-Satz. Das ist maximale Performance.


3. Funktionszeiger (fn) und indirekte Sprünge

Im Hauptkapitel hast du gelernt, dass wir Funktionen auch als Werte speichern und übergeben können. Der Typ dafür lautet fn (kleingeschrieben).

Auf Hardware-Ebene ist ein Funktionszeiger nichts anderes als eine 64-Bit-Ganzzahl, die die Speicheradresse des ersten CPU-Befehls der Funktion im .text-Segment enthält.

Wenn wir einen normalen Funktionsaufruf schreiben (z. B. berechne_wert(10, 20)), generiert der Compiler einen direkten Sprung:

call berechne_wert  # Die CPU springt zu einer festen, bekannten Adresse

Verwenden wir hingegen einen Funktionszeiger, muss die CPU einen indirekten Sprung ausführen. Die Adresse der Zielfunktion ist zur Kompilierzeit nicht starr bekannt, sondern wird erst zur Laufzeit aus einem Register oder dem Speicher geladen:

# Der Funktionszeiger wurde zuvor in das Register RAX geladen
call rax  # Indirekter Aufruf: Springe zu der Adresse, die in RAX steht

Warum indirekte Sprünge die Hardware ins Schwitzen bringen

Moderne CPUs nutzen eine technik-nahe Eigenschaft namens Instruction Pipelining. Sie lesen Befehle bereits ein und verarbeiten sie vor, noch bevor der aktuelle Befehl komplett abgeschlossen ist. Bei einem direkten Sprung weiß die CPU genau, welche Befehle als Nächstes kommen.

Bei einem indirekten Sprung (call rax) weiß sie das jedoch erst, wenn der Wert von RAX berechnet und geladen wurde. Um nicht warten zu müssen (was zu einem Pipeline Stall führen würde), greift die CPU auf den Branch Predictor (Zweigvorhersage) zurück. Dieser versucht zu erraten, wohin die Reise geht.

  • Liegt der Branch Predictor richtig: Super, kein Zeitverlust.
  • Liegt er falsch (Branch Misprediction): Die CPU must all fälschlicherweise bereits teilgeladenen Befehle verwerfen, die Pipeline leeren und von der korrekten Adresse neu starten. Das kostet etwa 10 bis 20 wertvolle CPU-Taktzyklen.

Fazit für den Systemprogrammierer: Funktionszeiger sind mächtig, aber sie bremsen die CPU-interne Optimierung leicht aus. Verwende sie also bewusst.


4. Das Speicherlayout von Closures: Die anonymen Structs

Jetzt kommen wir zum spannendsten Teil: Closures. In vielen Sprachen (wie Java oder C#) sind Lambdas mit spürbarem Laufzeit-Overhead verbunden (Garbage Collection, Boxing auf dem Heap). Rust geht hier einen radikal anderen Weg: Eine Closure hat keinen magischen Laufzeit-Zustand. Sie ist auf Hardware-Ebene ein einfaches Struct auf dem Stack.

Wenn du eine Closure schreibst, macht der Compiler im Wesentlichen zwei Dinge:

  1. Er generiert eine anonyme Struktur, in der die eingefangenen Variablen als Felder gespeichert werden.
  2. Er implementiert für diese Struktur einen der Traits Fn, FnMut oder FnOnce über eine normale Methode.

Schauen wir uns die drei Capture-Szenarien und ihr exaktes Speicherlayout im RAM an.

Szenario A: Einfangen per Referenz (&T)

Wenn deine Closure die Variablen aus dem äußeren Scope nur liest, fängt der Compiler sie per Referenz ein.

fn main() {
    let x: i32 = 42;
    let y: i64 = 100;
    
    // Die Closure fängt x und y lesend ein
    let mein_leser = || {
        println!("x: {}, y: {}", x, y);
    };
    
    mein_leser();
}

Wenn der Compiler diesen Code sieht, übersetzt er mein_leser unter der Haube in eine Struktur, die ungefähr so aussieht:

#![allow(unused)]
fn main() {
// Vom Compiler generierte anonyme Struktur (vereinfacht)
struct AnonymeClosure<'a> {
    x: &'a i32, // Unveränderlicher Zeiger auf die Stack-Variable x
    y: &'a i64, // Unveränderlicher Zeiger auf die Stack-Variable y
}

impl<'a> Fn<()> for AnonymeClosure<'a> {
    extern "rust-call" fn call(&self, _args: ()) {
        // Der Code der Closure greift über Dereferenzierung auf die Felder zu
        println!("x: {}, y: {}", *self.x, *self.y);
    }
}
}

Das Speicherlayout auf dem Stack:

Das Objekt mein_leser ist auf Hardware-Ebene genau so groß wie seine Felder. Auf einem 64-Bit-System belegt ein Zeiger 8 Byte. Die Struktur AnonymeClosure enthält zwei Zeiger. Ihre Größe auf dem Stack beträgt somit exakt 16 Byte.

Stack-Rahmen von main():
+-----------------------------------+
| x = 42 (4 Byte)                   | <======+
+-----------------------------------+        | (Zeiger x zeigt hierhin)
| y = 100 (8 Byte)                  | <===+  |
+-----------------------------------+     |  |
| mein_leser (Closure Struct):      |     |  |
| - Feld 'x': Zeiger auf x (8 Byte) | ----+--+
| - Feld 'y': Zeiger auf y (8 Byte) | ----+
+-----------------------------------+

Szenario B: Einfangen per veränderlicher Referenz (&mut T)

Wenn die Closure den Wert einer Variable modifiziert, muss sie exklusiven Schreibzugriff haben. Der Compiler fängt die Variable daher per &mut ein.

fn main() {
    let mut counter: i32 = 10;
    
    // Die Closure modifiziert 'counter'. 
    // Da sie counter exklusiv ausleiht, muss sie selbst als 'mut' deklariert sein!
    let mut inkrementor = || {
        counter += 1;
    };
    
    inkrementor();
}

Der Compiler generiert daraus folgende Struktur und Implementierung:

#![allow(unused)]
fn main() {
struct AnonymeClosureMut<'a> {
    counter: &'a mut i32, // Veränderlicher Zeiger auf 'counter'
}

impl<'a> FnMut<()> for AnonymeClosureMut<'a> {
    extern "rust-call" fn call_mut(&mut self, _args: ()) {
        *self.counter += 1; // Dereferenzieren und Wert erhöhen
    }
}
}

Das Speicherlayout:

Die Struktur enthält einen einzigen veränderlichen Zeiger (&mut i32). Auf einem 64-Bit-System belegt diese Closure somit exakt 8 Byte auf dem Stack!


Szenario C: Einfangen per Move (T)

Wenn wir das Schlüsselwort move verwenden oder die eingefangenen Variablen in der Closure konsumiert werden, übernimmt die Closure das komplette Eigentum (Ownership) an den Variablen. Sie werden direkt in die Struktur kopiert oder verschoben.

fn main() {
    let daten: Vec<u8> = vec![1, 2, 3];
    
    // 'move' erzwingt die Verschiebung der Daten in das Struct der Closure
    let drucker = move || {
        println!("Daten: {:?}", daten);
    };
    
    drucker();
}

Hieraus generiert der Compiler:

#![allow(unused)]
fn main() {
struct AnonymeClosureMove {
    daten: Vec<u8>, // Der komplette Vector-Deskriptor wurde verschoben!
}

impl FnOnce<()> for AnonymeClosureMove {
    type Output = ();
    extern "rust-call" fn call_once(self, _args: ()) {
        println!("Daten: {:?}", self.daten);
    } // Am Ende dieses Scopes wird self (und damit daten) gedroppt!
}
}

Das Speicherlayout im RAM:

Ein Vec in Rust besteht auf dem Stack immer aus einem 24-Byte-Deskriptor (8 Byte Zeiger auf den Heap-Speicher, 8 Byte Kapazität, 8 Byte Länge).

Durch das move wandert dieser 24-Byte-Deskriptor direkt in die AnonymeClosureMove-Struktur auf den Stack. Die ursprüngliche Variable daten in main() ist danach ungültig.

Stack-Rahmen von main():
+-----------------------------------+
| drucker (Closure Struct):         |
| - Feld 'daten' (24 Byte)          |
|   - Zeiger auf Heap (8 Byte) -----+======+
|   - Kapazität = 3 (8 Byte)        |      |
|   - Länge = 3 (8 Byte)            |      |
+-----------------------------------+      |
                                           |
Heap-Speicher:                             v
+--------------------------------------------+
| [1, 2, 3] (3 Byte belegt)                  |
+--------------------------------------------+

Was passiert nun mit dem Stack-Heap-Verhalten?

  • Wenn wir die Closure drucker als lokale Variable auf dem Stack behalten, liegen auch die eingefangenen Daten (daten) auf dem Stack (während die eigentlichen Elemente [1, 2, 3] auf dem Heap liegen).
  • Wenn wir die Closure nun in eine Box packen (z. B. let boxed_closure = Box::new(drucker);), verschiebt Rust das gesamte Closure-Struct (die 24 Byte) auf den Heap. Wir haben dann einen Zeiger auf dem Stack, der auf den 24-Byte-Deskriptor auf dem Heap zeigt, welcher wiederum auf die 3 Byte Elementdaten auf dem Heap verweist.

5. Warum eine Closure mit Zustand kein Funktionszeiger ist

Ein extrem häufiger Compilerfehler bei Rust-Einsteigern entsteht, wenn man versucht, eine Closure, die Variablen einfängt, dort zu verwenden, wo ein normaler Funktionszeiger (fn) erwartet wird.

Hier ist das klassische Drama im Code:

// Diese Funktion erwartet einen normalen Funktionszeiger
fn fuehre_aus(operation: fn(i32) -> i32) {
    println!("Ergebnis: {}", operation(10));
}

fn main() {
    let faktor = 3;
    
    // DIESER CODE KOMPILIERT NICHT!
    // Wir versuchen eine Closure mit Zustand (faktor) als fn-Zeiger zu übergeben.
    fuehre_aus(|x| x * faktor);
}

Der Compiler weist uns barsch ab:

error[E0308]: mismatched types
  --> src/main.rs:11:16
   |
11 |     fuehre_aus(|x| x * faktor);
   |     ---------- ^^^^^^^^^^^^^^ expected fn pointer, found closure
   |     |
   |     arguments to this function are incorrect
   |
   = note: expected fn pointer `fn(i32) -> i32`
                  found closure `[closure@src/main.rs:11:16:11:19]`
note: closures can only be coerced to `fn` types if they do not capture any variables

Die Hardware-Erklärung für diesen Fehler

Warum ist der Compiler hier so stur? Schauen wir uns die Größe der Typen im Speicher an:

  • Ein Funktionszeiger fn(i32) -> i32 ist genau 8 Byte groß (eine reine Codeadresse). Er hat keinerlei Speicherplatz, um irgendwelche Variablen zu sichern.
  • Unsere Closure fängt die Variable faktor (ein i32, also 4 Byte) per Referenz ein. Das anonyme Struct der Closure enthält also einen Zeiger auf faktor und belegt somit 8 Byte an Speicherdaten.
  • Wenn wir die Closure aufrufen wollen, müssen wir ihr zwingend die Adresse dieses anonymen Structs als verdecktes Argument (den self-Zeiger) übergeben, damit sie weiß, mit welchem faktor sie multiplizieren soll.

Ein Funktionszeiger weiß aber gar nichts von einem self-Zeiger! Er erwartet einfach nur ein i32 im Register RDI und springt stur los. Hätten wir Zustand in der Closure, gäbe es für die Zielfunktion keine Möglichkeit, an diesen Zustand heranzukommen.

Die Ausnahme von der Regel: Wenn eine Closure keine Variablen einfängt, hat ihr anonymes Struct die Größe 0 Byte (ZST - Zero Sized Type). In diesem Fall gibt es keinen Zustand, der übergeben werden müsste. Daher erlaubt der Compiler in diesem speziellen Szenario eine automatische Konvertierung (Coercion) in einen normalen Funktionszeiger fn!


6. LLVM, Inlining und die Magie der Null-Kosten-Abstraktion

Bisher klingt das alles nach einer Menge Zeiger-Dereferenzierungen und Struct-Aufbauten auf dem Stack. Man könnte meinen: “Das kostet doch Laufzeit!”

Die sensationelle Nachricht ist: In der Release-Kompilierung (cargo build --release) optimiert LLVM diesen Overhead in fast allen Fällen komplett weg. Das Prinzip dahinter nennt sich Zero-Cost Abstractions.

Wie LLVM Closures auflöst

Da jede Closure in Rust einen einzigartigen anonymen Typ besitzt, weiß der Compiler beim Aufruf einer generischen Funktion ganz genau, um welche Closure es sich handelt. Es gibt keine Mehrdeutigkeit (keinen dynamischen Dispatch zur Laufzeit).

Schauen wir uns an, wie das in der Praxis abläuft. Nehmen wir an, wir haben folgenden Code:

#[inline(never)] // Wir verbieten das Inlining für diese Funktion zum Testen
pub fn filtere_wert<F>(wert: i32, filter: F) -> bool 
where 
    F: Fn(i32) -> bool 
{
    filter(wert)
}

fn main() {
    let limit = 100;
    // Closure fängt 'limit' (4 Byte) per Referenz ein
    let ist_groesser = |x| x > limit;
    
    let ergebnis = filtere_wert(50, ist_groesser);
    println!("Ergebnis: {}", ergebnis);
}

Wenn du diesen Code mit Optimierungen übersetzen lässt, führt LLVM folgende Schritte aus:

  1. Monomorphisierung: Der Compiler generiert eine exakte Kopie der Funktion filtere_wert, die speziell für den anonymen Typ unserer Closure ist_groesser optimiert ist.
  2. Inlining der Closure: LLVM sieht den Aufruf filter(wert) innerhalb dieser spezialisierten Funktion. Da der exakte Typ bekannt ist, ersetzt LLVM den Funktionsaufruf direkt durch den Körper der Closure: wert > limit.
  3. Scalar Replacement of Aggregates (SROA): LLVM erkennt, dass das anonyme Closure-Struct nur kurz erzeugt wird, um auf limit zuzugreifen. LLVM bricht das Struct komplett auf und lädt den Wert von limit direkt in ein CPU-Register. Das Struct auf dem Stack wird rückstandslos gelöscht.
  4. Inlining von filtere_wert: Wenn LLVM nun auch noch die Funktion filtere_wert in main inlined, verschwindet der gesamte Funktionsaufruf.

Am Ende bleibt im Maschinencode oft nur noch ein einziger Assembler-Vergleichsbefehl übrig:

# Der gesamte Aufruf von filtere_wert und der Closure wurde zu diesem Vergleich reduziert:
cmp edi, 100    # Vergleiche den übergebenen Wert (EDI) direkt mit dem Limit (100)
setg al         # Schreibe 1 nach AL (Rückgabewert), wenn der Wert größer war, sonst 0

Es gibt zur Laufzeit kein Struct, keinen Zeiger, keinen Funktionsaufruf und keinen Stack-Rahmen für die Closure. Der Code läuft exakt so schnell, als hättest du den Vergleich 50 > 100 manuell hartcodiert an Ort und Stelle hingeschrieben. Das ist die wahre Power von Rust!


7. Zusammenfassung der Hardware-Sicht

Zusammenfassend können wir festhalten:

  1. Funktionsaufrufe werden auf Hardware-Ebene über Stack-Rahmen organisiert. Register (RDI, RSI etc.) transportieren Argumente blitzschnell zur Funktion, RAX bringt das Ergebnis zurück.
  2. Funktionszeiger (fn) sind reine 64-Bit-Speicheradressen im Code-Segment. Sie zwingen die CPU zu indirekten Sprüngen, was die Pipeline-Vorhersage erschweren kann.
  3. Closures sind keine Magie, sondern anonyme Strukturen, die der Compiler baut. Jede Closure hat einen einzigartigen Typ.
  4. Das Speicherlayout einer Closure entspricht exakt den Variablen, die sie einfängt:
    • &T fängt Zeiger ein (8 Byte pro Zeiger auf einem 64-Bit-System).
    • &mut T fängt veränderliche Zeiger ein.
    • move T verschiebt den kompletten Wert (inklusive eventueller Heap-Deskriptoren) in das Struct.
  5. Closures mit Zustand können nicht als normale Funktionszeiger (fn) verwendet werden, da fn-Zeiger keinen Speicherplatz für den Zustand (die eingefangenen Variablen) besitzen.
  6. Dank Monomorphisierung, Inlining und modernster LLVM-Optimierungen verschwindet die Struktur von Closures in optimierten Builds meist vollständig aus dem Maschinencode. Du bezahlst keinen einzigen Taktzyklus extra für diese elegante Abstraktion!