Сейчас parse_command возвращает Result<Command, String>, а оператор ? сам передаёт ошибку наверх, так что лестницы из match больше нет. Но ошибка у нас — просто строка, то есть набор букв, в котором программа ничего не понимает. Сегодня заменим строку на собственный тип: он и ответит на вопрос «что именно пошло не так?», и расскажет об этом пользователю человеческим языком.
Зачем своему коду свой тип ошибки
Представьте, что врач оставляет вам записку «что-то не так». Прочитать можно, а вот сделать с ней что-нибудь полезное — нельзя: непонятно, срочно ли, к какому специалисту идти, не опечатка ли. Теперь представьте бланк, где заранее напечатан список причин и врач просто отмечает нужную галочкой. Бланк можно разобрать по пунктам, пересчитать, разложить по папкам. Строка-ошибка — это записка, а свой тип ошибки — бланк.
На языке Rust бланк с галочками — это знакомое вам перечисление (enum): каждая возможная причина — один вариант, а лишние подробности (какое слово не поняли) лежат внутри варианта. Для «Копилки» причин пять:
#[derive(Debug, PartialEq)]
enum CommandError {
Empty,
UnknownCommand(String),
BadAmount(String),
UnknownCategory(String),
MissingArgs,
}
Разбор по строкам
#[derive(Debug, PartialEq)]— просим компилятор дописать два «умения»:Debug(показ через{:?}, вы с ним знакомы) иPartialEq(сравнение через==;Categoryполучила его раньше). Оно пригодится в тестах: сравнить результат сErr(CommandError::Empty).Empty— пользователь отправил пустую строку.UnknownCommand(String)— первое слово не похоже ни на одну команду; в скобках лежит это слово.BadAmount(String)— сумма не получилась (не число, ноль, отрицательное); в скобках — то, что ввели.UnknownCategory(String)— такой категории у нас нет; в скобках — слово пользователя.MissingArgs— после «добавить» не хватает суммы или категории.
Главная польза в том, что по такому значению можно сделать match. Например, на MissingArgs можно показать подсказку по командам, а на UnknownCategory — список категорий. Со строкой так не получится: пришлось бы сравнивать куски текста и молиться, чтобы никто не поправил опечатку в одном месте и не забыл в другом. К тому же все тексты ошибок окажутся в одном месте, а не разбросаны по программе.
Display: ошибка рассказывает о себе
Попробуйте напечатать нашу ошибку через println!("{}", err) — компилятор откажется. Причина в том, что фигурные скобки {} означают «покажи как для человека», а {:?} — «покажи как для программиста». Первое умение Rust сам придумать не может: ему неизвестно, какими словами вы хотите объяснить ошибку.
Такие «умения» типа в Rust называются трейтами (от слова «черта», свойство). Трейт — это набор действий, которые тип обещает уметь. Когда вы писали #[derive(Debug)], вы просили компилятор написать умение показа за вас. Умение для людей называется Display, и его пишем мы сами — один раз для всего типа. Для этого в модуле std::fmt нужно подключить имя fmt строкой use std::fmt; в начале файла.
impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
CommandError::Empty => write!(f, "Пустая строка. Наберите помощь"),
CommandError::UnknownCommand(word) => write!(f, "Не знаю команду «{word}»"),
CommandError::BadAmount(text) => write!(f, "«{text}» — не сумма"),
CommandError::UnknownCategory(word) => write!(f, "Не знаю категорию «{word}»"),
CommandError::MissingArgs => {
write!(f, "Мало данных: добавить <сумма> <категория> [заметка]")
}
}
}
}
Разбор по строкам
impl fmt::Display for CommandError— «вот умениеDisplayдля типаCommandError». Блок устроен какimplиз модуля про структуры, только послеimplстоит имя трейта и словоfor.fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result— единственная функция, которую трейт требует. Имя и вид менять нельзя: именно её вызоветprintln!.&self— сама ошибка, аf— «листок», на который мы пишем текст для вывода. Результатfmt::Resultсообщает «получилось записать или нет».match self— смотрим, какая именно ошибка перед нами. Это обычныйmatchпо перечислению.write!(f, "...")— то же, чтоprintln!, только текст пишется не на экран, а на листокf. Перевода строки в конце нет. Значение, которое возвращаетwrite!, как раз и естьfmt::Result, поэтому ставить после него точку с запятой нельзя.UnknownCommand(word)— образец вскрывает вариант и даёт имя тому, что лежит внутри; затем{word}подставляется в текст так же, как вprintln!.- Вариант
MissingArgsзаписан с фигурными скобками только потому, что строка не уместилась бы в ширину; смысл тот же.
Почему word и text вдруг работают как строки
Мы вызываем match self, где self — ссылка. Поэтому word внутри ветки тоже ссылка на строку внутри ошибки: ошибка остаётся целой, мы только читаем её. Для {word} это неважно — печатается он одинаково.
Тексты ошибок: говорим с человеком
Хорошее сообщение об ошибке отвечает на три вопроса: что случилось, что именно не понравилось и что делать дальше. Сравните: «Ошибка!» не говорит ничего, а «Не знаю категорию «кофе»» называет виновное слово. Вот тексты для «Копилки»:
| Вариант | Когда возникает | Что увидит пользователь |
|---|---|---|
Empty | нажали Enter и ничего не набрали | Пустая строка. Наберите помощь |
UnknownCommand("привет") | первое слово не команда | Не знаю команду «привет» |
BadAmount("abc") | сумма не число или ноль | «abc» — не сумма |
UnknownCategory("кофе") | категории нет в списке | Не знаю категорию «кофе» |
MissingArgs | после «добавить» чего-то не хватает | Мало данных: добавить <сумма> <категория> [заметка] |
Заметьте: ошибка хранит что случилось (пользователь ввёл «abc»), а фразу «не сумма» придумывает только Display. Захотите поменять тон или перевести сообщения на другой язык — правите одно место, а разбор команд не трогаете.
Используем свой тип вместе с ?
Оператор ? работает так же, как раньше: если слева Err, функция сразу возвращает эту ошибку. Меняется только то, какой тип стоит в Result. Начнём с самого маленького шага — разбора суммы:
fn parse_amount(text: &str) -> Result<u32, CommandError> {
if text.is_empty() {
return Err(CommandError::MissingArgs);
}
match text.parse::<u32>() {
Ok(0) | Err(_) => Err(CommandError::BadAmount(text.to_string())),
Ok(n) => Ok(n),
}
}
Разбор по строкам
-> Result<u32, CommandError>— «верну число или нашу ошибку». Раньше на месте второго типа стоялString.if text.is_empty()— слова нет вовсе: пользователь написал «добавить» без суммы. Это не «плохая сумма», а «мало данных», поэтому возвращаемMissingArgs.text.parse::<u32>()— уже знакомый разбор числа; возвращаетResultсо своей ошибкой разбора.Ok(0) | Err(_)— одна ветка на два случая: получился ноль или разбор не удался. Подробности ошибки разбора нам не нужны, мы кладём вBadAmountто, что ввёл человек:text.to_string()делает из среза&strсобственную строку.Ok(n) => Ok(n)— нормальная сумма проходит дальше как была.
Теперь используем её в parse_command. Вот ветка «добавить» целиком — в ней два ? и знакомый по прошлому уроку переходник ok_or:
"добавить" => {
let rest = rest_after_first(line);
let amount = parse_amount(first_word(rest))?;
let rest = rest_after_first(rest);
let category_word = first_word(rest);
if category_word.is_empty() {
return Err(CommandError::MissingArgs);
}
let category = Category::from_word(category_word)
.ok_or(CommandError::UnknownCategory(category_word.to_string()))?;
let note = rest_after_first(rest);
let note = if note.is_empty() { "без заметки" } else { note };
Ok(Command::Add(Expense::new(amount, category, note)))
}
Разбор по строкам
parse_amount(first_word(rest))?— если сумма плохая,parse_commandсразу вернёт ту жеCommandError, которую вернулparse_amount; если хорошая, вamountокажется число.if category_word.is_empty()— категории нет совсем: возвращаемMissingArgs(так «добавить 100» получает ответ «мало данных», а не «не знаю категорию «»»).Category::from_word(...)возвращаетOption, а?в функции сResultсOptionне дружит. Методok_or(ошибка)превращаетSome(x)вOk(x), аNone— вErr(ошибка), и уже после этого?знает, что делать.Ok(Command::Add(...))— все шаги прошли, возвращаем готовую команду, обёрнутую вOk.
Типы ошибок должны совпасть
Все функции, которые вы вызываете через ?, должны возвращать тот же тип ошибки, что и сама функция. Если parse_amount вернёт Result<u32, String>, а parse_command — Result<Command, CommandError>, компилятор остановит сборку: у Rust есть способ превращать ошибки одну в другую, но мы не будем его писать, и поэтому типы приводим в порядок вручную.
Печать ошибки в главном цикле
После всех переделок в main остаётся одна строка в ветке Err:
Err(err) => println!("Ошибка: {err}"),
Фигурные скобки {err} сами вызывают наше умение Display, поэтому пользователь увидит «Ошибка: Не знаю категорию «кофе»». А если захочется особого поведения для отдельных ошибок, ветку можно разделить, как любой match:
Err(CommandError::MissingArgs) => print_help(),
Err(err) => println!("Ошибка: {err}"),
Первая строка ловит одну конкретную ошибку и показывает справку, вторая — все остальные. Вот чего не добиться, пока ошибка — просто строка.
Debug для себя, Display для людей
Если нужно подсмотреть, что лежит в ошибке, пока вы отлаживаете программу, печатайте {err:?}. Но пользователю показывайте только {err}: он не должен видеть слова вроде UnknownCategory("кофе").
Практика: ошибки заговорили по-человечески
Мы работаем в проекте kopilka: открываем src/main.rs. Если какая-то из ваших вспомогательных функций выглядит чуть иначе, чем в тексте, это нормально — важно, чтобы названия first_word, rest_after_first, Category::from_word и Expense::new совпадали.
Шаг 1. Подключаем fmt
В самом верху файла, рядом со строкой use std::io;, добавьте ещё одну:
use std::fmt;
use std::io;
Шаг 2. Тип ошибки и его Display
Вставьте перечисление CommandError и блок impl fmt::Display for CommandError из разделов выше. Место — сразу над parse_amount и parse_command: компилятору порядок безразличен, а читателю удобнее, когда ошибка описана до первого использования.
Шаг 3. Меняем parse_amount и parse_command
Замените parse_amount целиком на версию из предыдущего раздела (теперь она возвращает CommandError, а не String). Тексты первого урока («Сумма должна быть больше нуля», «Не число: ...») больше не нужны: ноль и не-число дают один вариант BadAmount, а фразу для человека придумает Display. Затем замените parse_command на версию ниже: в ней match по первому слову, пустая строка и незнакомая команда тоже дают ошибки, а не None.
fn parse_command(line: &str) -> Result<Command, CommandError> {
let word = first_word(line);
if word.is_empty() {
return Err(CommandError::Empty);
}
match word {
"добавить" => {
let rest = rest_after_first(line);
let amount = parse_amount(first_word(rest))?;
let rest = rest_after_first(rest);
let category_word = first_word(rest);
if category_word.is_empty() {
return Err(CommandError::MissingArgs);
}
let category = Category::from_word(category_word)
.ok_or(CommandError::UnknownCategory(category_word.to_string()))?;
let note = rest_after_first(rest);
let note = if note.is_empty() { "без заметки" } else { note };
Ok(Command::Add(Expense::new(amount, category, note)))
}
"список" => Ok(Command::List),
"отчёт" | "отчет" => Ok(Command::Report),
"помощь" => Ok(Command::Help),
"выход" => Ok(Command::Quit),
other => Err(CommandError::UnknownCommand(other.to_string())),
}
}
Разбор по строкам
let word = first_word(line);— первое слово берём один раз и дальше сравниваем вmatch.return Err(CommandError::Empty);— пустая строка (или одни пробелы) больше не проходит дальше."список" => Ok(Command::List)— прежние ветки остались, но теперь команда обёрнута вOk, а не вSome.other => Err(...)— последняя ветка без кавычек в образце: имяotherпринимает любое слово, не подошедшее выше, и мы кладём его в ошибку.
Шаг 4. Главный цикл
В main ветки Ok(...) после прошлого урока остаются как были, а меняется последняя: вместо строки из Err(message) печатаем ошибку через Display. Целиком match теперь такой:
match parse_command(&line) {
Ok(Command::Add(expense)) => {
println!("Записано: {}", expense.describe());
expenses.push(expense);
let spent = total(&expenses);
println!("Потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
print_status(spent, MONTH_LIMIT);
}
Ok(Command::List) => print_list(&expenses),
Ok(Command::Report) => print_report(&expenses),
Ok(Command::Help) => print_help(),
Ok(Command::Quit) => break,
Err(err) => println!("Ошибка: {err}"),
}
И ещё одна правка: перед match удалите проверку «пустая строка — пропустить» (if line.trim().is_empty() { continue; }). Теперь пустую строку разбирает parse_command (вариант Empty), и пользователь получит объяснение вместо молчания. Условие bytes == 0 (конец ввода) не трогайте.
Шаг 5. Запуск
В терминале, в папке проекта, запустите программу и введите строки по очереди (первая из них — просто Enter, пустая). Символ > показывает, что строку набираете вы; на экране его нет:
cargo run
Копилка 0.5
Наберите помощь, чтобы увидеть команды.
>
Ошибка: Пустая строка. Наберите помощь
> привет
Ошибка: Не знаю команду «привет»
> добавить
Ошибка: Мало данных: добавить <сумма> <категория> [заметка]
> добавить abc еда
Ошибка: «abc» — не сумма
> добавить 100 кофе
Ошибка: Не знаю категорию «кофе»
> добавить 350 еда обед в столовой
Записано: 350 руб., еда, «обед в столовой»
Потрачено: 350 руб. из 30000 руб.
Статус: в норме
> выход
Итого потрачено: 350 руб. из 30000 руб.
Заголовок пока «Копилка 0.5»: версию 0.6 поставим в практикуме. Программа больше не отвечает общим «Не понял команду»: каждый сбой получил свой текст.
Проверьте себя
- Что напечатает программа на «добавить 0 еда» и почему именно так? (Подсказка: посмотрите ветку
Ok(0).) - Что произойдёт на «добавить 100»: какая ошибка вернётся и в какой функции?
- Что изменится, если убрать
fmt::Displayи оставить вmainстроку с{err}? Попробуйте и прочитайте сообщение компилятора. - Добавьте в перечисление ещё один вариант и забудьте про него в
Display. На какую ошибкуmatchпожалуется?
Частые ошибки
Печатаем ошибку, у которой нет Display
Что видно: error[E0277]: `CommandError` doesn't implement `std::fmt::Display`, стрелка указывает на {err}. Почему: фигурные скобки без двоеточия требуют умения Display, а у типа есть только derive(Debug). Как исправить: напишите impl fmt::Display for CommandError или (для отладки) печатайте через {err:?}.
write! без f
Что видно: error: requires a destination and format arguments, like `write!(dest, "format string", args...)`. Почему: write! нужно сказать, куда писать — первым аргументом идёт f. Как исправить: write!(f, "текст {word}").
Точка с запятой после последнего write!
Что видно: error[E0308]: mismatched types и пояснение expected `Result<(), Error>`, found `()`, а рядом совет «remove this semicolon». Почему: ветка с ; ничего не возвращает, а fmt обязана вернуть fmt::Result. Как исправить: уберите ; после write! в ветке с фигурными скобками. Для веток без скобок помните про запятую после выражения, а не точку с запятой.
? не может превратить ошибку
Что видно: error[E0277]: `?` couldn't convert the error to `CommandError`, а под ней — the trait `From<String>` is not implemented. Почему: функция, которую вы вызвали через ?, всё ещё возвращает Result<_, String>, а ваша — Result<_, CommandError>. Как исправить: поменяйте тип ошибки в вызываемой функции (в нашем случае — в parse_amount) и верните Err(CommandError::...) вместо строки.
Ошибка есть, а сообщения «не то»
Что видно: на «добавить» программа пишет «не знаю категорию «»» вместо «мало данных». Почему: пустое слово дошло до Category::from_word. Как исправить: проверьте is_empty() раньше, как в ветке «добавить» выше.
Что важно запомнить
- Свой тип ошибки — это
enum: по нему можно сделатьmatch, в вариантах лежат подробности (UnknownCommand(String)). #[derive(Debug, PartialEq)]даёт показ через{:?}и сравнение через==.{}требует уменияDisplay; его пишем сами:impl fmt::Display for ...с функциейfmtиwrite!(f, ...)в каждой веткеmatch self.- После последнего
write!в ветке точку с запятой не ставим: оно возвращаетfmt::Result. ?передаётErrнаверх, если тип ошибки совпадает;Optionпревращается вResultчерезok_or(ошибка).- Ошибка хранит факт (
BadAmount("abc")), а фраза для человека живёт вDisplay; вmainпечатаемprintln!("Ошибка: {err}").
Разбор команд теперь честно говорит, что пошло не так. В следующем уроке соберём всё вместе в практикуме: программу, которую не сломать вводом.