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

Перечисления

Вы научитесь описывать набор вариантов через enum, разбирать его через match и хранить в варианте данные.

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

В прошлом уроке мы собрали структуру Expense, и категория в ней была обычной строкой. Но строка позволяет написать "ела" вместо "еда", и программа спокойно примет опечатку. Сегодня заменим строку типом, у которого допустимые значения перечислены заранее, а заодно подготовим тип для команд Копилки.

Варианты: список допустимых значений

Представьте выпадающий список в форме на сайте: вы не печатаете категорию руками, а выбираете из готовых пунктов. Опечататься негде, лишнего не выберешь. Нам нужен такой же «список пунктов» внутри программы.

В Rust он называется перечисление (по-английски enum, от enumeration). Вы сами перечисляете все допустимые значения, и каждое из них зовётся вариантом (variant). Значение типа-перечисления всегда равно ровно одному из вариантов.

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

fn main() {
    let category = Category::Food;
    println!("{:?}", category);
    if category == Category::Food {
        println!("это еда");
    }
}
Вывод
Food
это еда

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

  • enum Category { ... } — объявляем новый тип с именем Category; имена типов, как и у структур, пишут с заглавной буквы.
  • Food, Transport, Home, Fun, Other, — пять вариантов через запятую. Это не строки и не числа, а отдельные значения нашего типа.
  • Category::Food — как достать вариант: имя типа, два двоеточия, имя варианта. Одного Food без Category:: компилятор не знает.
  • #[derive(Debug, Clone, Copy, PartialEq)] — как и со структурой, просим компилятор самому написать четыре умения. Debug нужен для печати через {:?}, PartialEq позволяет сравнивать через ==, а Clone и Copy делают так, что значение копируется, как число (подробнее ниже).
  • category == Category::Food — сравниваем значение с вариантом; без PartialEq эта строка не соберётся.

Почему здесь работает Copy, хотя мы говорили, что перемещается всё, что не число? Потому что Category не владеет ничем в куче: это просто «номер пункта», как u32. Такие значения безопасно копировать, и после let d = c; оба имени остаются живыми.

Тип сам по себе защищает от опечаток

Раньше "ела" была бы допустимой строкой. Теперь Category::Ela просто не существует, и компилятор остановит сборку ещё до запуска. Ошибка из «потом нашли на проде» превращается в «не собралось у меня на ноутбуке».

Разбор по вариантам: match

Когда у нас есть значение-вариант, мы почти всегда хотим что-то сделать в зависимости от того, какой он. Для этого в Rust есть match (мы уже встречали его как шаблон при разборе числа). Представьте таблицу: слева образец, справа что делать, если значение подошло к образцу. Rust идёт по строкам сверху вниз и выполняет первую подошедшую.

Официально такая конструкция называется сопоставление с образцом (pattern matching), а каждая строка таблицы — ветка (arm). Главное свойство match по перечислению: он исчерпывающий. Компилятор требует, чтобы были покрыты все варианты, ни одного нельзя забыть.

