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

Паника и Result

Вы отличите ошибку-аварию от ожидаемой ошибки и научитесь возвращать из функции Result с объяснением, что пошло не так.

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

Копилка уже умеет разбирать команды, но на плохой ввод отвечает одинаково: «Не понял команду». Напишите добавить abc еда — и программа не скажет, что не так: сумма не число, или её забыли, или она нулевая. Причина потерялась в тот момент, когда мы написали .ok() и превратили неудачу в безликое None. В этом модуле научим Копилку объяснять ошибки, а начнём с главного вопроса: что вообще делать, когда что-то пошло не так.

Два вида ошибок

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

В программах ровно так же. Первый вид — восстановимая ошибка: она случается по вине внешнего мира (пользователь ввёл «abc» вместо суммы), и программа должна её обработать и жить дальше. Второй вид — паника (panic): аварийная остановка программы, когда нарушено что-то, что нарушаться не должно, то есть в коде баг. Правило большого пальца: ошибка по вине программиста — паника, ошибка по вине ввода и внешнего мира — Result.

С паникой вы уже встречались: переполнение attempt to subtract with overflow — именно она. Вот самый частый способ её получить:

Rust · main.rs
fn main() {
    let expenses = vec![350, 1200, 99];
    println!("начало");
    let x = expenses[10];
    println!("{x}");
}

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

  • vec![350, 1200, 99] — вектор из трёх трат; индексы у него 0, 1 и 2.
  • println!("начало"); — успеет выполниться: паника случится позже.
  • expenses[10] — просим элемент с индексом 10, которого нет. Это баг в логике программы, и Rust останавливает работу.
  • println!("{x}"); — до этой строки дело не дойдёт.
Вывод
начало

thread 'main' (2096309) panicked at src/main.rs:4:21:
index out of bounds: the len is 3 but the index is 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Читайте сообщение сверху вниз: panicked at src/main.rs:4:21 — файл, строка и столбец, где всё случилось; следующая строка — причина («длина 3, а индекс 10»). Число в скобках после thread 'main' у вас будет другим, это номер потока. Паникой можно остановить программу и вручную: макрос panic!("текст") делает ровно то же самое, только с вашим сообщением.

Паника не значит «Rust сломался»

Паника — это безопасная остановка: программа не пойдёт дальше с неверными данными и не испортит память. Но пользователю такое поведение не подарок, поэтому ожидаемые ошибки мы паникой не оформляем.

Result: либо значение, либо причина отказа

С Option вы уже знакомы: это коробка, в которой что-то есть или пусто. Но пустая коробка не объясняет, почему она пуста. Result — это конверт: внутри либо то, что вы просили, либо записка с причиной отказа.

Официально Result<T, E> — перечисление (enum) из двух вариантов: Ok(T) — успех со значением и Err(E) — ошибка с описанием. Буквы T и E заменяются настоящими типами, как в Vec<Expense>: Result<u32, String> читаем как «или число u32, или ошибка в виде String». Свои дженерики мы не пишем, достаточно уметь прочитать такую запись.

Знакомая вам функция parse как раз возвращает Result: "350".parse::<u32>() даёт Result<u32, ParseIntError>, где ParseIntError — тип стандартной библиотеки для ошибки разбора числа. Посмотрим, что внутри:

Rust · main.rs
fn main() {
    let ok = "350".parse::<u32>();
    let bad = "abc".parse::<u32>();
    println!("{:?}", ok);
    println!("{:?}", bad);

    match bad {
        Ok(n) => println!("число {n}"),
        Err(e) => println!("не вышло: {e}"),
    }
}
Вывод
Ok(350)
Err(ParseIntError { kind: InvalidDigit })
не вышло: invalid digit found in string

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

  • ok и bad — два конверта; в первом число, во втором записка.
  • {:?} — отладочная печать, как для массива: она показывает упаковку целиком, вместе со словами Ok и Err. Внутри ошибки kind: InvalidDigit — «в строке встретилась не цифра».
  • match bad — открываем конверт, как раньше открывали Option: ветки Ok(n) и Err(e) достают содержимое. Как и всегда, match обязан покрыть оба варианта.
  • {e} — у ошибки есть и «человеческая» печать через обычные фигурные скобки: invalid digit found in string.

