Копилка уже умеет разбирать команды, но на плохой ввод отвечает одинаково: «Не понял команду». Напишите добавить abc еда — и программа не скажет, что не так: сумма не число, или её забыли, или она нулевая. Причина потерялась в тот момент, когда мы написали .ok() и превратили неудачу в безликое None. В этом модуле научим Копилку объяснять ошибки, а начнём с главного вопроса: что вообще делать, когда что-то пошло не так.
Два вида ошибок
Представьте кафе. Гость заказывает блюдо, которого нет в меню. Официант спокойно отвечает: «Такого нет, выберите другое». Это обычная, ожидаемая ситуация: работа продолжается. А теперь на кухне обрушился потолок. Тут уже не до заказов: работа останавливается, пока не разберутся с аварией.
В программах ровно так же. Первый вид — восстановимая ошибка: она случается по вине внешнего мира (пользователь ввёл «abc» вместо суммы), и программа должна её обработать и жить дальше. Второй вид — паника (panic): аварийная остановка программы, когда нарушено что-то, что нарушаться не должно, то есть в коде баг. Правило большого пальца: ошибка по вине программиста — паника, ошибка по вине ввода и внешнего мира — Result.
С паникой вы уже встречались: переполнение attempt to subtract with overflow — именно она. Вот самый частый способ её получить:
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 — тип стандартной библиотеки для ошибки разбора числа. Посмотрим, что внутри:
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("текст") делает то же самое, но в сообщении паники будет ваш текст: по нему легко понять, какая именно уверенность подвела.
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 есть короткие помощники. Четыре из них пригодятся нам дальше:
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
Время вернуть причину ошибки Копилке. Напишем функцию, которая берёт слово с суммой и возвращает либо число, либо понятное объяснение. Отказов у неё три: сумму забыли, сумма равна нулю или это вообще не число.
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!вставляет введённый текст.
Проверим на четырёх входах:
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):
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.
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.