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: 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:


1. Das Praxis-Szenario: Der Bezahl-Zustandsautomat

Wenn Kunden in unserem Online-Shop einkaufen, durchläuft der Bezahlvorgang eine klar definierte Reihe von Schritten:

  1. Erstellt (Created): Der Warenkorb wurde abgeschickt, es sind noch keine Kundendaten oder Zahlungsmittel hinterlegt.
  2. Autorisiert (Authorized): Der Kunde hat sich identifiziert (z. B. Benutzername hinterlegt).
  3. In Bearbeitung (Processing): Der Geldbetrag und die gewählte Zahlungsmethode (Kreditkarte, PayPal oder Überweisung) wurden übermittelt.
  4. Erfolgreich abgeschlossen (Completed): Die Zahlung war erfolgreich. Wir speichern eine eindeutige Transaktions-ID.
  5. Fehlgeschlagen (Failed): Die Zahlung schlug fehl. Wir speichern eine Fehlermeldung.

Wir werden:

  • Ein Enum PaymentMethod definieren, das unterschiedliche Daten je nach Zahlungsart speichert (z. B. Kartennummer für Kreditkarte oder E-Mail-Adresse für PayPal).
  • Ein Enum PaymentState definieren, 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 Created gibt es keine Daten. Im Zustand Processing kennen wir den Betrag und die PaymentMethod.

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 PaymentMethod beschreibt 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 PaymentState beschreibt 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 Methode self (Besitzübergabe) und nicht &self fordert. Durch die Übergabe von self wird das alte Zustandsobjekt in main() 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 Zustand Authorized war, 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 in Created oder bereits in Completed), verweigern wir den Übergang und geben eine Fehlermeldung zurück.
  • Zeilen 44–73: Die Funktion print_payment_status nimmt eine unveränderliche Referenz &PaymentState entgegen, 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 (wie user, amount, method) binden (Destrukturierung).
    • Zeilen 54–64: Verschachteltes Pattern Matching (Nested Match). Das Feld method ist selbst ein Enum (PaymentMethod). Wir matchen es innerhalb der Behandlung von Processing, um die spezifischen Details der Zahlungsart auszugeben.
  • Zeilen 88–94: In main() rufen wir transition_to_processing auf state2 auf. Da diese Methode das Ownership von state2 verbraucht, ist state2 danach ungültig. Wir speichern das Ergebnis des Übergangs in state3.
  • Zeilen 108–110: Wir verifizieren unser Sicherheitsnetz. Wir erstellen einen neuen Zustand Created und versuchen, direkt zu Completed zu springen. Der Rückgabewert ist ein Err, den wir mit assert!(invalid_transition.is_err()) überprüfen.