Zwięzła kontrola przepływu z if let i let...else
Składnia if let pozwala połączyć if i let w mniej rozwlekły sposób obsługi wartości pasujących do jednego wzorca, ignorując pozostałe. Rozważ program z Listingu 6-6, który dopasowuje wartość Option<u8> w zmiennej config_max, ale chce wykonać kod tylko wtedy, gdy wartość jest wariantem Some.
fn main() {
let config_max = Some(3u8);
match config_max {
Some(max) => println!("Maksymalna wartość skonfigurowana to {max}"),
_ => (),
}
}
Jeśli wartość jest Some, drukujemy wartość z wariantu Some, wiążąc ją ze zmienną max we wzorcu. Nie chcemy nic robić z wartością None. Aby spełnić wyrażenie match, musimy dodać _ => () po przetworzeniu tylko jednego wariantu, co jest irytującym, powtarzalnym kodem do dodania.
Zamiast tego, moglibyśmy to napisać w krótszy sposób za pomocą if let. Poniższy kod zachowuje się tak samo jak match w Listingu 6-6:
fn main() {
let config_max = Some(3u8);
if let Some(max) = config_max {
println!("Maksymalna wartość skonfigurowana to {max}");
}
}
Składnia if let przyjmuje wzorzec i wyrażenie oddzielone znakiem równości. Działa tak samo jak match, gdzie wyrażenie jest przekazywane do match, a wzorzec jest jego pierwszym ramieniem. W tym przypadku wzorzec to Some(max), a max wiąże się z wartością wewnątrz Some. Możemy następnie użyć max w ciele bloku if let w ten sam sposób, w jaki użyliśmy max w odpowiadającym ramieniu match. Kod w bloku if let jest wykonywany tylko wtedy, gdy wartość pasuje do wzorca.
Używanie if let oznacza mniej pisania, mniej wcięć i mniej kodu szablonowego. Tracisz jednak wyczerpujące sprawdzanie, które wymusza match, a które gwarantuje, że nie zapomnisz obsłużyć żadnych przypadków. Wybór między match a if let zależy od tego, co robisz w konkretnej sytuacji i czy zyskanie zwięzłości jest odpowiednim kompromisem za utratę wyczerpującego sprawdzania.
Innymi słowy, if let można traktować jako składnię cukrową dla match, która uruchamia kod, gdy wartość pasuje do jednego wzorca, a następnie ignoruje wszystkie inne wartości.
Możemy dołączyć else do if let. Blok kodu, który towarzyszy else, jest taki sam jak blok kodu, który towarzyszyłby przypadkowi _ w wyrażeniu match równoważnym if let i else. Przypomnij sobie definicję enum Coin w Listingu 6-4, gdzie wariant Quarter zawierał również wartość UsState. Gdybyśmy chcieli zliczać wszystkie monety inne niż ćwierćdolarówki, jednocześnie ogłaszając stan ćwierćdolarówek, moglibyśmy to zrobić za pomocą wyrażenia match, tak jak to:
#[derive(Debug)]
enum UsState {
Alabama,
Alaska,
// --snip--
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
fn main() {
let coin = Coin::Penny;
let mut count = 0;
match coin {
Coin::Quarter(state) => println!("Moneta stanowa z {state:?}!"),
_ => count += 1,
}
}
Albo moglibyśmy użyć wyrażenia if let i else, w ten sposób:
#[derive(Debug)]
enum UsState {
Alabama,
Alaska,
// --snip--
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
fn main() {
let coin = Coin::Penny;
let mut count = 0;
if let Coin::Quarter(state) = coin {
println!("Moneta stanowa z {state:?}!");
} else {
count += 1;
}
}
Pozostawanie na „szczęśliwej ścieżce” z let...else
Częstym wzorcem jest wykonanie pewnego obliczenia, gdy wartość jest obecna, a w przeciwnym razie zwrócenie wartości domyślnej. Kontynuując nasz przykład monet z wartością UsState, gdybyśmy chcieli powiedzieć coś zabawnego w zależności od wieku stanu na monecie, moglibyśmy wprowadzić metodę w UsState do sprawdzania wieku stanu, w ten sposób:
#[derive(Debug)] // abyśmy mogli za chwilę sprawdzić stan
enum UsState {
Alabama,
Alaska,
// --snip--
}
impl UsState {
fn existed_in(&self, year: u16) -> bool {
match self {
UsState::Alabama => year >= 1819,
UsState::Alaska => year >= 1959,
// -- snip --
}
}
}
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:?} jest dość stare, jak na Amerykę!"))
} else {
Some(format!("{state:?} jest stosunkowo nowe."))
}
} else {
None
}
}
fn main() {
if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
println!("{desc}");
}
}
Następnie, moglibyśmy użyć if let, aby dopasować typ monety, wprowadzając zmienną state w treści warunku, jak w Listingu 6-7.
#[derive(Debug)] // abyśmy mogli za chwilę sprawdzić stan
enum UsState {
Alabama,
Alaska,
// --snip--
}
impl UsState {
fn existed_in(&self, year: u16) -> bool {
match self {
UsState::Alabama => year >= 1819,
UsState::Alaska => year >= 1959,
// -- snip --
}
}
}
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:?} jest dość stare, jak na Amerykę!"))
} else {
Some(format!("{state:?} jest stosunkowo nowe."))
}
} else {
None
}
}
fn main() {
if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
println!("{desc}");
}
}
To załatwia sprawę, ale przenosi pracę do ciała instrukcji if let, a jeśli praca do wykonania jest bardziej skomplikowana, trudno może być dokładnie śledzić, jak gałęzie najwyższego poziomu się ze sobą łączą. Moglibyśmy również wykorzystać fakt, że wyrażenia produkują wartość, aby albo wyprodukować state z if let, albo zwrócić wcześnie, jak w Listingu 6-8. (Podobną rzecz można by zrobić z match.)
#[derive(Debug)] // abyśmy mogli za chwilę sprawdzić stan
enum UsState {
Alabama,
Alaska,
// --snip--
}
impl UsState {
fn existed_in(&self, year: u16) -> bool {
match self {
UsState::Alabama => year >= 1819,
UsState::Alaska => year >= 1959,
// -- snip --
}
}
}
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:?} jest dość stare, jak na Amerykę!"))
} else {
Some(format!("{state:?} jest stosunkowo nowe."))
}
}
fn main() {
if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
println!("{desc}");
}
}
To jest w pewnym sensie trochę irytujące! Jedna gałąź if let produkuje wartość, a druga całkowicie zwraca z funkcji.
Aby ten powszechny wzorzec był łatwiejszy do wyrażenia, Rust posiada let...else. Składnia let...else przyjmuje wzorzec po lewej stronie i wyrażenie po prawej, bardzo podobnie do if let, ale nie ma gałęzi if, tylko gałąź else. Jeśli wzorzec pasuje, to wiąże wartość ze wzorca w zewnętrznym zasięgu. Jeśli wzorzec nie pasuje, program przechodzi do gałęzi else, która musi zwrócić wartość z funkcji.
W Listingu 6-9 możesz zobaczyć, jak wygląda Listing 6-8, gdy używamy let...else zamiast if let.
#[derive(Debug)] // abyśmy mogli za chwilę sprawdzić stan
enum UsState {
Alabama,
Alaska,
// --snip--
}
impl UsState {
fn existed_in(&self, year: u16) -> bool {
match self {
UsState::Alabama => year >= 1819,
UsState::Alaska => year >= 1959,
// -- snip --
}
}
}
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:?} jest dość stare, jak na Amerykę!"))
} else {
Some(format!("{state:?} jest stosunkowo nowe."))
}
}
fn main() {
if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
println!("{desc}");
}
}
Zauważ, że w ten sposób pozostaje ona na „szczęśliwej ścieżce” w głównym ciele funkcji, bez znacząco różniącego się przepływu sterowania dla dwóch gałęzi, tak jak to było w przypadku if let.
Jeśli znajdziesz się w sytuacji, w której logika Twojego programu jest zbyt rozwlekła, aby wyrazić ją za pomocą match, pamiętaj, że if let i let...else również są w Twojej skrzynce narzędziowej Rust.
Podsumowanie
Omówiliśmy, jak używać typów wyliczeniowych do tworzenia niestandardowych typów, które mogą być jednym z zestawu wyliczonych wartości. Pokazaliśmy, jak typ Option<T> z biblioteki standardowej pomaga używać systemu typów do zapobiegania błędom. Gdy wartości wyliczeniowe zawierają dane, możesz użyć match lub if let do wyodrębnienia i użycia tych wartości, w zależności od liczby przypadków, które musisz obsłużyć.
Twoje programy w Rust mogą teraz wyrażać koncepcje w Twojej dziedzinie za pomocą struktur i typów wyliczeniowych. Tworzenie niestandardowych typów do użycia w Twoim API zapewnia bezpieczeństwo typów: kompilator upewni się, że Twoje funkcje otrzymują tylko wartości typu, którego oczekuje każda funkcja.
Aby zapewnić użytkownikom dobrze zorganizowane API, które jest proste w użyciu i eksponuje dokładnie to, czego będą potrzebować, przejdźmy teraz do modułów Rust.