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

Praxisteil & Übungen: Funktionen, Lifetimes und Closures

In diesem Praxisteil vertiefen wir unser Verständnis für Funktionen, Closures und Lebensdauern (Lifetimes) in Rust. Wir arbeiten direkt mit dem Compiler, um typische Einschränkungen zu verstehen und elegant zu lösen.


1. Praxis-Szenario: Berechnungen und Datenverarbeitung im Logistik-System

In unserem Logistik-Workspace müssen wir flexibel Berechnungen anwenden (z. B. Steuersätze oder Rabatte auf Preise aufschlagen) und Referenzen auf Daten vergleichen, ohne diese im Speicher zu kopieren. Dabei stoßen wir auf die feinen Unterschiede zwischen statischen Funktionszeigern, zustandsbehafteten Closures und den Regeln des Borrow Checkers bezüglich der Lebensdauer von Referenzen.

Die Übungsaufgabe befindet sich im Verzeichnis:

Unser Ziel ist es, die Compilerfehler in dieser Datei systematisch zu analysieren, zu verstehen und zu beheben.


2. Strukturierte Praxis-Einheiten

2.1 Funktionszeiger (fn) vs. Closures

Ein Funktionszeiger (fn) ist eine direkte Referenz auf ein Stück kompilierten Maschinencode. Er ist vollkommen zustandslos. Eine Closure (|| {}) hingegen kann Variablen aus ihrer Umgebung “einfangen”. Sobald sie das tut, ist sie kein einfacher Funktionszeiger mehr, sondern ein komplexes Objekt, das intern die gefangenen Daten hält.

Die Analogie: Das statische Kochbuch vs. der persönliche Koch

  • Funktionszeiger (fn): Ein gedrucktes Kochrezept auf einer Buchseite. Es ist statisch, unveränderlich und weiß nichts über das Wetter draußen oder wer es liest. Es verarbeitet nur die Zutaten, die man ihm direkt übergibt (Parameter).
  • Closure: Ein Koch, der zu Ihnen nach Hause kommt. Er bringt seine eigenen Gewürze aus seiner eigenen Küche (Umgebung) mit und erinnert sich daran, was Sie gestern gegessen haben.

Der Compilerfehler (CDD-Ansatz):

In unserer Übung finden wir folgenden Code:

#![allow(unused)]
fn main() {
fn wende_an(wert: i32, operation: fn(i32) -> i32) -> i32 {
    operation(wert)
}

// In main():
let offset = 10;
let addiere_offset = |x| x + offset; // Fehler!
let ergebnis_1 = wende_an(5, addiere_offset);
}

Wenn wir versuchen, dies zu kompilieren, meldet der Compiler einen Typkonflikt:

error[E0308]: mismatched types
   | expected fn pointer `fn(i32) -> i32`
   |    found closure `[closure@src/main.rs:41:26: 41:40]`

Warum lehnt der Compiler das ab? Die Closure addiere_offset fängt die Variable offset aus ihrer Umgebung ein. Dadurch benötigt sie Speicherplatz für diese Variable. Ein reiner Funktionszeiger fn hat jedoch keine Möglichkeit, diesen zusätzlichen Zustand zu speichern oder zu transportieren.

Die Lösung:

Da die Funktion wende_an explizit einen Funktionszeiger verlangt, müssen wir ihr eine zustandsfreie Operation übergeben. Wir können den offset entweder direkt in den Funktionskörper einbauen (ohne eine Variable von außen einzufangen) oder eine echte Funktion deklarieren:

#![allow(unused)]
fn main() {
// Lösungsmöglichkeit A: Eine zustandslose Closure, die nichts einfängt
let addiere_konstante = |x| x + 10; // Fängt keine Variable ein!
let ergebnis_1 = wende_an(5, addiere_konstante); // Funktioniert!

// Lösungsmöglichkeit B: Eine klassische fn-Funktion
fn addiere_zehn(x: i32) -> i32 {
    x + 10
}
let ergebnis_1 = wende_an(5, addiere_zehn); // Funktioniert ebenfalls!
}

2.2 Lebensdauern (Lifetimes) in Funktionen

Rust garantiert Speichersicherheit zur Kompilierzeit. Wenn eine Funktion eine Referenz entgegennimmt und eine Referenz zurückgibt, muss der Compiler sicherstellen, dass die zurückgegebene Referenz nicht auf gelöschten Speicher zeigt.

Die Analogie: Der Hotelgast und das Hotel

