Лаконичное управление потоком с if let
и let else
Синтаксис if let
позволяет скомбинировать if
и let
в конструкцию, менее многословно обрабатывающую значения, соответствующие только одному шаблону, одновременно игнорируя все остальные варианты. Рассмотрим программу в Листинге 6-6, которая проводит сопоставление значения Option<u8>
переменной config_max
, но которая собирается выполнять код только в том случае, если значение является Some
.
fn main() { let config_max = Some(3u8); match config_max { Some(max) => println!("Максимум выставлен на {max}"), _ => (), } }
Если значение равно Some
, мы распечатываем значение в варианте Some
, привязывая его значение к переменной max
в шаблоне. Мы ничего не хотим делать со значением None
. Чтобы удовлетворить выражение match
, мы должны добавить _ => ()
после обработки первой и единственной ветви. Однако сразу возникает чувство, что для этой задачи хорошо бы иметь инструмент попроще и покороче.
Собственно, мы могли бы написать всё более кратко, воспользовавшись if let
. Следующий код ведёт себя так же, как выражение match
в Листинге 6-6:
fn main() { let config_max = Some(3u8); if let Some(max) = config_max { println!("Максимум выставлен на {max}"); } }
Синтаксис if let
принимает шаблон и выражение, разделённые знаком равенства. Он работает так же, как match
, которому на вход подают выражение, отвечающее шаблону первой ветви. В данном случае шаблоном является Some(max)
, где max
привязывается к значению внутри Some
. После сопоставления, мы можем использовать max
в теле блока if let
так же, как мы использовали max
в соответствующей ветке match
. Код в блоке if let
не запускается, если значение не отвечает шаблону.
Используя if let
, мы печатаем меньше, делаем меньше отступов и получаем меньше повторяющегося кода. Тем не менее, мы теряем полную проверку всех вариантов, предоставляемую выражением match
. Выбор между match
и if let
зависит от того, что вы делаете в вашем конкретном случае и удовлетворяет ли вас потеря полноты сопоставления ради лаконичности.
Другими словами, вы можете думать о конструкции if let
как о синтаксическом сахаре для match
, выполняющем код, если входное значение будет отвечать единственному шаблону, и проигнорирует все остальные значения.
Можно добавлять else
к if let
. Блок кода, который находится внутри else
, аналогичен по смыслу блоку кода ветви выражения match
(если оно эквивалентно сборной конструкции if let
и else
), связанной с шаблоном _
. Вспомним объявление перечисления Coin
в Листинге 6-4, где вариант Quarter
также содержит внутри себя значение штата типа UsState
. Если бы мы хотели посчитать все монеты, не являющиеся четвертаками, а для четвертаков лишь печатать название штата, то с помощью выражения match
мы могли бы сделать это таким образом:
#[derive(Debug)] enum UsState { Alabama, Alaska, // --код сокращён-- } enum Coin { Penny, Nickel, Dime, Quarter(UsState), } fn main() { let coin = Coin::Penny; let mut count = 0; match coin { Coin::Quarter(state) => println!("Четвертак из штата {state:?}!"), _ => count += 1, } }
Или мы могли бы использовать выражение if let
и else
; вот так:
#[derive(Debug)] enum UsState { Alabama, Alaska, // --код сокращён-- } enum Coin { Penny, Nickel, Dime, Quarter(UsState), } fn main() { let coin = Coin::Penny; let mut count = 0; if let Coin::Quarter(state) = coin { println!("Четвертак из штата {state:?}!"); } else { count += 1; } }
Красивый выход при ошибкоопасных значениях с помощью let else
Одна из распространённых задач заключается в выполнении некоторых вычислений (при наличии значения) и возврате значения по умолчанию (в противном случае). Продолжая наш пример с монетами с дополнительным значением UsState
: если бы мы хотели сказать что-то забавное в зависимости от того, насколько стар штат на монете, мы могли бы написать метод для типа UsState
, который осуществляет проверку возраста штата; например, так:
#[derive(Debug)] // нужно, чтобы мы могли легко посмотреть конкретный штат enum UsState { Alabama, Alaska, // --код сокращён-- } impl UsState { fn existed_in(&self, year: u16) -> bool { match self { UsState::Alabama => year >= 1819, UsState::Alaska => year >= 1959, // -- код сокращён -- } } } 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:?} довольно староват для Америки!")) } else { Some(format!("Штат {state:?} относительно молодой.")) } } else { None } } fn main() { if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) { println!("{desc}"); } }
Затем мы могли бы использовать if let
, чтобы возвращать особенный вывод, если сталкиваемся с четвертаком. Для этого нам также понадобится ввести переменную state
, которая будет связываться со значением штата. Взгляните на Листинг 6-7:
#[derive(Debug)] // нужно, чтобы мы могли легко посмотреть конкретный штат enum UsState { Alabama, Alaska, // --код сокращён-- } impl UsState { fn existed_in(&self, year: u16) -> bool { match self { UsState::Alabama => year >= 1819, UsState::Alaska => year >= 1959, // -- код сокращён -- } } } 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:?} довольно староват для Америки!")) } else { Some(format!("Штат {state:?} относительно молодой.")) } } else { None } } fn main() { if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) { println!("{desc}"); } }
Этого достаточно для желаемого нами результата. Однако нам пришлось поместить всю работу кода в тело инструкции if let
. Если работа, которую необходимо выполнить, окажется более сложной, может быть трудно распутать, к чему именно относятся вложенные операторы if
и else
. Мы могли бы решить нашу задачу и иначе: воспользоваться тем фактом, что выражения возвращают значения, чтобы вернуть значение переменной state
из if let
(либо сразу, не дожидаясь, вернуть в блоке else
значение None
, если мы получили не четвертак). Это альтернативное решение приведено в Листинге 6-8. (Вы могли бы написать всё и через match
, конечно же!)
#[derive(Debug)] // нужно, чтобы мы могли легко посмотреть конкретный штат enum UsState { Alabama, Alaska, // --код сокращён-- } impl UsState { fn existed_in(&self, year: u16) -> bool { match self { UsState::Alabama => year >= 1819, UsState::Alaska => year >= 1959, // -- код сокращён -- } } } 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:?} довольно староват для Америки!")) } else { Some(format!("Штат {state:?} относительно молодой.")) } } fn main() { if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) { println!("{desc}"); } }
Однако и это решение несколько раздражает, поскольку каждая ветвь if let
ведёт себя совершенно по-своему! Одна ветвь лишь вычисляется в значение, а другая прерывает всю функцию и сразу из неё что-то возвращает.
Для упрощения подобных выражений в Rust есть let
-else
. Конструкции let
-else
предоставляются шаблон слева и выражение справа (что очень похоже на if let
— но у let
-else
нет ветви if
, лишь else
). Если значение сопоставится, то значение из шаблона свяжется с переменной из внешней области видимости. Если значение не сопоставится, исполнение перейдёт в ветвь else
, которая должна вернуть значение из функции.
В Листинге 6-9 вы можете увидеть, как выглядит Листинг 6-8 с использованием let
-else
вместо if let
. Обратите внимание, что таким образом, мы выносим всю обработку особых случаев в начало функции, а весь её последующий остаток спокойно будет делать самую важную работу, без необходимости отвлекаться на что-либо ещё. С if let
наш поток пришлось делить на две ветви, и только одна из них выполняла настоящую работу.
#[derive(Debug)] // нужно, чтобы мы могли легко посмотреть конкретный штат enum UsState { Alabama, Alaska, // --код сокращён-- } impl UsState { fn existed_in(&self, year: u16) -> bool { match self { UsState::Alabama => year >= 1819, UsState::Alaska => year >= 1959, // -- код сокращён -- } } } 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:?} довольно староват для Америки!")) } else { Some(format!("Штат {state:?} относительно молодой.")) } } fn main() { if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) { println!("{desc}"); } }
Если у вас возникла ситуация, в которой логика вашей программы слишком многословна, если выражать её через match
, помните о том, что в вашем арсенале есть ещё if let
и let else
.
Подведём итоги
Мы рассмотрели, как использовать перечисления для создания пользовательских типов, представляющий перечень возможных вариантов. Мы показали, как тип стандартной библиотеки Option<T>
помогает использовать систему типов для предотвращения ошибок. Если варианты перечисления имеют данные внутри них, можно использовать match
или if let
(смотря, сколько случаев вам нужно обработать), чтобы извлечь их и воспользоваться ими.
Теперь ваши программы на Rust могут выражать концепции вашей предметной области, используя структуры и перечисления. Создание и использование пользовательских типов в вашем API обеспечивает типобезопасность: компилятор позаботится о том, чтобы функции получали значения только того типа, который они ожидают.
Теперь пришла пока поговорить о модулях в Rust. Они нужны, чтобы предоставлять пользователям вашего кода хорошо организованный API, который прост в использовании и предоставляет только то, что нужно.