Лаконичное управление потоком с 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, который прост в использовании и предоставляет только то, что нужно.