Rust · main.rs
fn is_daily(category: Category) -> bool {
    match category {
        Category::Food => true,
        Category::Transport => true,
        _ => false,
    }
}

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

  • match category { — берём значение category и сравниваем его с образцами ниже.
  • Category::Food => true, — ветка: если вариант Food, результатом match будет true. Знак => читается как «тогда», ветки разделяют запятыми.
  • _ => false, — образец-подстановка: подходит ко всем остальным вариантам. Он идёт последним, потому что ветки проверяются по порядку.
  • сам match — это выражение: его значение (здесь true или false) становится результатом функции, потому что после match нет точки с запятой.

А теперь убираем подстановку и забываем вариант. Вот что делает компилятор, если в match по Category есть ветки для Food, Transport, Home и Fun, а Other пропущен:

Ошибка компилятора
error[E0004]: non-exhaustive patterns: `Category::Other` not covered
 --> src/main.rs:4:11
  |
4 |     match category {
  |           ^^^^^^^^ pattern `Category::Other` not covered
  |
note: `Category` defined here
  |
2 | enum Category { Food, Transport, Home, Fun, Other }
  |      ^^^^^^^^                               ----- not covered

Компилятор прямо называет, какого варианта не хватает. Это одно из главных удобств Rust: вы добавили новый вариант в перечисление, и компилятор сам покажет каждое место, где про него забыли.

Не злоупотребляйте подстановкой _

Ветка _ удобна, но она же и прячет забытые варианты: добавите завтра Category::Health, а _ молча отнесёт его к «остальным». Если для каждого варианта нужен свой ответ, перечисляйте варианты поимённо.

Методы перечисления

В прошлом уроке мы писали методы структур. Перечисления получают методы точно так же: отдельный блок impl с именем типа. Нам нужен метод, который превращает вариант в красивое русское слово для вывода: Category::Food должен стать "еда".

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

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

  • impl Category { — блок методов для типа Category, точно как impl Expense в прошлом уроке.
  • fn title(&self) — метод читает значение, но не меняет его, поэтому &self (одолжили на чтение).
  • -> &'static str — результат — текст, вшитый в программу (мы возвращаем литералы в кавычках, а такой текст живёт всё время работы программы). Подробнее про такую запись позже, пока просто возвращаем текст в кавычках.
  • match self { ... } — сопоставляем сам вариант; поскольку все пять перечислены, _ не нужна, и добавленный завтра вариант компилятор заставит обработать тоже.
  • Category::Other => "другое", — последняя ветка; запятая после неё допустима и принята в Rust.

Вызываем метод через точку: Category::Home.title() вернёт "дом". Заметьте, что слово для пользователя живёт в одном месте, в title, а не разбросано по программе.

Варианты с данными

Бывает, что у варианта есть «начинка». Подумайте о конвертах: на одном написано «Помощь» и он пустой, на другом написано «Добавить» и внутри лежит записка с описанием траты. Тип такого конверта один, а содержимое зависит от пометки.

В Rust вариант перечисления может нести данные: после имени в круглых скобках пишут тип того, что внутри. Так мы опишем команды Копилки: добавить требует трату, а список, отчёт, помощь и выход никаких данных не несут.

Rust · main.rs
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!("До свидания"),
    }
}

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

  • Add(Expense), — вариант Add хранит внутри одну трату, значение типа Expense.
  • List, Report, Help, Quit, — четыре варианта без начинки; пишутся просто именем.
  • fn run(command: Command) — функция принимает любую команду и решает, что с ней делать.
  • Command::Add(expense) => — образец не просто проверяет вариант, а ещё и достаёт содержимое: внутри ветки трата доступна под именем expense. Имя вы придумываете сами.
  • остальные ветки — вариантов без данных образец пишется без скобок, и перечислены все пять — match остаётся исчерпывающим.

Почему это сильнее, чем строка? Если бы команда была текстом "добавить", то трату пришлось бы хранить отдельно, а проверять, что текст написан без ошибок, приходилось бы самим и во время работы. С перечислением компилятор гарантирует две вещи: такого варианта, как Command::Dobavit, нет, а если вы создали Add, то трата внутри точно есть. Нельзя получить «добавить без суммы».

Что дальше с Command

В этом уроке мы объявим Command и проверим на нём match. В практике команды выбираются простой цепочкой match по первому слову прямо в main. Полноценную функцию parse_command, которая превращает всю строку в Command, мы напишем в практикуме модуля, когда познакомимся с Option.

Трата с настоящей категорией

Теперь подключим Category к нашей трате. Было поле category с типом строки, станет поле с типом Category. Сигнатура конструктора меняется вместе с ним: вместо строки он принимает вариант.

Rust · main.rs
#[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)
    }
}

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

  • category: Category, — поле теперь нашего собственного типа. Записать в него можно только один из пяти вариантов.
  • fn new(amount: u32, category: Category, note: &str) -> Self — вызов станет Expense::new(350, Category::Food, "обед"); число 350 и вариант не перепутать, а опечатка в слове не пройдёт.
  • note: note.to_string() — заметку приняли как &str и сделали из неё свою String, как в прошлом уроке.
  • self.category.title() — describe просит у категории её русское слово. Так describe не знает, как категория называется, он просто спрашивает её.

Практика: категория и команды в Копилке

