Praxisteil & Übungen: Enumerationen (Enums) in der Praxis
Herzlich willkommen zum Praxisteil von Kapitel 12! In Rust sind Enumerationen (Enums) weit mehr als eine bloße Liste von benannten Zahlenkonstanten wie in vielen anderen Programmiersprachen. Sie sind sogenannte algebraische Datentypen (Summen-Typen). Das bedeutet, dass jede Variante eines Enums eigene, individuelle Daten mit sich führen kann.
In diesem Praxisteil entwickeln wir einen Zustandsautomaten für einen Online-Bezahlvorgang. Dieses Szenario zeigt eindrucksvoll, wie wir mit Enums und Pattern Matching unzulässige Systemzustände unmöglich machen und einen klaren, fehlerfreien Kontrollfluss garantieren können.
Die Übungsaufgabe befindet sich im Verzeichnis:
- exercises/09_enums/src/main.rs (Starten Sie hier mit einer leeren
main()-Funktion)
1. Das Praxis-Szenario: Der Bezahl-Zustandsautomat
Wenn Kunden in unserem Online-Shop einkaufen, durchläuft der Bezahlvorgang eine klar definierte Reihe von Schritten:
- Erstellt (
Created): Der Warenkorb wurde abgeschickt, es sind noch keine Kundendaten oder Zahlungsmittel hinterlegt. - Autorisiert (
Authorized): Der Kunde hat sich identifiziert (z. B. Benutzername hinterlegt). - In Bearbeitung (
Processing): Der Geldbetrag und die gewählte Zahlungsmethode (Kreditkarte, PayPal oder Überweisung) wurden übermittelt. - Erfolgreich abgeschlossen (
Completed): Die Zahlung war erfolgreich. Wir speichern eine eindeutige Transaktions-ID. - Fehlgeschlagen (
Failed): Die Zahlung schlug fehl. Wir speichern eine Fehlermeldung.
Wir werden:
- Ein Enum
PaymentMethoddefinieren, das unterschiedliche Daten je nach Zahlungsart speichert (z. B. Kartennummer für Kreditkarte oder E-Mail-Adresse für PayPal). - Ein Enum
PaymentStatedefinieren, das den aktuellen Status des Bezahlvorgangs und die jeweils logisch dazugehörigen Daten abbildet. - Eine Funktion schreiben, die den aktuellen Zustand analysiert und eine detaillierte Statusmeldung ausgibt.
- Eine Übergangsmethode implementieren, die den Zustand sicher von einer Stufe in die nächste überführt.
Die Alltagsanalogie: Der Pfandautomat
Stellen Sie sich einen Pfandflaschenautomaten im Supermarkt vor. Der Automat befindet sich immer in genau einem Zustand:
- Warte auf Flasche (
Idle): Der Automat zeigt “Bitte Flasche einwerfen”. - Flasche wird gescannt (
Scanning): Eine Flasche dreht sich auf den Rollen. Der Laser liest das Barcode-Label. - Flasche akzeptiert (
Accepted): Die Flasche wird einsortiert. Der Automat aktualisiert den Pfandwert (z. B. +0.25 Euro). - Störung (
Error): Die Flasche hat sich verkeilt. Das rote Licht leuchtet und das Band steht still.
Der Automat kann nicht im Zustand “Flasche akzeptiert” sein, ohne dass zuvor eine Flasche gescannt wurde. Ebenso kann der Automat nicht gleichzeitig im Zustand “Warte auf Flasche” und “Störung” sein. Das Enum repräsentiert genau diese exklusiven Zustände. Jedes Event (Flasche rein, Knopf drücken) löst eine kontrollierte Zustandsänderung aus.
2. Strukturierte Praxis-Einheiten
2.1 Get Started: Die Enums definieren
Wir definieren zuerst die Zahlungsmethode. Hier sieht man bereits, wie Enums in Rust unterschiedliche Datenstrukturen bündeln können:
#![allow(unused)]
fn main() {
#[derive(Debug)]
enum PaymentMethod {
CreditCard { card_number: String, holder: String },
PayPal(String), // Speichert nur die E-Mail-Adresse als anonymes Feld
BankTransfer { iban: String },
}
}
CreditCard: Nutzt benannte Felder (ähnlich einer klassischen Struktur).PayPal: Nutzt ein anonymes Feld (ähnlich einer Tupel-Struktur).BankTransfer: Nutzt ebenfalls benannte Felder.
Nun definieren wir den Zustand des gesamten Bezahlvorgangs:
#![allow(unused)]
fn main() {
#[derive(Debug)]
enum PaymentState {
Created,
Authorized { user: String },
Processing { amount: f64, method: PaymentMethod },
Completed { transaction_id: String },
Failed(String),
}
}
- Jede Variante speichert exakt die Daten, die für diesen Zustand relevant sind. Im Zustand
Createdgibt es keine Daten. Im ZustandProcessingkennen wir den Betrag und diePaymentMethod.
2.2 CDD Deep Dive: Der Compiler erzwingt Vollständigkeit (Exhaustiveness)
Der größte Vorteil beim Arbeiten mit Enums in Rust ist die vollständige Prüfung (Exhaustiveness Check) durch den Compiler.
Der fehlerhafte Code:
Wir schreiben eine Funktion, die den Status auswertet, vergessen aber bewusst die Behandlung der Variante Failed.
#![allow(unused)]
fn main() {
fn print_payment_status(state: &PaymentState) {
match state {
PaymentState::Created => println!("Zahlung wurde erstellt."),
PaymentState::Authorized { user } => println!("Benutzer {} wurde autorisiert.", user),
PaymentState::Processing { amount, method } => {
println!("Betrag von {} € wird verarbeitet via {:?}", amount, method);
}
PaymentState::Completed { transaction_id } => {
println!("Erfolgreich! Transaktions-ID: {}", transaction_id);
}
// FEHLER: PaymentState::Failed wird ignoriert!
}
}
}
Die Reaktion des Compilers:
Wenn wir versuchen, diesen Code zu kompilieren, stoppt uns der Compiler sofort mit einer detaillierten Fehlermeldung:
error[E0004]: non-exhaustive patterns: `Failed(_)` not covered
--> src/main.rs:18:11
|
10 | / enum PaymentState {
11 | | Created,
12 | | Authorized { user: String },
13 | | Processing { amount: f64, method: PaymentMethod },
14 | | Completed { transaction_id: String },
15 | | Failed(String),
| | -------------- not covered
16 | | }
| |_- `PaymentState` defined here
...
18 | match state {
| ^^^^^ pattern `Failed(_)` not covered
|
= note: the matched value is of type `PaymentState`
= help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
= help: add `#![deny(non_exhaustive_omitted_patterns)]` to suggest missing cases
Warum lehnt der Compiler das ab?
In C++ oder Java würde ein vergessenes case im switch-Statement einfach stillschweigend ignoriert werden oder zu einem Laufzeitfehler führen. Rust garantiert auf Sprachebene: Ein match-Ausdruck darf niemals unvollständig sein. Wenn sich der Bezahlvorgang im Zustand Failed befindet und wir diesen Zustand nicht behandeln, wüsste das Programm zur Laufzeit nicht, was es tun soll. Der Compiler verhindert diesen potenziellen Absturz bereits vor dem Ausführen.
Wie beheben wir das?
Wir müssen die Variante im match-Block explizit abfangen. Alternativ könnten wir einen Standardfall (_) nutzen, dies wird jedoch bei Zustandsautomaten oft als schlechte Praxis angesehen, da neue Zustände (z. B. Refunded) dann unbemerkt im Standard-Arm landen würden.
#![allow(unused)]
fn main() {
// Die Korrektur:
PaymentState::Failed(reason) => {
println!("Zahlung fehlgeschlagen! Grund: {}", reason);
}
}
3. Die vollständige Musterlösung
Der fertige Code der Übung befindet sich unter solutions/09_enums/src/main.rs:
1: // Musterlösung: Zustandsautomat für Bezahlvorgang über Enums
2:
3: #[derive(Debug)]
4: enum PaymentMethod {
5: CreditCard { card_number: String, holder: String },
6: PayPal(String),
7: BankTransfer { iban: String },
8: }
9:
10: #[derive(Debug)]
11: enum PaymentState {
12: Created,
13: Authorized { user: String },
14: Processing { amount: f64, method: PaymentMethod },
15: Completed { transaction_id: String },
16: Failed(String),
17: }
18:
19: impl PaymentState {
20: // Überführt den Zustand in den nächsten Schritt (Besitzübergabe)
21: fn transition_to_processing(self, amount: f64, method: PaymentMethod) -> Result<Self, String> {
22: match self {
23: PaymentState::Authorized { user: _ } => {
24: Ok(PaymentState::Processing { amount, method })
25: }
26: _ => Err(String::from(
27: "Zustandsübergang zu 'Processing' ist nur aus dem Zustand 'Authorized' möglich!"
28: )),
29: }
30: }
31:
32: fn transition_to_completed(self, transaction_id: String) -> Result<Self, String> {
33: match self {
34: PaymentState::Processing { amount: _, method: _ } => {
35: Ok(PaymentState::Completed { transaction_id })
36: }
37: _ => Err(String::from(
38: "Zustandsübergang zu 'Completed' ist nur aus 'Processing' möglich!"
39: )),
40: }
41: }
42: }
43:
44: fn print_payment_status(state: &PaymentState) {
45: match state {
46: PaymentState::Created => {
47: println!("[STATUS] Zahlung initiiert. Warte auf Autorisierung.");
48: }
49: PaymentState::Authorized { user } => {
50: println!("[STATUS] Kunde '{}' ist autorisiert.", user);
51: }
52: PaymentState::Processing { amount, method } => {
53: print!("[STATUS] Verarbeite {} € via ", amount);
54: match method {
55: PaymentMethod::CreditCard { card_number, holder } => {
56: println!("Kreditkarte (Inhaber: {}, Nummer: {})", holder, card_number);
57: }
58: PaymentMethod::PayPal(email) => {
59: println!("PayPal (Konto: {})", email);
60: }
61: PaymentMethod::BankTransfer { iban } => {
62: println!("Überweisung (IBAN: {})", iban);
63: }
64: }
65: }
66: PaymentState::Completed { transaction_id } => {
67: println!("[STATUS] Zahlung erfolgreich abgeschlossen. ID: {}", transaction_id);
68: }
69: PaymentState::Failed(reason) => {
70: println!("[STATUS] ZAHLUNG FEHLGESCHLAGEN. Grund: {}", reason);
71: }
72: }
73: }
74:
75: fn main() {
76: // 1. Initialer Zustand
77: let state1 = PaymentState::Created;
78: print_payment_status(&state1);
79:
80: // 2. Autorisierung des Benutzers
81: let state2 = PaymentState::Authorized {
82: user: String::from("Thorsten"),
83: };
84: print_payment_status(&state2);
85:
86: // 3. Übergang in Verarbeitung
87: let method = PaymentMethod::PayPal(String::from("thorsten@example.com"));
88: let state3 = match state2.transition_to_processing(49.99, method) {
89: Ok(s) => s,
90: Err(e) => {
91: println!("Fehler beim Übergang: {}", e);
92: return;
93: }
94: };
95: print_payment_status(&state3);
96:
97: // 4. Abschluss der Zahlung
98: let final_state = match state3.transition_to_completed(String::from("TX-998877")) {
99: Ok(s) => s,
100: Err(e) => {
101: println!("Fehler beim Abschluss: {}", e);
102: return;
103: }
104: };
105: print_payment_status(&final_state);
106:
107: // 5. Test eines ungültigen Übergangs
108: let test_state = PaymentState::Created;
109: let invalid_transition = test_state.transition_to_completed(String::from("TX-FAIL"));
110: assert!(invalid_transition.is_err());
111: println!("\nValidierung erfolgreich: Ungültiger Zustandsübergang wurde blockiert!");
112: }
4. Anatomische Zeilenzerlegung und Detail-Analyse
Lassen Sie uns den Code der Musterlösung nun Zeile für Zeile genau analysieren:
- Zeilen 4–8: Das Enum
PaymentMethodbeschreibt die verfügbaren Zahlungsarten. Bei der Kreditkarte kapseln wir Kartennummer und Inhaber als benannte Strukturfelder. Bei PayPal nutzen wir ein anonymes Tupelfeld, das nur einen String (E-Mail) hält. - Zeilen 10–17: Das Enum
PaymentStatebeschreibt die Zustände. Das ist ein fantastisches Beispiel für datenhaltige Varianten. Je nach Lebenszyklus-Zustand des Objekts existieren unterschiedliche Daten im Speicher. - Zeilen 21–30: Die Methode
transition_to_processing.- Zeile 21:
fn transition_to_processing(self, amount: f64, method: PaymentMethod) -> Result<Self, String>– Beachten Sie, dass diese Methodeself(Besitzübergabe) und nicht&selffordert. Durch die Übergabe vonselfwird das alte Zustandsobjekt inmain()konsumiert (moved) und somit zerstört. Der Aufrufer erhält ein neues Zustandsobjekt zurück. Das verhindert, dass wir nach einer Statusänderung versehentlich noch mit dem veralteten Zustand weiterarbeiten! - Zeilen 22–29: Wir machen ein Pattern Matching auf
self.PaymentState::Authorized { user: _ }– Nur wenn der ZustandAuthorizedwar, erlauben wir den Übergang. Das Zeichen_(Wildcard) sagt dem Compiler: “Wir brauchen den Benutzernamen hier nicht, wir prüfen nur, ob die Variante stimmt.”_ => Err(...)– Befindet sich das Objekt in einem anderen Zustand (z. B. noch inCreatedoder bereits inCompleted), verweigern wir den Übergang und geben eine Fehlermeldung zurück.
- Zeile 21:
- Zeilen 44–73: Die Funktion
print_payment_statusnimmt eine unveränderliche Referenz&PaymentStateentgegen, da das Anzeigen des Status das Objekt nicht zerstören oder modifizieren soll.- Zeilen 45–72: Ein großer
match-Block. Hier sieht man die Eleganz von Rusts Pattern Matching. Wir können nicht nur den Zustand prüfen, sondern gleichzeitig die darin enthaltenen Daten entpacken und an lokale Variablen (wieuser,amount,method) binden (Destrukturierung). - Zeilen 54–64: Verschachteltes Pattern Matching (Nested Match). Das Feld
methodist selbst ein Enum (PaymentMethod). Wir matchen es innerhalb der Behandlung vonProcessing, um die spezifischen Details der Zahlungsart auszugeben.
- Zeilen 45–72: Ein großer
- Zeilen 88–94: In
main()rufen wirtransition_to_processingaufstate2auf. Da diese Methode das Ownership vonstate2verbraucht, iststate2danach ungültig. Wir speichern das Ergebnis des Übergangs instate3. - Zeilen 108–110: Wir verifizieren unser Sicherheitsnetz. Wir erstellen einen neuen Zustand
Createdund versuchen, direkt zuCompletedzu springen. Der Rückgabewert ist einErr, den wir mitassert!(invalid_transition.is_err())überprüfen.