$ sudo teach IT
Модуль 6 · Ошибки · Урок 6.3

Свой тип ошибки

Вы опишете ошибки программы своим перечислением и научите их говорить понятным человеческим языком.

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

Сейчас parse_command возвращает Result<Command, String>, а оператор ? сам передаёт ошибку наверх, так что лестницы из match больше нет. Но ошибка у нас — просто строка, то есть набор букв, в котором программа ничего не понимает. Сегодня заменим строку на собственный тип: он и ответит на вопрос «что именно пошло не так?», и расскажет об этом пользователю человеческим языком.

Зачем своему коду свой тип ошибки

Представьте, что врач оставляет вам записку «что-то не так». Прочитать можно, а вот сделать с ней что-нибудь полезное — нельзя: непонятно, срочно ли, к какому специалисту идти, не опечатка ли. Теперь представьте бланк, где заранее напечатан список причин и врач просто отмечает нужную галочкой. Бланк можно разобрать по пунктам, пересчитать, разложить по папкам. Строка-ошибка — это записка, а свой тип ошибки — бланк.

На языке Rust бланк с галочками — это знакомое вам перечисление (enum): каждая возможная причина — один вариант, а лишние подробности (какое слово не поняли) лежат внутри варианта. Для «Копилки» причин пять:

Rust · main.rs
#[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; в начале файла.

Rust · main.rs
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. Начнём с самого маленького шага — разбора суммы:

Rust · main.rs
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:

Rust · main.rs
"добавить" => {
    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:

Rust · main.rs
Err(err) => println!("Ошибка: {err}"),

Фигурные скобки {err} сами вызывают наше умение Display, поэтому пользователь увидит «Ошибка: Не знаю категорию «кофе»». А если захочется особого поведения для отдельных ошибок, ветку можно разделить, как любой match:

Rust · main.rs
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;, добавьте ещё одну:

Rust · main.rs
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.

Rust · main.rs
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 теперь такой:

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

Разбор команд теперь честно говорит, что пошло не так. В следующем уроке соберём всё вместе в практикуме: программу, которую не сломать вводом.

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