Enum und Pattern Matching in Rust

1. Überblick

In vielen Sprachen ist ein Enum nur eine Liste benannter Konstanten. In Rust kann jede Variante eines Enums zudem ihre eigenen Daten tragen. In Kombination mit match zwingt der Compiler dich, alle möglichen Fälle zu behandeln.

Dieser Artikel führt dich durch die Definition von Enums, über match, Guards, Binding mit @, bis hin zu Option, Result, if let, let else und matches!. Danach kannst du Geschäftszustände mit Enums modellieren und häufige Fehler vermeiden.

Der Artikel verwendet Rust 1.65 oder später (benötigt für let else) und setzt keine externen Bibliotheken voraus.

2. Enums mit Daten definieren

Angenommen, du möchtest den Status einer Bestellung beschreiben:

#[derive(Debug, Clone, PartialEq)]
enum OrderStatus {
    Pending,
    Paid(u64),
    Shipped { carrier: String, tracking: String },
    Cancelled { reason: String },
}

fn main() {
    let statuses = vec![
        OrderStatus::Pending,
        OrderStatus::Paid(250_000),
        OrderStatus::Shipped {
            carrier: "GHN".to_string(),
            tracking: "GHN123".to_string(),
        },
        OrderStatus::Cancelled { reason: "Ausverkauft".to_string() },
    ];

    for s in &statuses {
        println!("{:?}", s);
    }
}

Dieses Enum hat drei Arten von Varianten:

  • Pending ist eine Unit-Variante, ohne Daten.
  • Paid(u64) ist eine Tuple-Variante, die den bezahlten Betrag nach Position trägt.
  • Shipped { .. } und Cancelled { .. } sind Struct-Varianten, die Daten mit benannten Feldern tragen.

#[derive(Debug, Clone, PartialEq)] ermöglicht es, Werte mit {:?} auszugeben, zu klonen und mit == zu vergleichen. Der Vergleich ist wichtig beim Schreiben von Tests.

Pending
Paid(250000)
Shipped { carrier: "GHN", tracking: "GHN123" }
Cancelled { reason: "Ausverkauft" }

Der Kernpunkt: Eine Pending-Bestellung kann nicht versehentlich einen Tracking-Code haben. Daten existieren nur bei der Variante, die sie braucht. Mit Structs, die viele Option-Felder haben, hast du diese Garantie nicht.

3. Grundlegende match und Vollständigkeit (Exhaustiveness)

match vergleicht einen Wert mit jedem Pattern von oben nach unten. Der erste übereinstimmende Branch wird ausgeführt:

fn describe(status: &OrderStatus) -> String {
    match status {
        OrderStatus::Pending => "Warte auf Zahlung".to_string(),
        OrderStatus::Paid(amount) => format!("Bezahlt: {amount} VND"),
        OrderStatus::Shipped { carrier, tracking } => {
            format!("Versand mit {carrier}, Nummer {tracking}")
        }
        OrderStatus::Cancelled { reason } => format!("Storniert: {reason}"),
    }
}

Einige wichtige Punkte:

  • status hat den Typ &OrderStatus. Dank Default Binding Modes schreibst du Patterns wie mit gewöhnlichen Werten. Die Variablen amount, carrier, tracking werden zu Referenzen (&u64, &String), sodass keine Daten verschoben werden.
  • match ist ein Ausdruck. Alle Branches müssen denselben Typ zurückgeben, hier String.

Wenn du jetzt den Cancelled-Branch löschst, wird der Compiler sich weigern zu kompilieren:

error[E0004]: non-exhaustive patterns: `&OrderStatus::Cancelled { .. }` not covered
 --> src/main.rs:12:11
   |