Теперь шаблон из модуля про функции, который мы тогда принимали на веру, читается целиком: match line.trim().parse::<u32>() { Ok(n) => n, Err(_) => { ...; continue; } }. Ветка Ok(n) достаёт число, а в ветке Err(_) подчёркивание означает «причина нам не нужна, просто попросим ввести ещё раз».

Option<T>Result<T, E>
Есть значениеSome(v)Ok(v)
Нет значенияNone (без причины)Err(e) (с причиной)
Когда братьотсутствие — нормальнопроизошла ошибка, и важно знать какая

Ok и Some не взаимозаменяемы

Это два разных типа. Функция с Result возвращает Ok(...) и Err(...), функция с Option — Some(...) и None. Перепутаете — компилятор остановит вас с error[E0308]: mismatched types.

unwrap и expect: «открыть, не глядя»

Иногда вы уверены, что в конверте именно товар. Тогда не хочется писать целый match. Для этого есть два метода. unwrap() (англ. «распаковать») возвращает значение из Ok, а если внутри Err — вызывает панику. expect("текст") делает то же самое, но в сообщении паники будет ваш текст: по нему легко понять, какая именно уверенность подвела.

Rust · main.rs
fn main() {
    let n = "abc".parse::<u32>().expect("сумма должна быть числом");
    println!("{n}");
}
Вывод
thread 'main' (2096604) panicked at src/main.rs:2:34:
сумма должна быть числом: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Для сравнения: unwrap() в этом же месте напечатал бы стандартное called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }. Смысл тот же, но без вашей подсказки. Поэтому в готовых программах предпочитают expect.

Когда можно, а когда нельзя

  • Можно: в учебных примерах и быстрых прототипах; в тестах; когда ошибка по-настоящему невозможна, например "350".parse::<u32>().expect("350 — число"). Вспомните read_line(&mut line).expect("не удалось прочитать строку") из цикла ввода: сбой чтения с клавиатуры — экзотика, и текст в expect объясняет, что случилось.
  • Нельзя: на всём, что приходит от пользователя или из внешнего мира. Если в Копилке написать unwrap() после разбора суммы, то первый же «abc» уронит программу вместе со всеми введёнными тратами.

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

Кроме match у Result есть короткие помощники. Четыре из них пригодятся нам дальше:

Rust · main.rs
fn main() {
    let n = "12a".parse::<u32>().unwrap_or(0);
    println!("{}", n + 1);

    println!("{}", "350".parse::<u32>().is_ok());
    println!("{}", "".parse::<u32>().is_err());

    println!("{:?}", "5".parse::<u32>().ok());
    println!("{:?}", "x".parse::<u32>().ok());

    let r: Result<u32, String> = "x".parse::<u32>().map_err(|e| format!("ошибка: {e}"));
    println!("{:?}", r);
}
Вывод
1
true
true
Some(5)
None
Err("ошибка: invalid digit found in string")

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

  • unwrap_or(0) — «если не вышло, возьми запасное значение»: вместо паники получаем 0, поэтому n + 1 равно 1. Удобно, когда запасное значение осмысленно, но причину ошибки вы тоже теряете.
  • is_ok() и is_err() — спросить «успех?» или «ошибка?», не открывая конверт; возвращают bool.
  • ok() — превращает Result в Option: Ok(v) становится Some(v), а Err(...) — None. Именно его мы ставили в parse_command, и именно из-за него терялась причина.
  • map_err(|e| ...) — переписать записку об ошибке, не трогая успешное значение: здесь ошибка ParseIntError превращается в наш String. Пока запомните одной фразой, в следующем уроке он нам понадобится.

Пишем свой Result: parse_amount

Время вернуть причину ошибки Копилке. Напишем функцию, которая берёт слово с суммой и возвращает либо число, либо понятное объяснение. Отказов у неё три: сумму забыли, сумма равна нулю или это вообще не число.

