$ sudo teach IT
Модуль 4 · Свои типы · Урок 4.3

Match и Option

Вы научитесь писать функции, которые честно говорят «ответа нет», и доставать значение из Option через match, if let и let else.

Теория + практика~30 минутСредний

Мы умеем описывать категории расходов перечислением и разбирать их через match. Но пока категория у нас всегда уже готова. А пользователь печатает слово еда текстом, и это слово нужно превратить в Category::Food. Что делать, если он напечатал кино, а такой категории нет? Для таких случаев в Rust есть Option, и его мы сегодня разберём.

Нет значения: Option вместо null

Представьте коробку. В ней может лежать что-то нужное, а может быть пусто. Прежде чем что-то достать, вы заглядываете внутрь. Именно так Rust относится к ситуации «ответа может не быть»: функция отдаёт не само значение, а коробку, и вы обязаны проверить, что в ней.

Во многих языках для «ничего» есть специальное значение вроде null или None без всяких проверок, и забытая проверка приводит к падению программы. В Rust null нет совсем. Вместо него есть тип из стандартной библиотеки, который называется Option («вариант, возможность»). Это обычное перечисление с двумя вариантами: Some(x) — «значение есть, вот оно» и None — «значения нет». Запись Option<u32> читается как «коробка, в которой может лежать число u32»; в угловых скобках указано, что именно лежит в коробке. Свои такие типы мы писать не будем, нужно только уметь их читать.

Rust · main.rs
fn half(n: u32) -> Option<u32> {
    if n % 2 == 0 {
        Some(n / 2)
    } else {
        None
    }
}

fn main() {
    println!("{:?}", half(10));
    println!("{:?}", half(7));
}
Вывод
Some(5)
None

Разбор по строкам

  • -> Option<u32> — функция возвращает не число, а коробку с числом или без него.
  • n % 2 == 0 — остаток от деления на 2 равен нулю, то есть число чётное.
  • Some(n / 2) — кладём в коробку половину числа. Слова Some и None доступны всегда, писать Option:: перед ними не нужно.
  • None — пустая коробка: нечётное число пополам нацело не делится.
  • {:?} — отладочная печать, которую вы уже знаете; она показывает и слово Some, и значение внутри.

Главное свойство коробки: с её содержимым нельзя обращаться как с самим значением, пока вы не заглянули внутрь. Попробуйте прибавить единицу к half(10), и компилятор остановит вас:

Ошибка компилятора
error[E0369]: cannot add `{integer}` to `Option<u32>`

Это не придирка, а защита. Если бы half(7) можно было сложить с единицей, пришлось бы складывать «ничего» с числом. Компилятор заставляет вас сначала решить, что делать в обоих случаях. Самый основной способ решить — match.

match по Option: обе ветки обязательны

С match по перечислению вы уже работали: у каждого варианта своя ветка. Option — такое же перечисление, поэтому всё работает так же, с одним приятным бонусом. В образце ветки можно записать Some(h): если в коробке что-то есть, это значение автоматически попадёт в новую переменную h, и в этой ветке им можно пользоваться. Такая запись и называется сопоставлением с образцом: Rust сравнивает форму значения с образцом и, если совпало, вынимает из него части.

Rust · main.rs
fn main() {
    let n = 8;
    match half(n) {
        Some(h) => println!("Половина: {h}"),
        None => println!("Нечётное, пополам не делится"),
    }
}
Вывод
Половина: 4

Разбор по строкам

  • match half(n) — проверяем коробку, которую вернула функция (сама half из прошлого примера лежит над main).
  • Some(h) => ... — ветка для непустой коробки; число из неё попадает в h.
  • None => ... — ветка для пустой коробки; вынимать нечего, поэтому переменной нет.

Как и в прошлый раз, match обязан перечислить все возможности. Если вычеркнуть ветку None, компилятор напомнит о ней:

Ошибка компилятора
error[E0004]: non-exhaustive patterns: `None` not covered

Именно здесь Rust избавляет вас от целого класса ошибок: забыть про «ничего» просто не получится. Программа не соберётся, пока вы не скажете, что делать, когда значения нет.