12 |     match status {
   |           ^^^^^^ pattern `&OrderStatus::Cancelled { .. }` not covered

Das ist der größte Vorteil von match. Wenn du eine neue Variante hinzufügst, etwa Refunded, melden alle match-Ausdrücke, die sie nicht handhaben, einen Fehler. Du musst nicht manuell überall nachschauen, wo Anpassungen nötig sind.

4. Fortgeschrittene Patterns: Tuple, |, Guard, Range und @

Patterns sind nicht nur für Enums. Du kannst auf einem Tuple matchen, um mehrere Bedingungen gleichzeitig zu überprüfen:

enum CustomerTier {
    Regular,
    Silver,
    Gold,
}

fn discount(tier: &CustomerTier, total: u64) -> u64 {
    match (tier, total) {
        (CustomerTier::Gold, _) => total / 10,
        (CustomerTier::Silver, t @ 1_000_000..) => t / 20,
        (CustomerTier::Silver | CustomerTier::Regular, t) if t >= 2_000_000 => 50_000,
        _ => 0,
    }
}

fn main() {
    println!("{}", discount(&CustomerTier::Gold, 300_000));
    println!("{}", discount(&CustomerTier::Silver, 1_200_000));
    println!("{}", discount(&CustomerTier::Regular, 2_500_000));
    println!("{}", discount(&CustomerTier::Regular, 100_000));
}

Jeder Branch funktioniert wie folgt:

  • _ passt auf jeden Wert und bindet keine Variable.
  • t @ 1_000_000.. prüft, ob total im Bereich von 1 Million oder höher liegt, und bindet den Wert zugleich an t.
  • CustomerTier::Silver | CustomerTier::Regular verwendet |, um mehrere Patterns in einem Branch zu kombinieren.
  • if t >= 2_000_000 ist ein Match Guard. Es ist eine zusätzliche Bedingung, die nur nach erfolgreichem Pattern-Match überprüft wird.
  • _ => 0 ist der Standard-Branch. Er ist erforderlich, da u64 unendlich viele Werte haben kann.
30000
60000
50000
0

Mit Struct-Varianten verwendest du .., um Felder zu ignorieren, die du nicht brauchst:

fn shipping_fee(status: &OrderStatus) -> u64 {
    match status {
        OrderStatus::Shipped { carrier, .. } if carrier == "GHN" => 22_000,
        OrderStatus::Shipped { .. } => 30_000,
        _ => 0,
    }
}

Beachte: Der Compiler berücksichtigt Guards nicht bei der Überprüfung der Vollständigkeit. Wenn du nur Shipped { .. } if ... schreibst, aber keinen Shipped-Branch ohne Guard hast, musst du dennoch einen anderen Branch hinzufügen, um alle Fälle abzudecken.

5. Option und Result sind auch nur Enums

Die Standardbibliothek definiert Option<T> (mit Some(T) und None) und Result<T, E> (mit Ok(T) und Err(E)). Da diese Enums sind, verarbeitest du sie mit den oben beschriebenen Werkzeugen:

fn find_customer(id: u32) -> Option<&'static str> {
    match id {
        1 => Some("An"),
        2 => Some("Binh"),
        _ => None,
    }
}

fn parse_quantity(input: &str) -> Result<u32, String> {
    match input.trim().parse::<u32>() {
        Ok(0) => Err("Menge muss größer als 0 sein".to_string()),
        Ok(n) => Ok(n),
        Err(e) => Err(format!("Ungültig: {e}")),
    }
}

fn main() {
    println!("{:?}", find_customer(2));
    println!("{:?}", find_customer(9));
    println!("{:?}", parse_quantity(" 3 "));
    println!("{:?}", parse_quantity("0"));
    println!("{:?}", parse_quantity("abc"));
}

Ok(0) ist ein verschachteltes Pattern. Es passt, wenn das Ergebnis Ok ist und der innere Wert 0 ist. Weil Ok(0) vor Ok(n) steht, wird der Wert 0 abgefangen, bevor es zum allgemeinen Branch kommt.

Some("Binh")
None
Ok(3)
Err("Menge muss größer als 0 sein")
Err("Ungültig: invalid digit found in string")

Rust hat kein null. Jeder Wert, der “abwesend sein” kann, muss durch Option gehen, und der Compiler zwingt dich, den None-Fall zu behandeln.

6. Kurzschreibweise: if let, let else, while let, matches!

