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:
Pendingist eine Unit-Variante, ohne Daten.Paid(u64)ist eine Tuple-Variante, die den bezahlten Betrag nach Position trägt.Shipped { .. }undCancelled { .. }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:
statushat den Typ&OrderStatus. Dank Default Binding Modes schreibst du Patterns wie mit gewöhnlichen Werten. Die Variablenamount,carrier,trackingwerden zu Referenzen (&u64,&String), sodass keine Daten verschoben werden.matchist ein Ausdruck. Alle Branches müssen denselben Typ zurückgeben, hierString.
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, obtotalim Bereich von 1 Million oder höher liegt, und bindet den Wert zugleich ant.CustomerTier::Silver | CustomerTier::Regularverwendet|, um mehrere Patterns in einem Branch zu kombinieren.if t >= 2_000_000ist ein Match Guard. Es ist eine zusätzliche Bedingung, die nur nach erfolgreichem Pattern-Match überprüft wird._ => 0ist der Standard-Branch. Er ist erforderlich, dau64unendlich 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 keinenShipped-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 einboolzurück. Das Makro ist sehr praktisch infilteroderassert!.while let Some(status) = queue.pop()iteriert, bispop()Nonezurü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:
shipundcancelnehmenselfnach 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 Variableother, um sie in der Fehlermeldung zu verwenden.is_finalliest 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.
matchmit Binding,|, Guards, Ranges,@und...OptionundResultals 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.