Prägnanter Kontrollfluss mit if let und let else

Mit der Syntax if let kannst du if und let in einer weniger wortreichen Weise kombinieren, um mit Werten umzugehen, die einem Muster entsprechen, während der Rest ignoriert wird. Betrachte das Programm in Codeblock 6-6, das auf einen Option<u8>-Wert in der Variable config_max passt, aber nur dann Code ausführen soll, wenn der Wert die Some-Variante ist.

#![allow(unused)]
fn main() {
let config_max = Some(3u8);
match config_max {
    Some(max) => println!("Das Maximum ist mit {max} konfiguriert"),
    _ => (),
}
}

Codeblock 6-6: Ein match-Ausdruck, der nur dann Code ausführt, wenn der Wert Some ist

Wenn der Wert Some ist, geben wir den Wert in der Variante Some aus, indem wir den Wert an die Variable max im Muster binden. Wir wollen nichts mit dem Wert None machen. Um den Ausdruck match zu erfüllen, müssen wir nach der Verarbeitung nur einer Variante _ => () hinzufügen, was lästiger Codeballast ist.

Stattdessen könnten wir dies in kürzerer Form schreiben, indem wir if let verwenden. Der folgende Code verhält sich genauso wie der match-Ausdruck in Codeblock 6-6:

#![allow(unused)]
fn main() {
let config_max = Some(3u8);
if let Some(max) = config_max {
    println!("Das Maximum ist mit {max} konfiguriert");
}
}

Die Syntax if let nimmt ein Muster und einen Ausdruck, getrennt durch ein Gleichheitszeichen. Sie funktioniert auf gleiche Weise wie bei match, wo der Ausdruck hinter match angegeben wird und das Muster der erste Zweig ist. In diesem Fall ist das Muster Some(max) und das max ist an den Wert innerhalb von Some gebunden. Wir können dann max im Rumpf des if let-Blocks auf die gleiche Weise verwenden, wie max im entsprechenden match-Zweig. Der Code im if let-Block wird nur ausgeführt, wenn der Wert zum Muster passt.

Die Verwendung von if let bedeutet weniger Tipparbeit, weniger Einrückung und weniger Codeanteil. Du verlierst jedoch die Prüfung auf Vollständigkeit, die match erzwingt. Die Wahl zwischen match und if let hängt davon ab, was du in der speziellen Situation machst, und davon, ob ein Gewinn an Prägnanz ein angemessener Kompromiss für den Verlust einer Prüfung auf Vollständigkeit ist.

Anders gesagt kannst du dir if let als syntaktischen Zucker für einen match-Ausdruck vorstellen, der Code nur bei Übereinstimmung mit einem Muster ausführt und alle anderen Werte ignoriert.

Wir können ein else an ein if let anhängen. Der Code-Block, der zum else gehört, ist der gleiche wie der Code-Block, der zum _-Zweig im match-Ausdruck gehören würde. Erinnere dich an die Aufzählung Coin in Codeblock 6-4, wo die Variante Quarter auch einen UsState-Wert enthielt. Wenn wir alle Nicht-25-Cent-Münzen zählen wollten, während wir die Eigenschaft der 25-Cent-Münzen ausgeben, könnten wir das mit einem match-Ausdruck wie diesem tun:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --abschneiden--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    match coin {
        Coin::Quarter(state) => println!("25-Cent-Münze aus {state:?}!"),
        _ => count += 1,
    }
}

Oder wir könnten einen Ausdruck mit if let und else wie diesen verwenden:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --abschneiden--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    if let Coin::Quarter(state) = coin {
        println!("25-Cent-Münze aus {state:?}!");
    } else {
        count += 1;
    }
}

Auf dem „richtigen Weg“ bleiben mit let...else