Ein vollständiger match kann ausschweifend sein, wenn du dich nur für eine Variante interessierst. Rust hat einige kürzere Syntaxvarianten für diesen Fall.

6.1. if let

fn tracking_code(status: &OrderStatus) -> Option<&str> {
    if let OrderStatus::Shipped { tracking, .. } = status {
        Some(tracking.as_str())
    } else {
        None
    }
}

if let führt den Block aus, wenn das Pattern passt. Der else-Teil ist optional. Diese Syntax zwingt dich nicht, alle Fälle vollständig zu behandeln, verwende sie also nur, wenn die anderen Varianten wirklich unwichtig sind.

6.2. let else

let else passt zum Muster “früh raus”: Daten extrahieren, wenn sie passen, andernfalls sofort beenden.

fn charge(status: &OrderStatus) -> Result<u64, String> {
    let OrderStatus::Paid(amount) = status else {
        return Err("Bestellung nicht bezahlt".to_string());
    };
    Ok(*amount)
}

Nach der let-Zeile kann die Variable amount im restlichen Code verwendet werden, ohne dass ein zusätzlicher Block verschachtelt werden muss. Der else-Block muss den aktuellen Ablauf mit return, break, continue oder panic! beenden.

6.3. while let und matches!

fn main() {
    let mut queue = vec![
        OrderStatus::Paid(100_000),
        OrderStatus::Pending,
        OrderStatus::Paid(400_000),
    ];

    let paid = queue
        .iter()
        .filter(|s| matches!(s, OrderStatus::Paid(_)))
        .count();
    println!("Anzahl bezahlter Bestellungen: {paid}");

    while let Some(status) = queue.pop() {
        println!("Verarbeite: {status:?}");
    }
}
  • matches!(wert, pattern) gibt ein bool zurück. Das Makro ist sehr praktisch in filter oder assert!.
  • while let Some(status) = queue.pop() iteriert, bis pop() None zurückgibt.
Anzahl bezahlter Bestellungen: 2
Verarbeite: Paid(400000)
Verarbeite: Pending
Verarbeite: Paid(100000)

7. Methoden an Enums anhängen: Statusübergänge modellieren

Enums können wie Structs impl haben. Ein häufiger Anwendungsfall ist die Darstellung gültiger Zustandsübergänge:

impl OrderStatus {
    fn ship(self, carrier: &str, tracking: &str) -> Result<OrderStatus, String> {
        match self {
            OrderStatus::Paid(_) => Ok(OrderStatus::Shipped {
                carrier: carrier.to_string(),
                tracking: tracking.to_string(),
            }),
            other => Err(format!("Kann Bestellung im Status {other:?} nicht versenden")),
        }
    }

    fn cancel(self, reason: &str) -> Result<OrderStatus, String> {
        match self {
            OrderStatus::Pending | OrderStatus::Paid(_) => Ok(OrderStatus::Cancelled {
                reason: reason.to_string(),
            }),
            other => Err(format!("Kann Bestellung im Status {other:?} nicht stornieren")),
        }
    }

    fn is_final(&self) -> bool {
        matches!(self, OrderStatus::Cancelled { .. })
    }
}

Einige wichtige Punkte:

  • ship und cancel nehmen self nach Wert, daher wird der alte Status verbraucht. Du kannst die alte Statusvariable nach der Übergabe nicht mehr verwenden.
  • other => ... bindet alle verbleibenden Werte an die Variable other, um sie in der Fehlermeldung zu verwenden.
  • is_final liest nur, nimmt also &self.

8. Tests für Geschäftslogik schreiben

Rust hat ein eingebautes Test-Framework. Du platzierst Tests in einem #[cfg(test)]-Modul in derselben Datei:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn paid_order_can_be_shipped() {
        let result = OrderStatus::Paid(250_000).ship("GHN", "GHN123");

        assert_eq!(
            result,
            Ok(OrderStatus::Shipped {
                carrier: "GHN".to_string(),
                tracking: "GHN123".to_string(),
            })
        );
    }

    #[test]
    fn pending_order_cannot_be_shipped() {
        let result = OrderStatus::Pending.ship("GHN", "X");

        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Pending"));
    }

    #[test]
    fn cancelled_order_is_final() {
        let status = OrderStatus::Pending.cancel("Kunde hat es sich anders überlegt").unwrap();

        assert!(status.is_final());
        assert!(matches!(status, OrderStatus::Cancelled { ref reason } if reason == "Kunde hat es sich anders überlegt"));
    }
}