Образцы шире: диапазоны, «или», условия и подчёркивание

Образец — это не только имя варианта. Им может быть и конкретное значение, и целый диапазон. Разберём четыре приёма, которые встречаются в match постоянно.

Литералы и диапазоны. Ветка может сравнивать значение с числом или строкой: 0 => ..., "еда" => .... Для промежутка пишут 1..=499 — от 1 до 499 включительно (= после двух точек значит «включая последнее»).

Несколько вариантов через |. Если для нескольких значений нужна одна и та же ветка, их соединяют чертой, как слово «или»: "метро" | "такси".

Подчёркивание _. Это образец «всё остальное». Он подходит к любому значению и ничего не запоминает. Ставят его последним, потому что ветки проверяются сверху вниз.

Rust · main.rs
fn size_word(amount: u32) -> &'static str {
    match amount {
        0 => "ничего",
        1..=499 => "мелочь",
        500..=4999 => "обычная",
        _ => "крупная",
    }
}

fn kind(word: &str) -> &'static str {
    match word {
        "метро" | "такси" | "автобус" => "транспорт",
        "хлеб" => "еда",
        _ => "неизвестно",
    }
}

Здесь size_word(499) вернёт "мелочь", а size_word(500) — "обычная": границы диапазонов входят в свои ветки. А kind("такси") вернёт "транспорт", как и для метро и автобуса. Тип результата &'static str вы уже встречали: это текст, вшитый в программу.

Условие в ветке. Иногда образца мало и хочется добавить проверку. После образца можно написать if и условие: ветка сработает, только если образец подошёл и условие верно. Такое условие называется guard («страж»).

Rust · main.rs
match half(n) {
    Some(h) if h > 100 => println!("Много: {h}"),
    Some(h) => println!("Половина: {h}"),
    None => println!("Нечётное"),
}

При n = 400 напечатается Много: 200, при n = 8 — Половина: 4, а при n = 7 — Нечётное. Порядок важен: если поставить обычную ветку Some(h) выше ветки со стражем, до стража дело никогда не дойдёт.

Ветки проверяются сверху вниз

Rust выбирает первую подошедшую ветку и дальше не смотрит. Поэтому частные случаи (конкретные числа, ветки со стражем) пишут выше, а общие (Some(h), _) — ниже. Если же _ поставить слишком рано, компилятор предупредит, что остальные ветки недостижимы.

if let и let else: когда важен только один вариант

Иногда нас интересует только один случай, а на пустую коробку достаточно не реагировать вообще. Писать для этого целый match с пустой веткой None => {} громоздко. Для таких случаев есть короткая запись if let: «если значение подходит под этот образец, то сделай вот это».

Rust · main.rs
if let Some(c) = Category::from_word("еда") {
    println!("Нашли: {}", c.title());
} else {
    println!("Такой категории нет");
}

Разбор по строкам

  • if let Some(c) = ... — читается так: «если справа лежит Some, достань значение в переменную c». Обратите внимание, что здесь один знак =, а не ==: это не сравнение, а сопоставление с образцом.
  • c.title() — внутри блока c уже обычная категория, и метод title работает как всегда.
  • else — необязательная часть: что делать, если коробка пуста.

Есть и обратная ситуация. В цикле ввода пустая коробка значит «этот ввод никуда не годится, пропускаем его», а всё остальное тело цикла должно идти с готовым значением на том же уровне, без лишней вложенности. Для этого придумана запись let else:

Rust · main.rs
let Some(category) = Category::from_word(word) else {
    println!("Не знаю категорию «{word}»");
    continue;
};
// здесь category уже обычная Category, не коробка

Читается так: «пусть category будет значением из Some; а если там None, то выполни блок else». Правило одно: блок else обязан не продолжать выполнение дальше — то есть закончиться через continue, break или return (а также паникой). Иначе после него переменной category просто не было бы.

Что выбрать