Ein gängiges Muster besteht darin, eine Berechnung durchzuführen, wenn ein Wert vorhanden ist, und andernfalls einen Standardwert zurückzugeben. Um bei unserem Beispiel der Münzen mit einem UsState-Wert zu bleiben: Wenn wir etwas Lustiges sagen wollten, je nachdem, wie alt der Zustand des Vierteldollars ist, könnten wir eine Methode für UsState einführen, um das Alter eines Bundesstaates zu prüfen, etwa so:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --abschneiden--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // --abschneiden--
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    if let Coin::Quarter(state) = coin {
        if state.existed_in(1900) {
            Some(format!("{state:?} ist ziemlich alt für Amerika!"))
        } else {
            Some(format!("{state:?} ist relativ neu."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

Dann könnten wir if let verwenden, um die Art der Münze zu bestimmen, und eine Variable state in den Rumpf der Bedingung einfügen, wie in Codeblock 6-7.

Dateiname: src/main.rs

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --abschneiden--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // --abschneiden--
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    if let Coin::Quarter(state) = coin {
        if state.existed_in(1900) {
            Some(format!("{state:?} ist ziemlich alt für Amerika!"))
        } else {
            Some(format!("{state:?} ist relativ neu."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

Codeblock 6-7: Prüfen, ob ein Bundesstaat im Jahr 1900 existiert, durch Verwenden von Bedingungen, die in if let verschachtelt sind.

Damit ist die Aufgabe erledigt, aber die Arbeit wurde in den Rumpf der if let-Anweisung verlagert. Und wenn die zu erledigende Arbeit komplizierter ist, könnte es schwierig sein, genau zu verfolgen, wie die Verzweigungen der obersten Ebene zusammenhängen. Wir könnten uns auch die Tatsache zunutze machen, dass Ausdrücke einen Wert erzeugen, um entweder state aus der if let-Anweisung zu setzen oder um früh zurückzukehren, wie in Codeblock 6-8. (Ähnliches könnte man auch mit einem match machen.)

Dateiname: src/main.rs

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --abschneiden--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // --abschneiden--
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let state = if let Coin::Quarter(state) = coin {
        state
    } else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} ist ziemlich alt für Amerika!"))
    } else {
        Some(format!("{state:?} ist relativ neu."))
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

Codeblock 6-8: Verwenden von if let, um einen Wert zu setzen oder frühzeitig zurückzukehren.

Das ist allerdings auf gewisse Weise schwierig zu verstehen! Ein Zweig von if let erzeugt einen Wert und der andere verlässt die Funktion vollständig.

Um dieses gängige Muster besser auszudrücken, gibt es in Rust let...else. Die let...else-Syntax nimmt ein Muster auf der linken Seite und einen Ausdruck auf der rechten Seite, sehr ähnlich zu if let, aber sie hat keinen if-Zweig, nur einen else-Zweig. Wenn das Muster passt, wird der Wert des Musters im äußeren Gültigkeitsbereich gebunden. Wenn das Muster nicht passt, wird das Programm im else-Zweig fortgesetzt, der die Funktion beendet.

In Codeblock 6-9 kannst du sehen, wie Codeblock 6-8 aussieht, wenn du let...else anstelle von if let verwendest. Beachte, dass der Funktionsrumpf auf diese Weise „auf dem richtigen Weg“ bleibt, ohne dass sich der Kontrollfluss für zwei Verzweigungen signifikant unterscheidet, wie es bei if let der Fall war.

Dateiname: src/main.rs

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --abschneiden--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // --abschneiden--
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let Coin::Quarter(state) = coin else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} ist ziemlich alt für Amerika!"))
    } else {
        Some(format!("{state:?} ist relativ neu."))
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

Codeblock 6-9: Verwenden von let...else, um den Fluss durch die Funktion klarer darzustellen.

Wenn du eine Situation hast, in der dein Programm über eine Logik verfügt, die mit einem match-Ausdruck zu wortreich auszudrücken wäre, denke daran, dass if let und let...else ebenfalls in deinem Rust-Werkzeugkasten enthalten sind.

Zusammenfassung

Wir haben uns damit befasst, wie man Aufzählungen verwendet, um benutzerdefinierte Typen zu erstellen, die zu einem Satz von Aufzählungswerten gehören können. Wir haben gezeigt, wie der Typ Option<T> der Standardbibliothek dir dabei hilft, das Typsystem zu verwenden, um Fehler zu vermeiden. Wenn Aufzählungswerte Daten enthalten, kannst du diese Werte mit match oder if let extrahieren und verwenden, je nachdem, wie viele Fälle du behandeln musst.

Deine Rust-Programme können nun Konzepte in deiner Domäne mit Hilfe von Strukturen und Aufzählungen ausdrücken. Das Erstellen benutzerdefinierter Typen zur Verwendung in deiner API gewährleistet Typsicherheit: Der Compiler wird sicherstellen, dass deine Funktionen nur Werte jenes Typs erhalten, den die Funktion erwartet.

Um deinen Nutzern eine gut organisierte API zur Verfügung zu stellen, die einfach zu benutzen ist und nur genau das offenbart, was deine Nutzer benötigen, wenden wir uns nun den Modulen von Rust zu.