Rust · main.rs
fn parse_amount(text: &str) -> Result<u32, String> {
    let text = text.trim();
    if text.is_empty() {
        return Err("Не указана сумма".to_string());
    }
    match text.parse::<u32>() {
        Ok(0) => Err("Сумма должна быть больше нуля".to_string()),
        Ok(amount) => Ok(amount),
        Err(_) => Err(format!("Не число: {text}")),
    }
}

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

  • -> Result<u32, String> — обещаем вернуть либо число, либо ошибку-текст.
  • let text = text.trim(); — убираем пробелы и перевод строки по краям (затенение, как и раньше).
  • return Err("Не указана сумма".to_string()); — пустая строка: выходим сразу. Внутри Err должен лежать String, а литерал в кавычках — это &str, поэтому нужен .to_string().
  • match text.parse::<u32>() — пробуем превратить текст в число. Последнее выражение функции без точки с запятой и есть её результат.
  • Ok(0) — образец с точным значением: число получилось, и оно равно нулю. Ветки проверяются сверху вниз, поэтому нулевая сумма поймана раньше общей ветки.
  • Ok(amount) => Ok(amount) — любое другое число: достаём и упаковываем обратно в Ok уже как результат нашей функции.
  • Err(_) — текст не разобрался (буквы, дробь, минус или число больше u32). Причину ParseIntError нам знать не нужно, у нас своё объяснение, в которое format! вставляет введённый текст.

Проверим на четырёх входах:

Rust · main.rs
fn main() {
    println!("{:?}", parse_amount("350"));
    println!("{:?}", parse_amount(""));
    println!("{:?}", parse_amount("0"));
    println!("{:?}", parse_amount("abc"));
}
Вывод
Ok(350)
Err("Не указана сумма")
Err("Сумма должна быть больше нуля")
Err("Не число: abc")

Заметьте: теперь функция сама не печатает и не падает. Она только сообщает о результате, а решать, что делать с ошибкой, будет вызывающий код. Так устроено большинство ошибок в Rust.

Совет: пишите сообщения для человека

Текст в Err увидит пользователь. «Сумма должна быть больше нуля» полезнее, чем «bad amount», а в «не число» стоит показать, что именно ввели: так человек сразу видит опечатку.

Подробнее про обработку ошибок — в книге: глава 9 «Error Handling» и описание Result в std.

Практика: Копилка объясняет, что не так с суммой

Откройте проект kopilka и файл src/main.rs в том виде, в каком он остался после модуля про коллекции (вектор трат, список, отчёт). Менять будем только две вещи: добавим новую функцию и поправим одну функцию. Остальной код не трогаем.

Шаг 1. Добавьте parse_amount

Вставьте функцию целиком в main.rs над parse_command (удобно сразу после rest_after_first):

Rust · main.rs
fn parse_amount(text: &str) -> Result<u32, String> {
    let text = text.trim();
    if text.is_empty() {
        return Err("Не указана сумма".to_string());
    }
    match text.parse::<u32>() {
        Ok(0) => Err("Сумма должна быть больше нуля".to_string()),
        Ok(amount) => Ok(amount),
        Err(_) => Err(format!("Не число: {text}")),
    }
}

Шаг 2. Перепишите parse_command

Замените всю функцию parse_command на эту. Изменилась только ветка "добавить": четыре строки с let Some(amount) = ... .ok() else превратились в match по parse_amount.

Rust · main.rs
fn parse_command(line: &str) -> Option<Command> {
    match first_word(line) {
        "добавить" => {
            let rest = rest_after_first(line);
            let amount = match parse_amount(first_word(rest)) {
                Ok(amount) => amount,
                Err(message) => {
                    println!("Ошибка: {message}");
                    return None;
                }
            };
            let rest = rest_after_first(rest);
            let Some(category) = Category::from_word(first_word(rest)) else {
                return None;
            };
            let note = rest_after_first(rest);
            let note = if note.is_empty() { "без заметки" } else { note };
            Some(Command::Add(Expense::new(amount, category, note)))
        }
        "список" => Some(Command::List),
        "отчёт" | "отчет" => Some(Command::Report),
        "помощь" => Some(Command::Help),
        "выход" => Some(Command::Quit),
        _ => None,
    }
}

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

  • parse_amount(first_word(rest)) — передаём первое слово после «добавить»; если сумму забыли, слово пустое, и мы получим «Не указана сумма».
  • Ok(amount) => amount — число получилось: новая переменная amount становится значением всего match и попадает во внешний let amount.
  • Err(message) — ошибка: message это текст, который сформировал parse_amount. Печатаем его с пометкой «Ошибка:» и выходим из parse_command через return None.
  • Остальные ветки и категория не изменились: parse_command по-прежнему возвращает Option<Command>, поэтому main менять не нужно: ветки Some(Command::...) и None в цикле остаются прежними.