Нужны обе ветки — берите match. Интересен один вариант, остальное можно пропустить — if let. Нужно «достать значение или выйти из этого места» — let else. Все три записи делают одно и то же, отличается только удобство.

Полезные методы Option

У коробки Option есть несколько готовых методов (вспомните: метод вызывается через точку), которые закрывают самые частые случаи без match.

  • unwrap_or(запасное) — достаёт значение, а если коробка пуста, возвращает запасное. None::<u32>.unwrap_or(7) даст 7, а Some(3).unwrap_or(7) — 3.
  • is_some() и is_none() — отвечают true или false: есть что-то в коробке или нет. Само значение они не отдают.

Теперь станет понятным и то, что вы встречали раньше. Когда вы разбирали строку на слова, метод next() у split_whitespace() возвращал именно Option: слова может не оказаться, если строка пустая. Поэтому и писали .unwrap_or(""): «нет слова — пусть будет пустая строка». Так же устроен и find(' ') в rest_after_first: его ответы Some(i) и None — это тот же Option, только внутри лежит номер байта. А в модуле про ввод у parse::<u32>() результат был устроен чуть сложнее, и тогда мы пользовались шаблоном с Ok и Err. Если вам важно только «получилось или нет», добавьте .ok(): "350".parse::<u32>().ok() даст Some(350), а "abc".parse::<u32>().ok() — None. Подробно про Ok и Err мы поговорим в модуле про ошибки.

А что такое unwrap

У Option есть соблазнительный метод unwrap(): он достаёт значение, а на пустой коробке аварийно останавливает программу сообщением called `Option::unwrap()` on a `None` value. Так проверка превращается в падение, как в языках с null. Привыкайте использовать match, if let или unwrap_or; когда unwrap уместен, расскажем в модуле про ошибки.

Практика: категория из слова

Научим «Копилку» превращать слово в категорию по-настоящему. Откройте src/main.rs проекта kopilka и замените всё содержимое кодом ниже. Это программа из прошлого урока (цикл команд, Expense, Command, run, разбор строки), в которой изменилось два места. Во-первых, временная функция category_from_word с веткой _ => Category::Other исчезла, а в impl Category появилась from_word, которая возвращает Option. Во-вторых, в ветке "добавить" мы разбираем этот Option через match: неизвестное слово больше не превращается молча в «другое», а даёт сообщение, и трата не записывается.

Rust · main.rs
use std::io;

const MONTH_LIMIT: u32 = 30_000;

#[derive(Debug, Clone, Copy, PartialEq)]
enum Category {
    Food,
    Transport,
    Home,
    Fun,
    Other,
}

impl Category {
    fn from_word(word: &str) -> Option<Category> {
        match word {
            "еда" => Some(Category::Food),
            "транспорт" => Some(Category::Transport),
            "дом" => Some(Category::Home),
            "отдых" => Some(Category::Fun),
            "другое" => Some(Category::Other),
            _ => None,
        }
    }

    fn title(&self) -> &'static str {
        match self {
            Category::Food => "еда",
            Category::Transport => "транспорт",
            Category::Home => "дом",
            Category::Fun => "отдых",
            Category::Other => "другое",
        }
    }
}

#[derive(Debug)]
struct Expense {
    amount: u32,
    category: Category,
    note: String,
}

impl Expense {
    fn new(amount: u32, category: Category, note: &str) -> Self {
        Expense {
            amount,
            category,
            note: note.to_string(),
        }
    }

    fn describe(&self) -> String {
        format!(
            "{} руб., {}, «{}»",
            self.amount,
            self.category.title(),
            self.note
        )
    }
}

enum Command {
    Add(Expense),
    List,
    Report,
    Help,
    Quit,
}

fn run(command: Command) {
    match command {
        Command::Add(expense) => println!("Записано: {}", expense.describe()),
        Command::List => println!("Список пока пуст"),
        Command::Report => println!("Отчёт пока пуст"),
        Command::Help => println!("Команды: добавить, список, отчёт, помощь, выход"),
        Command::Quit => println!("До свидания"),
    }
}