Соберём всё в Копилке. Берём программу из прошлого урока: цикл команд остаётся, но трата теперь хранит настоящую Category, а выполнение команд берёт на себя функция run с match по Command.

  1. Откройте папку проекта kopilka и файл src/main.rs.
  2. Замените его содержимое кодом ниже. Разбор строки (first_word, rest_after_first, split_command, parse_amount, note_or_default), print_header и print_status те же, что в прошлом уроке; новое — Category, Command, run и временная функция category_from_word.
  3. В терминале в папке kopilka выполните cargo run.
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 title(&self) -> &'static str {
        match self {
            Category::Food => "еда",
            Category::Transport => "транспорт",
            Category::Home => "дом",
            Category::Fun => "отдых",
            Category::Other => "другое",
        }
    }
}

fn category_from_word(word: &str) -> Category {
    match word {
        "еда" => 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 = category_from_word(word);
                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} руб.");
}

Что здесь важно. Функция category_from_word — временная: она превращает слово в Category и всё незнакомое отправляет в Other (ветка _). Так слово кино запишется как «другое», и это нехорошо: пользователь опечатался, а программа молчит. В следующем уроке заменим её функцией, которая умеет сказать «такой категории нет». В ветке "добавить" трата сначала прибавляется к spent, а потом уходит в Command::Add(expense), то есть перемещается в команду, поэтому spent += expense.amount; стоит раньше вызова run. Остальные слова-команды выбираются цепочкой match command, где "" — пустая строка (просто нажали Enter).

Запустите cargo run и введите по очереди добавить 350 еда обед в столовой, добавить 90 кино, список, помощь, выход. Должно получиться (набранные строки в выводе не показаны):

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

Предупреждений компилятор не печатает: все пять вариантов Command создаются в цикле. Заметьте и ещё одно: Command::Add(expense) забрал трату во владение, поэтому после run пользоваться expense уже нельзя.

Теперь два эксперимента, чтобы своими глазами увидеть защиту компилятора. Каждый раз возвращайте код обратно.

  1. Удалите ветку Command::Quit => ... из run и выполните cargo check. Увидите error[E0004]: non-exhaustive patterns: `Command::Quit` not covered.
  2. Замените в category_from_word Category::Food на Category::Ela. Получите ошибку: у Category нет варианта с таким именем, опечатка поймана до запуска.

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

  • Команда добавить 90 кино записывает трату в категорию «другое», а команды список и помощь отвечают заглушками из run?
  • После удаления ветки Quit компилятор называет именно этот вариант?
  • Можете ли вы объяснить, почему Command::Add(expense) в ветке даёт доступ к трате?
  • Если добавить в Category вариант Health, в каком месте компилятор потребует поправить код?

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

1. Забыть имя типа перед вариантом

Что видно. error[E0425]: cannot find value `Food` in this scope.

Почему. Варианты живут внутри перечисления, а не сами по себе. Как исправить. Пишите Category::Food.

2. Не покрыть все варианты в match

Что видно. error[E0004]: non-exhaustive patterns: `Category::Other` not covered.

Почему. match по перечислению обязан знать, что делать с каждым вариантом. Как исправить. Добавьте недостающую ветку (лучше) или общую _ => ... (если для остальных поведение одинаковое).

3. Забыть derive и получить непонятную ошибку про {:?} или ==

Что видно. error[E0277]: `Category` doesn't implement `Debug` при печати через {:?}, или error[E0369]: binary operation `==` cannot be applied to type `Category` при сравнении.

Почему. Умения печати и сравнения компилятор сам не добавляет. Как исправить. Допишите над перечислением #[derive(Debug, Clone, Copy, PartialEq)]. Компилятор в тексте ошибки даже подсказывает, какой derive нужен.

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

  • Перечисление (enum) — тип с заранее заданным набором вариантов; значение всегда ровно один из них.
  • Вариант достают через имя типа: Category::Food. Без Category:: компилятор не найдёт его (E0425).
  • match по перечислению исчерпывающий: забыли вариант — error[E0004]. Подстановка _ покрывает все остальные.
  • У перечисления бывают методы в блоке impl, как у структуры; fn title(&self) -> &'static str превращает вариант в слово.
  • Вариант может нести данные: Add(Expense). Образец Command::Add(expense) достаёт их в ветку match.
  • Перечисление вместо строки отсекает опечатки на этапе компиляции.
  • #[derive(Debug, Clone, Copy, PartialEq)] даёт печать через {:?}, копирование как у числа и сравнение через ==.

Дальше решим, что делать, когда слово, введённое пользователем, не совпадает ни с одной категорией: для этого в Rust есть Option.

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