В прошлом уроке мы собрали структуру Expense, и категория в ней была обычной строкой. Но строка позволяет написать "ела" вместо "еда", и программа спокойно примет опечатку. Сегодня заменим строку типом, у которого допустимые значения перечислены заранее, а заодно подготовим тип для команд Копилки.
Варианты: список допустимых значений
Представьте выпадающий список в форме на сайте: вы не печатаете категорию руками, а выбираете из готовых пунктов. Опечататься негде, лишнего не выберешь. Нам нужен такой же «список пунктов» внутри программы.
В Rust он называется перечисление (по-английски enum, от enumeration). Вы сами перечисляете все допустимые значения, и каждое из них зовётся вариантом (variant). Значение типа-перечисления всегда равно ровно одному из вариантов.
#[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 по перечислению: он исчерпывающий. Компилятор требует, чтобы были покрыты все варианты, ни одного нельзя забыть.
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 должен стать "еда".
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 вариант перечисления может нести данные: после имени в круглых скобках пишут тип того, что внутри. Так мы опишем команды Копилки: добавить требует трату, а список, отчёт, помощь и выход никаких данных не несут.
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. Сигнатура конструктора меняется вместе с ним: вместо строки он принимает вариант.
#[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.
- Откройте папку проекта
kopilkaи файлsrc/main.rs. - Замените его содержимое кодом ниже. Разбор строки (
first_word,rest_after_first,split_command,parse_amount,note_or_default),print_headerиprint_statusте же, что в прошлом уроке; новое —Category,Command,runи временная функцияcategory_from_word. - В терминале в папке
kopilkaвыполнитеcargo run.
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, ¬e);
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 уже нельзя.
Теперь два эксперимента, чтобы своими глазами увидеть защиту компилятора. Каждый раз возвращайте код обратно.
- Удалите ветку
Command::Quit => ...изrunи выполнитеcargo check. Увидитеerror[E0004]: non-exhaustive patterns: `Command::Quit` not covered. - Замените в
category_from_wordCategory::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.