assert_eq! funktioniert, weil das Enum PartialEq und Debug implementiert. Wenn ein Test fehlschlägt, zeigt Rust sowohl den erwarteten als auch den tatsächlichen Wert. Im letzten Test bindet ref reason das Feld per Referenz, sodass status nicht in matches! verschoben wird.

cargo test
running 3 tests
test tests::cancelled_order_is_final ... ok
test tests::paid_order_can_be_shipped ... ok
test tests::pending_order_cannot_be_shipped ... ok

test result: ok. 3 passed; 0 failed; 0 ignored

9. Häufige Fehler und wann Enums nicht geeignet sind

Missbrauch von _ kostet Vollständigkeit. Wenn du _ => ... für ein Enum schreibst, das du selbst verwaltest, und später eine Variante hinzufügst, wird der Compiler stillschweigend bleiben. Die neue Variante fällt einfach in den Default-Branch, was nicht das ist, was du möchtest. Bei geschäftsorientierten Enums solltest du jede Variante explizit aufzählen.

Verschieben von Daten aus einer Referenz. Das Schreiben von match *status { OrderStatus::Cancelled { reason } => ... } führt zu einem Fehler:

error[E0507]: cannot move out of `status` as enum variant `Cancelled` which is behind a shared reference

Es gibt drei Wege das zu beheben: Direkt auf status (die Referenz) matchen, ref reason verwenden, oder Daten klonen, wenn du sie wirklich besitzen musst.

Allgemeinen Branch vor spezifischem platzieren. Wenn Ok(n) vor Ok(0) steht, wird der Branch Ok(0) niemals ausgeführt. Der Compiler warnt mit unreachable pattern, wirft aber keinen Fehler, sodass es leicht übersehen wird.

Zu großes Variant. Die Größe eines Enums ist so groß wie das größte Variant plus Metadaten. Wenn ein Variant ein großes Array enthält, belegt jeder Wert des Enums so viel Speicher. Wickel dieses Variant in Box<...> ein. Das Lint clippy::large_enum_variant warnt dich davor.

Beachte: Wenn du eine Bibliothek schreibst und später Varianten hinzufügen möchtest, ohne externen Code zu brechen, markiere das Enum mit #[non_exhaustive]. Dann müssen Benutzer in anderen Crates einen _-Branch haben.

Wann sollte man Enums nicht verwenden? Enums sind geeignet, wenn die Menge der Fälle geschlossen und unter deiner Kontrolle ist, etwa Bestellungsstatus oder Fehlertypen. Wenn die Menge von Typen offen ist, zum Beispiel wenn Bibliotheksnutzer neue Zahlungsmethoden hinzufügen müssen, verwende ein Trait (Box<dyn PaymentMethod> oder Generics). Das Hinzufügen einer Variante erfordert die Änderung des ursprünglichen Enums, während ein Trait von außen erweitert werden kann.

10. Fazit

Enums in Rust ermöglichen es, dass jeder Fall genau die Daten trägt, die er benötigt. match zwingt dich, alle Fälle zur Compile-Zeit zu behandeln. Du hast gelernt:

  • Unit-, Tuple- und Struct-Varianten.
  • match mit Binding, |, Guards, Ranges, @ und ...
  • Option und Result als gewöhnliche Enums.
  • Kurzsyntaxen if let, let else, while let, matches!.
  • Wie man Methoden auf Enums verwendet, um Statusübergänge zu modellieren, inklusive Tests.

Die leicht zu merkende Regel: Bei Enums, die du besitzt, liste alle Varianten auf, anstatt _ zu verwenden. Dann zeigt der Compiler dir bei jeder neuen Variante alle Stellen, an denen du Änderungen vornehmen musst.

rustenumpattern matchingmatchoption