Шаг 3. Запустите и попробуйте

Выполните cargo run в папке проекта и введите по очереди команды:

Терминал
cargo run
Вывод
> добавить 350 еда обед
Записано: 350 руб., еда, «обед»
Потрачено: 350 руб. из 30000 руб.
Статус: в норме
> добавить abc еда
Ошибка: Не число: abc
Не понял команду. Наберите помощь
> добавить 0 еда
Ошибка: Сумма должна быть больше нуля
Не понял команду. Наберите помощь
> добавить
Ошибка: Не указана сумма
Не понял команду. Наберите помощь
> выход
Итого потрачено: 350 руб. из 30000 руб.

Символы > в начале строк показывают, что вводите вы; программа их не печатает.

Две строки вместо одной — это временно

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

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

  • Что напечатает Копилка на добавить -5 дом и почему не «Сумма должна быть больше нуля»?
  • Что случится, если в parse_command заменить match parse_amount(...) на parse_amount(...).unwrap() и ввести добавить abc еда?
  • Уберите из parse_amount проверку text.is_empty(). Какой ответ теперь даст добавить без суммы и почему он хуже?
  • Добавьте в parse_amount четвёртый отказ: сумма больше MONTH_LIMIT — «Сумма больше лимита месяца». В какую ветку match его ставить?

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

unwrap() на вводе пользователя

Что видно: программа аварийно завершается с called `Result::unwrap()` on an `Err` value: ... после первого же неверного ввода. Почему: unwrap превращает любую ошибку в панику, а пользователь ошибается постоянно. Как исправить: разберите Result через match (или вызовите unwrap_or, если запасное значение уместно). unwrap и expect оставьте для случаев, где ошибка невозможна.

Перепутали Ok и Some

Что видно: error[E0308]: mismatched types и пояснение expected `Result<u32, String>`, found `Option<{integer}>`. Почему: Some и None принадлежат Option, Ok и Err — Result. Как исправить: смотрите на тип в сигнатуре функции и возвращайте именно его варианты.

Забыли обернуть значение в Ok или текст в String

Что видно: expected `Result<u32, String>`, found integer (в конце функции написали просто 350) или expected `String`, found `&str` (написали Err("Не указана сумма")). Почему: результат должен быть упакован в Ok, а ошибка — именно типом String. Как исправить: Ok(350) и Err("Не указана сумма".to_string()); компилятор в подсказке help предлагает то же самое.

Результат проигнорировали

Что видно: предупреждение warning: unused `Result` that must be used на строке вроде parse_amount("350");. Почему: вы вызвали функцию, которая могла сообщить об ошибке, и выбросили ответ, не посмотрев. Программа соберётся, но ошибка пройдёт незамеченной. Как исправить: разберите результат через match, if let или сохраните его в переменную; не заглушайте предупреждение без причины.

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

  • Ошибки бывают двух видов: паника (баг, аварийная остановка) и ожидаемая ошибка, которую возвращают как значение. Ввод пользователя — всегда второй случай.
  • Result<T, E> — enum с вариантами Ok(T) и Err(E); в отличие от Option, он хранит причину неудачи.
  • "350".parse::<u32>() возвращает Result<u32, ParseIntError>; match с ветками Ok(n) и Err(e) открывает его.
  • unwrap() и expect("текст") паникуют на Err; годятся для прототипов, тестов и невозможных ошибок, но не для ввода.
  • unwrap_or, is_ok, is_err, ok() (Result в Option, причина теряется) и map_err (переписать ошибку) — короткие помощники.
  • В Копилке теперь есть fn parse_amount(text: &str) -> Result<u32, String>: пусто, ноль и не число дают разные понятные сообщения.

Пока parse_command вынуждена печатать ошибку сама и всё равно возвращать None. В следующем уроке узнаем оператор ?, который передаёт ошибку наверх одной буквой, и избавимся от вложенных match.

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