Praxisteil & Übungen: Iteratoren und funktionale Programmierung
Willkommen zum Praxisteil über Iteratoren! In diesem Kapitel verlassen wir die klassischen, imperativen Schleifen (for i in 0..10) und tauchen tief in die funktionale Programmierung ein. Rusts Iteratoren sind nicht nur elegant zu lesen, sondern gehören zu den am besten optimierten Abstraktionen der Sprache. Sie erlauben es uns, komplexe Transformationen an Datenströmen in einer Kette von Funktionsaufrufen zu beschreiben – und das ohne jegliche Performance-Einbußen gegenüber handgeschriebenen Schleifen.
Wir werden Schritt für Schritt eine funktionale Umsatz- und Datenanalyse für ein E-Commerce-Unternehmen aufbauen. Dabei lernen wir, wie wir Daten filtern, transformieren, aggregieren und in neuen Datenstrukturen sammeln.
1. Praxis-Szenario: Eine funktionale Umsatz- und Datenanalyse
Wir arbeiten mit den Rohdaten eines Online-Shops. Uns liegt eine Liste von Transaktionen vor. Jede Transaktion ist als Struktur Transaction abgebildet und enthält einen Geldbetrag, eine Produktkategorie (z. B. “Elektronik”, “Bücher”, “Kleidung”) und einen Status (erfolgreich abgeschlossen oder abgebrochen).
Unsere Aufgabe ist es, mithilfe von funktionalen Iterator-Ketten folgende Analysen durchzuführen:
- Den Gesamtumsatz aller erfolgreich abgeschlossenen Transaktionen in einer bestimmten Kategorie berechnen.
- Die Namen bzw. Beschreibungen aller fehlgeschlagenen Transaktionen sammeln, um sie an das Support-Team zu übergeben.
- Herausfinden, ob es im gesamten Datensatz Transaktionen gibt, die einen verdächtig hohen Betrag aufweisen (z. B. über 1000 Euro), um Betrugsprävention zu betreiben.
Die Übungsaufgabe befindet sich im Verzeichnis:
- exercises/15_iterators/src/main.rs (Vervollständigen Sie die dort vorbereiteten Analysefunktionen)
2. Didaktische Alltagsanalogie: Das Fließband in der Saftfabrik
Um zu verstehen, wie Iteratoren arbeiten, stellen wir uns eine Saftfabrik mit einem Fließband vor.
Auf der einen Seite der Fabrik steht eine Kiste mit Äpfeln (unsere Datenquelle, z. B. ein Vec). Das Fließband selbst transportiert die Äpfel einzeln an verschiedenen Stationen vorbei.
- Die erste Station filtert faule Äpfel aus. Die guten Äpfel laufen weiter. (
filter) - Die zweite Station schält die Äpfel. Aus einem Apfel-Objekt wird ein geschältes Apfel-Objekt. (
map) - Die dritte Station presst die Äpfel zu Saft und füllt sie in Flaschen ab. (
collect)
Das Wichtigste an diesem Prozess ist die Trägheit (Lazy Evaluation):
Solange am Ende des Fließbandes keine Flaschen abgefüllt werden (also niemand den Saft anfordert), bewegt sich kein einziger Apfel auf dem Band. Die Stationen 1 und 2 sind bloß Wegbeschreibungen für die Äpfel. Erst wenn die Abfüllmaschine am Ende eingeschaltet wird (ein terminaler Adapter wie collect oder sum), setzt sich das gesamte Band in Bewegung und verarbeitet ein Element nach dem anderen.
3. Strukturierte Praxis-Einheiten
3.1 Get Started: Die Rohdaten und der Iterator-Einstieg
Bevor wir filtern können, müssen wir aus unserer Datenquelle einen Iterator erzeugen. Rust bietet uns dafür drei Standardmethoden an:
iter(): Liefert einen Iterator über unveränderliche Referenzen (&T). Die Original-Liste bleibt unberührt.iter_mut(): Liefert einen Iterator über veränderliche Referenzen (&mut T). Wir können die Elemente direkt in der Liste modifizieren.into_iter(): Konsumiert die Collection und übernimmt den Besitz (T). Die Original-Liste existiert danach nicht mehr.
Für Analysen nutzen wir in der Regel iter(), da wir die Daten nur lesen wollen.
Beispiel:
#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq)]
enum Status {
Completed,
Failed,
}
struct Transaction {
id: u32,
amount: f64,
category: String,
status: Status,
}
let transactions = vec![
Transaction { id: 1, amount: 120.0, category: String::from("Bücher"), status: Status::Completed },
Transaction { id: 2, amount: 450.0, category: String::from("Elektronik"), status: Status::Failed },
];
// Iterator über Referenzen erstellen
let transactions_iter = transactions.iter();
}
3.2 Filtern und Transformieren: filter, map und die Tücken der Referenzen
Jetzt wollen wir nur die Transaktionen behalten, die erfolgreich abgeschlossen wurden (Status::Completed). Dafür nutzen wir filter. Danach wollen wir uns nur noch für die Beträge (amount) interessieren. Dafür nutzen wir map.
Der CDD-Ansatz: Wir stolpern über doppelte Zeiger (&&) und Trägheit
Ein sehr häufiger Fehler beim Filtern von Iteratoren entsteht durch die Typen der Closure-Argumente. Schreiben wir naiv folgenden Code:
#![allow(unused)]
fn main() {
let completed_amounts = transactions.iter()
.filter(|t| t.status == Status::Completed) // Fehlerquelle!
.map(|t| t.amount);
}
Wenn wir das kompilieren, meldet uns der Compiler einen Fehler, der Anfänger oft verwirrt:
error[E0308]: mismatched types
--> src/main.rs:25:28
|
25 | .filter(|t| t.status == Status::Completed)
| ^^^^^^^^^^^^^^^^^^ expected struct `Transaction`, found reference
|
= note: expected enum `Status`
found reference `&Status`
Warum passiert das?
Da transactions.iter() einen Iterator über Referenzen (&Transaction) erzeugt, gibt der Iterator bei jedem Schritt ein &Transaction aus.
Die Methode .filter() nimmt nun aber eine Closure entgegen, die ihrerseits eine Referenz auf das vom Iterator gelieferte Element erhält. Das bedeutet: Das Argument t in der Closure |t| hat den Typ &&Transaction (eine Referenz auf eine Referenz)!
Wenn wir nun auf t.status zugreifen, löst Rust die erste Referenz automatisch auf, aber das Ergebnis ist immer noch eine Referenz: &Status. Wir versuchen also, ein &Status mit dem konkreten Wert Status::Completed zu vergleichen. Das geht schief!
Die Behebung
Wir können das Problem auf drei Arten lösen:
- Wir dereferenzieren das Feld beim Vergleich explizit:
*(t.status) == Status::Completed(wenn der Typ Kopieren erlaubt). - Wir vergleichen mit einer Referenz:
t.status == Status::Completed(da Rust bei Enums automatische Vergleiche anstellt, wenn wir das Enum dereferenzieren). - Die idiomatische Rust-Methode: Wir nutzen Pattern Matching in den Closure-Argumenten, um die Referenz direkt zu destrukturieren:
#![allow(unused)]
fn main() {
// Wir schreiben `&t` statt `t`. Damit "ziehen" wir eine Ebene der Referenz ab!
// Nun ist `t` vom Typ `&Transaction` und wir können direkt arbeiten.
let completed_amounts = transactions.iter()
.filter(|&t| t.status == Status::Completed)
.map(|t| t.amount);
}
Warnung zur Trägheit (Lazy Evaluation)
Wenn wir diesen Code schreiben und nichts weiter tun, gibt uns der Compiler eine Warnung aus:
warning: unused `Map` that must be used
--> src/main.rs:24:29
|
24 | / transactions.iter()
25 | | .filter(|&t| t.status == Status::Completed)
26 | | .map(|t| t.amount);
| |___________________________^
|
= note: `#[warn(unused_must_use)]` on by default
= note: iterators are lazy and do nothing unless consumed
Wie die Warnung sagt: Ein Iterator tut absolut gar nichts, wenn wir ihn nicht konsumieren! Er ist nur ein Rezept. Wir müssen ein “Abfüll-Event” am Ende anhängen.
Aufgabe:
Schreiben Sie den Filter- und Map-Prozess für die Transaktionen. Nutzen Sie Destrukturierung |&t|, um Typprobleme mit doppelten Referenzen zu vermeiden.
3.3 Daten aggregieren: sum und fold
Um unseren Iterator zu konsumieren und die Beträge zusammenzurechnen, können wir entweder .sum() nutzen (wenn der Typ das Trait Sum implementiert) oder das allgemeinere .fold().
Weg A: sum
#![allow(unused)]
fn main() {
// sum verlangt eine Typangabe (Turbofish), da Rust wissen muss, in welchen Typ summiert werden soll.
let summe: f64 = completed_amounts.sum();
}
Weg B: fold (Der Akkumulator)
fold ist das funktionale Gegenstück zu einer Schleife mit einer Summen-Variable. Es nimmt einen Startwert (den Akkumulator) und eine Closure, die den Akkumulator für jedes Element aktualisiert.
#![allow(unused)]
fn main() {
let summe_fold = completed_amounts.fold(0.0, |acc, amount| acc + amount);
}
Aufgabe:
Implementieren Sie die Berechnung des Gesamtumsatzes unter Verwendung von .sum() oder .fold().
3.4 Daten sammeln: collect und der Turbofish ::<>
Die Methode .collect() sammelt die Elemente eines Iterators in einer neuen Collection (z. B. einem Vec, einer HashMap oder einem HashSet). Da collect extrem flexibel ist und in fast jede Collection sammeln kann, weiß der Compiler oft nicht, welche Struktur wir am Ende haben wollen.
Wir müssen dem Compiler helfen. Entweder über eine Typ-Annotation an der Variable:
#![allow(unused)]
fn main() {
let ids: Vec<u32> = transactions.iter().map(|t| t.id).collect();
}
Oder über den sogenannten Turbofish-Operator ::<> direkt an der Methode:
#![allow(unused)]
fn main() {
let ids = transactions.iter().map(|t| t.id).collect::<Vec<u32>>();
}
Tip
Der Turbofish sieht aus wie ein Fisch, der nach rechts schwimmt:
::<>. Er wird in Rust immer dann verwendet, wenn wir einer generischen Funktion oder Methode explizit Typen übergeben wollen.
Aufgabe:
Schreiben Sie eine Funktion, die alle IDs von fehlgeschlagenen Transaktionen filtert und diese in einem Vec<u32> mittels collect zurückgibt.
4. Genaue Code-Erklärung der Musterlösung
Der fertige und lauffähige Code für die Umsatzanalyse befindet sich unter solutions/15_iterators/src/main.rs.
1: // Musterlösung zu Übung 15: Datenanalyse mit funktionalen Iteratoren
2:
3: #[derive(Debug, Clone, PartialEq)]
4: pub enum Status {
5: Completed,
6: Failed,
7: }
8:
9: #[derive(Debug, Clone)]
10: pub struct Transaction {
11: pub id: u32,
12: pub amount: f64,
13: pub category: String,
14: pub status: Status,
15: }
16:
17: // 1. Berechnet die Summe aller abgeschlossenen Transaktionen einer Kategorie
18: pub fn umsatz_nach_kategorie(transactions: &[Transaction], kategorie: &str) -> f64 {
19: transactions
20: .iter()
21: .filter(|&t| t.status == Status::Completed && t.category == kategorie)
22: .map(|t| t.amount)
23: .sum()
24: }
25:
26: // 2. Sammelt die IDs aller fehlgeschlagenen Transaktionen
27: pub fn fehlgeschlagene_ids(transactions: &[Transaction]) -> Vec<u32> {
28: transactions
29: .iter()
30: .filter(|&t| t.status == Status::Failed)
31: .map(|t| t.id)
32: .collect::<Vec<u32>>()
33: }
34:
35: // 3. Prüft, ob es eine Transaktion gibt, die einen Schwellenwert überschreitet
36: pub fn hat_grossen_betrag(transactions: &[Transaction], schwellenwert: f64) -> bool {
37: transactions
38: .iter()
39: .any(|t| t.amount > schwellenwert)
40: }
41:
42: fn main() {
43: let daten = vec![
44: Transaction { id: 101, amount: 45.99, category: String::from("Bücher"), status: Status::Completed },
45: Transaction { id: 102, amount: 899.00, category: String::from("Elektronik"), status: Status::Completed },
46: Transaction { id: 103, amount: 15.00, category: String::from("Bücher"), status: Status::Failed },
47: Transaction { id: 104, amount: 1200.50, category: String::from("Elektronik"), status: Status::Completed },
48: Transaction { id: 105, amount: 25.00, category: String::from("Kleidung"), status: Status::Failed },
49: ];
50:
51: // Umsatz-Analyse
52: let buch_umsatz = umsatz_nach_kategorie(&daten, "Bücher");
53: let elektro_umsatz = umsatz_nach_kategorie(&daten, "Elektronik");
54: println!("Umsatz Bücher: {} EUR", buch_umsatz);
55: println!("Umsatz Elektronik: {} EUR", elektro_umsatz);
56:
57: // Fehlersuche
58: let fehler = fehlgeschlagene_ids(&daten);
59: println!("Fehlgeschlagene Transaktions-IDs: {:?}", fehler);
60:
61: // Sicherheits-Check
62: let alarm = hat_grossen_betrag(&daten, 1000.0);
63: println!("Gibt es Beträge über 1000 EUR? {}", alarm);
64: }
Zeilen-Analyse der Lösung:
- Zeile 3-7:
pub enum Status– Ein einfaches Enum für den Zustand der Transaktion. Wir leitenPartialEqab, um den Zustand direkt mit==vergleichen zu können. - Zeile 10-15:
pub struct Transaction– Unsere Datenstruktur. Sie kapselt die Attribute einer einzelnen Transaktion. - Zeile 18:
pub fn umsatz_nach_kategorie(transactions: &[Transaction], kategorie: &str) -> f64– Die Funktion nimmt einen Slice&[Transaction]entgegen. Dies ist flexibler als ein Vektor, da wir sowohl einen ganzen Vektor (&Vec<T>) als auch Teile davon übergeben können. - Zeile 20:
transactions.iter()– Wir erzeugen den Iterator über unveränderliche Referenzen auf die Transaktionen. - Zeile 21:
.filter(|&t| ...)– Das Herzstück der Filterung. Die Closure erhält&t(Destrukturierung der doppelten Referenz). Wir prüfen zwei Bedingungen: Den Status (Status::Completed) und die Kategorie (t.category == kategorie). Nur wenn beide Bedingungen zutreffen (&&), wandert das Element zur nächsten Station auf dem Fließband. - Zeile 22:
.map(|t| t.amount)– Die Transformation. Aus der gefilterten&Transactionextrahieren wir den Betrag (f64). Der Iterator liefert ab hier nur nochf64-Werte. - Zeile 23:
.sum()– Der terminale Adapter. Er setzt den Iterator in Bewegung, addiert alle Beträge auf und gibt das Endergebnis alsf64zurück. - Zeile 30:
.filter(|&t| t.status == Status::Failed)– Filtert nach fehlgeschlagenen Transaktionen. - Zeile 31:
.map(|t| t.id)– Extrahiert die ID der Transaktion. - Zeile 32:
.collect::<Vec<u32>>()– Sammelt die IDs im Vektor. Hier nutzen wir den Turbofish, umcollectmitzuteilen, dass wir einen Vektor vonu32-Werten haben möchten. - Zeile 39:
.any(|t| t.amount > schwellenwert)– Ein cleverer Kurzschluss-Adapter (Short-circuiting). Er prüft, ob mindestens ein Element die Bedingung erfüllt. Sobald ein Element gefunden wird, das größer als der Schwellenwert ist, brichtanydie Ausführung sofort ab und gibttruezurück. Es müssen nicht alle restlichen Elemente geprüft werden! Das spart Rechenzeit. - Zeile 52: Wir übergeben die Daten mit einem vorangestellten
&(Referenzierung), um den Vektordatennicht zu verbrauchen. So können wir ihn für alle drei Funktionsaufrufe wiederverwenden.