fn first_word(line: &str) -> &str {
    line.split_whitespace().next().unwrap_or("")
}

fn rest_after_first(line: &str) -> &str {
    let line = line.trim();
    match line.find(' ') {
        Some(i) => line[i + 1..].trim_start(),
        None => "",
    }
}

fn split_command(line: &str) -> (&str, &str) {
    (first_word(line), rest_after_first(line))
}

fn parse_amount(text: &str) -> u32 {
    match text.trim().parse::<u32>() {
        Ok(n) => n,
        Err(_) => 0,
    }
}

fn note_or_default(rest: &str) -> String {
    let note = rest.trim();
    if note.is_empty() {
        "без заметки".to_string()
    } else {
        note.to_string()
    }
}

fn print_status(spent: u32, limit: u32) {
    if spent > limit {
        println!("Статус: лимит превышен");
    } else if spent * 100 >= limit * 80 {
        println!("Статус: почти лимит");
    } else {
        println!("Статус: в норме");
    }
}

fn print_header() {
    println!("Копилка: добавить <сумма> <категория> [заметка], список, отчёт, помощь, выход");
}

fn main() {
    print_header();

    let mut spent: u32 = 0;
    let mut line = String::new();

    loop {
        line.clear();
        let bytes = io::stdin()
            .read_line(&mut line)
            .expect("не удалось прочитать строку");
        if bytes == 0 {
            break;
        }

        let (command, rest) = split_command(&line);
        match command {
            "" => continue,
            "добавить" => {
                let amount = parse_amount(first_word(rest));
                if amount == 0 {
                    println!("Это не число, попробуйте ещё раз");
                    continue;
                }
                let rest = rest_after_first(rest);
                let word = first_word(rest);
                if word.is_empty() {
                    println!("Не хватает категории, попробуйте ещё раз");
                    continue;
                }
                let category = match Category::from_word(word) {
                    Some(category) => category,
                    None => {
                        println!("Не знаю категорию «{word}»");
                        continue;
                    }
                };
                let note = note_or_default(rest_after_first(rest));

                let expense = Expense::new(amount, category, &note);
                spent += expense.amount;
                run(Command::Add(expense));
                println!("Потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
                print_status(spent, MONTH_LIMIT);
            }
            "список" => run(Command::List),
            "отчёт" | "отчет" => run(Command::Report),
            "помощь" => run(Command::Help),
            "выход" => {
                run(Command::Quit);
                break;
            }
            _ => println!("Неизвестная команда: {command}"),
        }
    }

    println!("Итого потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
}

Разбор по строкам

  • fn from_word(word: &str) -> Option<Category> — принимает слово, возвращает коробку: категорию или пустоту. Это ассоциированная функция, поэтому вызывается как Category::from_word(...).
  • match word { "еда" => Some(Category::Food), ... } — каждое известное слово кладём в Some.
  • _ => None — любое другое слово: коробка пустая.
  • let word = first_word(rest); — слово категории уже без пробелов и перевода строки: first_word режет по пробельным символам.
  • match Category::from_word(word) — ветка Some(category) отдаёт категорию дальше, ветка None объясняет, что слово не знакомо, и через continue начинает новый круг цикла, так что дальше код идёт только с готовой категорией.
  • run(Command::Add(expense)) — дальше всё как раньше: трата прибавляется к spent и печатается.

Как запустить

В папке проекта выполните команду и вводите команды по одной:

Терминал
cargo run
Вывод
Копилка: добавить <сумма> <категория> [заметка], список, отчёт, помощь, выход
Записано: 350 руб., еда, «обед в столовой»
Потрачено: 350 руб. из 30000 руб.
Статус: в норме
Не знаю категорию «кино»
Записано: 24000 руб., дом, «без заметки»
Потрачено: 24350 руб. из 30000 руб.
Статус: почти лимит
До свидания
Итого потрачено: 24350 руб. из 30000 руб.

Для примера набрано: добавить 350 еда обед в столовой, добавить 90 кино, добавить 24000 дом, выход; сами набранные строки в выводе не показаны. Если вместо Записано вы видите Не знаю категорию для слова еда, проверьте, что слово набрано маленькими буквами: сравнение точное, Еда с большой буквы не подойдёт.

Второй шаг: let else

Перепишите разбор категории с let else. В ветке "добавить" удалите весь блок let category = match Category::from_word(word) { ... }; и вставьте на его место этот код (на тот же отступ):

Rust · main.rs
                let Some(category) = Category::from_word(word) else {
                    println!("Не знаю категорию «{word}»");
                    continue;
                };

Поведение не изменилось, но код стал короче: сразу видно, что при неизвестной категории мы пропускаем круг, а всё остальное идёт с готовой category. Именно такой вариант нам пригодится в практикуме, где разбор команды будет длиннее.

Проверьте себя

  • Команды добавить 350 еда, добавить 350 транспорт, добавить 350 дом, добавить 350 отдых, добавить 350 другое дают строку Записано: 350 руб., ... с тем же словом.
  • Команда добавить 90 кино даёт Не знаю категорию «кино», сумма в spent не растёт, программа продолжает работать.
  • Удалите строку _ => None, из from_word — какая ошибка появится? Верните строку обратно.
  • Удалите из match Category::from_word(word) ветку None => ... (верните первый вариант с match, если уже перешли на let else) — какую ошибку покажет компилятор? Верните ветку обратно.
  • Добавьте в from_word слово "проезд" через | вместе с "транспорт".

Частые ошибки

Забыли ветку None

Что видно: error[E0004]: non-exhaustive patterns: `None` not covered. Почему: match по Option обязан разобрать и Some, и None. Как исправить: добавьте ветку None => ... или, если остальное безразлично, _ => ....

Сравнивают коробку со значением

Что видно: error[E0308]: mismatched types с пояснением «expected `Option<Category>`, found `Category`» при записи Category::from_word("еда") == Category::Food. Почему: слева коробка, справа сама категория, а сравнивать можно только одинаковые типы. Как исправить: достаньте значение через match или if let, либо сравнивайте коробку с коробкой: ... == Some(Category::Food) (для этого у Category есть PartialEq в derive).

Используют коробку как число

Что видно: error[E0369]: cannot add `{integer}` to `Option<u32>` — или похожая ошибка при любой попытке сложить, умножить, напечатать через {}. Почему: внутри Some число, но снаружи это ещё не число. Как исправить: сначала достаньте значение (match, if let, let else или unwrap_or).

unwrap везде

Что видно: программа собирается, а при неожиданном вводе падает с сообщением called `Option::unwrap()` on a `None` value. Почему: unwrap не проверяет коробку, а ломает программу, если она пуста. Как исправить: замените на match, let else или unwrap_or с разумным запасным значением.

Блок else в let else не выходит

Что видно: ошибка error[E0308]: `else` clause of `let...else` does not diverge. Почему: после else код пошёл бы дальше без значения. Как исправить: завершите блок словом continue, break или return.

Что важно запомнить

  • В Rust нет null: возможное отсутствие значения выражает тип Option<T> с вариантами Some(x) и None.
  • Значение внутри Option нельзя использовать напрямую; сначала его нужно достать, и компилятор следит, чтобы вы не забыли про None.
  • match по Option требует обе ветки; в образце Some(h) значение сразу попадает в переменную h.
  • Образцы бывают шире имён вариантов: числа и строки, диапазоны 1..=499, варианты через |, условие if (guard) и _ для остального; ветки проверяются сверху вниз.
  • if let удобен, когда важен один вариант; let else достаёт значение или выходит из цикла/функции через continue, break, return.
  • unwrap_or(запасное), is_some(), is_none() и .ok() избавляют от лишнего match; unwrap() роняет программу на None.
  • Шаг проекта: Category::from_word(word: &str) -> Option<Category> превращает слово пользователя в категорию или честно говорит «не знаю».

Дальше у нас практикум: из этих кирпичиков соберём функцию parse_command, которая превращает всю строку ввода в команду и заменит цепочку слов-команд в main.

На следующий урок