Stellen Sie sich vor, ein Hotelgast (die Referenz) bucht ein Zimmer. Der Hotelier (der Compiler) muss garantieren, dass der Gast nicht länger im Zimmer bleibt, als das Hotelgebäude selbst existiert. Wenn Sie zwei verschiedene Hotels haben und der Gast in eines davon einzieht, muss der Compiler wissen, an welches Hotel die Lebensdauer des Gasts gekoppelt ist.

Der Compilerfehler (CDD-Ansatz):

#![allow(unused)]
fn main() {
fn finde_kuerzere(x: &str, y: &str) -> &str {
    if x.len() < y.len() {
        x
    } else {
        y
    }
}
}

Der Compiler bricht mit folgendem Fehler ab:

error[E0106]: missing lifetime specifier
   |
14 | fn finde_kuerzere(x: &str, y: &str) -> &str {
   |                      ----     ----     ^ expected named lifetime parameter

Warum lehnt der Compiler das ab? Die Funktion gibt entweder x oder y zurück. Beide sind Referenzen. Da der Compiler zur Kompilierzeit nicht weiß, welcher Zweig der if-Bedingung zur Laufzeit ausgeführt wird, weiß er nicht, wovon die Lebensdauer des Rückgabewerts abhängt. Er verlangt von uns, dass wir die Beziehungen mithilfe von Lifetime-Annotationen explizit machen.

Die Lösung:

Wir führen einen Lifetime-Parameter 'a ein. Dieser sagt dem Compiler: “Die zurückgegebene Referenz lebt mindestens so lange wie die kürzere der beiden Eingabereferenzen.”

#![allow(unused)]
fn main() {
fn finde_kuerzere<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() < y.len() {
        x
    } else {
        y
    }
}
}

2.3 Die drei Closure-Traits: Fn, FnMut und FnOnce

Closures werden in Rust über drei verschiedene Traits (Schnittstellen) charakterisiert, je nachdem, wie sie auf die eingefangenen Variablen zugreifen:

  1. Fn (Immutable Borrow): Die Closure liest die Variablen nur (&T). Sie kann beliebig oft und parallel aufgerufen werden.
  2. FnMut (Mutable Borrow): Die Closure verändert die eingefangenen Variablen (&mut T). Sie kann mehrfach aufgerufen werden, aber nicht parallel.
  3. FnOnce (Moving): Die Closure übernimmt das Eigentum (Ownership) der Variablen (T). Sie kann daher nur ein einziges Mal aufgerufen werden.

Die Analogie: Buch lesen, Notizbuch beschreiben, Eintrittskarte entwerten

  • Fn (Lesen): Ein Buch in einer Bibliothek. Viele Menschen können es gleichzeitig lesen (&T). Das Buch verändert sich dadurch nicht.
  • FnMut (Schreiben): Ein persönliches Tagebuch. Sie schlagen es auf und schreiben hinein (&mut T). Sie verändern den Zustand, können es aber am nächsten Tag wieder tun.
  • FnOnce (Konsumieren): Eine Kinokarte. Sie übergeben sie dem Einlasser, der sie zerreißt (T). Sie ist danach verbraucht und kann nicht noch einmal verwendet werden.

Der Compilerfehler (CDD-Ansatz):

In der Übung haben wir:

#![allow(unused)]
fn main() {
fn zweimal_ausfuehren<F>(mut aktion: F) 
where 
    F: Fn() // Hier verlangen wir den Fn-Trait (nur lesend!)
{
    aktion();
    aktion();
}

// In main():
let mut summe = 0;
zweimal_ausfuehren(|| {
    summe += 5; // Fehler! Versucht, die äußere Variable `summe` zu verändern.
});
}

Der Compiler meldet:

error[E0525]: expected a closure that implements the `Fn` trait, but this closure only implements `FnMut`
   |
54 |     zweimal_ausfuehren(|| {
   |     ------------------ - this closure implements `FnMut`, not `Fn`
55 |         summe += 5;
   |         ^^^^^ focus of mutation

Warum lehnt der Compiler das ab? Da unsere Closure die Variable summe verändert (summe += 5), implementiert sie automatisch den Trait FnMut. Die Funktion zweimal_ausfuehren fordert aber über ihren Trait-Bound where F: Fn(), dass die übergebene Funktion die Umgebung nicht verändern darf.

Die Lösung:

Wir lockern den Trait-Bound in der Definition von zweimal_ausfuehren auf FnMut auf:

#![allow(unused)]
fn main() {
fn zweimal_ausfuehren<F>(mut aktion: F) 
where 
    F: FnMut() // Geändert von Fn() zu FnMut()!
{
    aktion();
    aktion();
}
}

3. Genaue Code-Erklärung der Musterlösung

Hier sehen wir den vollständigen, korrigierten und kompilierbaren Code der Musterlösung für exercises/05_functions/src/main.rs:

1:  // Übung 5: Funktionen, Lifetimes und Closures
2:  // Beheben Sie die Compilerfehler in dieser Datei, damit das Programm läuft!
3:  
4:  // 1. Funktionszeiger (Function Pointers)
5:  // Ein klassischer Funktionszeiger `fn` kann keine Variablen aus seiner Umgebung erfassen.
6:  fn wende_an(wert: i32, operation: fn(i32) -> i32) -> i32 {
7:      operation(wert)
8:  }
9:  
10: // 2. Lebensdauern (Lifetimes)
11: // Wir fügen die Lifetime-Annotation `'a` hinzu, um anzuzeigen, dass der Rückgabewert
12: // so lange gültig ist wie die übergebenen Referenzen.
13: fn finde_kuerzere<'a>(x: &'a str, y: &'a str) -> &'a str {
14:     if x.len() < y.len() {
15:         x
16:     } else {
17:         y
18:     }
19: }
20: 
21: // 3. Closure-Traits
22: // Wir ändern den Trait-Bound von Fn auf FnMut, da die Closure `summe` verändert.
23: fn zweimal_ausfuehren<F>(mut aktion: F) 
24: where 
25:     F: FnMut()
26: {
27:     aktion();
28:     aktion();
29: }
30: 
31: fn main() {
32:     // Zu Aufgabe 1:
33:     // Da `wende_an` einen reinen Funktionszeiger `fn` erwartet, übergeben wir eine 
34:     // zustandslose Closure, die keine Variablen aus der Umgebung (wie `offset`) einfängt.
35:     let addiere_zehn = |x| x + 10;
36:     let ergebnis_1 = wende_an(5, addiere_zehn);
37:     println!("Aufgabe 1 Ergebnis: {}", ergebnis_1);
38: 
39:     // Zu Aufgabe 2:
40:     let kette1 = "Rust";
41:     let kette2 = "Lernpfad";
42:     let kuerzere = finde_kuerzere(kette1, kette2);
43:     println!("Aufgabe 2 Ergebnis (Kürzere): {}", kuerzere);
44: 
45:     // Zu Aufgabe 3:
46:     let mut summe = 0;
47:     // Die Closure erfasst `summe` veränderbar (FnMut)
48:     zweimal_ausfuehren(|| {
49:         summe += 5;
50:     });
51:     println!("Aufgabe 3 Ergebnis (Summe): {}", summe);
52: }
53: 
54: #[cfg(test)]
55: mod tests {
56:     use super::*;
57: 
58:     #[test]
59:     fn test_kuerzere() {
60:         let a = "Apfel";
61:         let b = "Birne";
62:         assert_eq!(finde_kuerzere(a, b), "Apfel");
63:     }
64: }

Zeilen-Analyse der Lösung:

  • Zeile 6: fn wende_an(wert: i32, operation: fn(i32) -> i32) -> i32 – Deklariert eine Funktion höherer Ordnung, die einen Funktionszeiger fn akzeptiert. fn belegt genau ein Wort auf dem Stack (die Speicheradresse des Maschinencodes).
  • Zeile 13: fn finde_kuerzere<'a>(x: &'a str, y: &'a str) -> &'a str – Definiert die generische Lifetime 'a. Der Compiler prüft nun beim Aufruf, ob der Gültigkeitsbereich der übergebenen Variablen groß genug ist, um den Rückgabewert gefahrlos zuzuweisen.
  • Zeile 23: fn zweimal_ausfuehren<F>(mut aktion: F) – Da der Trait-Bound nun FnMut verlangt, muss die Funktion das Eigentum an der Closure übernehmen und sie als veränderbar (mut aktion) deklarieren, um die internen Zustände bei jedem Aufruf modifizieren zu können.
  • Zeile 35: let addiere_zehn = |x| x + 10; – Diese Closure fängt keine Variablen ein. Rust-Closures, die nichts einfangen, können implizit in reine Funktionszeiger (fn) umgewandelt werden, da sie keinen Zustand mitschleppen müssen.
  • Zeile 42: let kuerzere = finde_kuerzere(kette1, kette2); – Da beide Strings im Datensegment des Programms liegen (Typ &'static str), ist die Lebensdauer 'a hier unendlich lang, und die Zuweisung klappt problemlos.
  • Zeile 48: zweimal_ausfuehren(|| { summe += 5; }); – Die Closure erzeugt im Hintergrund eine anonyme Struktur auf dem Stack, die eine veränderliche Referenz &mut summe hält. Durch den Aufruf von zweimal_ausfuehren wird dieser Zustand zweimal verarbeitet, sodass summe am Ende den Wert 10 aufweist.