За этот модуль вы научились описывать сбои значениями: Result, оператор ?, свой CommandError с понятным текстом. Но по отдельности эти кусочки ещё не дают программу, которую можно отдать другому человеку. Сегодня мы проверим «Копилку» на всём, что может набрать пользователь, найдём оставшиеся слабые места и приведём главный цикл в порядок.
Что сделаем: сессия «как ломает человек»
Лучший способ проверить программу — вести себя как невнимательный пользователь. Вот сценарий, который мы будем прогонять в конце. В нём есть всё, что обычно ломает консольные программы: пустая строка, незнакомая команда, команда без аргументов, буквы вместо суммы, ноль, неизвестная категория и огромное число.
| Что набрали | Что должна ответить программа |
|---|---|
| пустая строка (просто Enter) | Ошибка: Пустая строка. Наберите помощь |
| привет | Ошибка: Не знаю команду «привет» |
| добавить | Ошибка: Мало данных: добавить <сумма> <категория> [заметка] |
| добавить abc еда | Ошибка: «abc» — не сумма |
| добавить 0 еда | Ошибка: «0» — не сумма |
| добавить 99999999 еда | Ошибка: «99999999» — не сумма |
| добавить 100 кофе | Ошибка: Не знаю категорию «кофе» |
| добавить 350 еда обед в столовой | Записано: 350 руб., еда, «обед в столовой» |
Ни в одном случае не должно быть паники, и после каждой ошибки программа должна спокойно ждать следующую строку. Это и есть «надёжный ввод».
Ищем места, где программа может упасть
Паника в Rust — это аварийная остановка. Её вызывают panic!, unwrap и expect на ошибке, выход за границу массива, деление на ноль и переполнение числа. Поэтому аудит начинаем с поиска. Откройте src/main.rs и найдите в редакторе (Ctrl+F) слова unwrap и expect, либо в терминале macOS и Linux:
grep -n "unwrap\|expect" src/main.rs
Найдутся ровно две строки, и обе безобидные:
unwrap_or("")вfirst_word. Это неunwrap: если слов нет, функция возвращает пустую строку, паниковать нечему..expect("не удалось прочитать строку")уread_lineвmain. Эту строку мы оставляем сознательно.
Почему expect у read_line остаётся? Правило такое: ошибки, которые вызвал пользователь, мы объясняем и идём дальше; ошибки, после которых работать невозможно, заканчивают программу. Если не удаётся прочитать стандартный ввод (оборвался канал, сломался терминал), пользователь ничего неправильного не набрал и продолжать нечем. Здесь паника с понятным текстом — честный ответ. Обратите внимание: это не конец ввода. Когда ввод закончился штатно, read_line возвращает Ok(0), и мы выходим из цикла через bytes == 0.
Остаются скрытые паники, которых не видно по слову unwrap. Проверим одну руками. Запустите программу и наберите огромную сумму, а потом отчёт:
cargo run
> добавить 99999999 еда
Записано: 99999999 руб., еда, «без заметки»
Потрачено: 99999999 руб. из 30000 руб.
Статус: лимит превышен
> отчёт
thread 'main' panicked at src/main.rs:204:21:
attempt to multiply with overflow
(Номер строки в сообщении у вас может быть другим.)
Сумма корректна для u32, разбор прошёл, а в отчёте формула amount * 100 / spent умножает на сто и выходит за предел u32 (около 4,29 миллиарда). Это та самая паника «переполнение», которую мы видели в первом модуле, только теперь её вызывает пользователь. У неё два лекарства, и мы применим оба:
- Ограничим одну трату разумным потолком: константа
MAX_AMOUNT, больше миллиона рублей за раз — это опечатка, и мы вернём обычнуюBadAmount. - Посчитаем проценты в более широком типе:
u64вмещает числа до 18 квинтиллионов, так что умножение на сто безопасно.
Почему потолок, а не только u64
Если оставить сумму в u32 без потолка, паникует уже total: два раза по 4 миллиарда не помещаются в u32 при сложении. Потолок на каждую трату закрывает и эту дыру: чтобы переполнить сумму, пришлось бы ввести больше четырёх тысяч трат.
Потолок суммы: итоговая parse_amount
Разбор суммы вы собрали в прошлом уроке. Здесь она приводится целиком с двумя изменениями: новой константой и потолком в parse_amount. Сверьтесь со своей версией.
const MAX_AMOUNT: u32 = 1_000_000;
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) => {
if n > MAX_AMOUNT {
Err(CommandError::BadAmount(text.to_string()))
} else {
Ok(n)
}
}
}
}
Разбор по строкам
const MAX_AMOUNT: u32 = 1_000_000;— потолок одной траты; константы пишем рядом сMONTH_LIMIT, в самом верху файла.Ok(0) | Err(_)— ноль и «не число» (буквы, минус, дробь, слишком большое дляu32) дают одну и ту же ошибкуBadAmountс исходным текстом.Ok(n) => { if n > MAX_AMOUNT { ... } else { Ok(n) } }— число прочиталось, но слишком большое: тожеBadAmount; иначе возвращаем сумму как есть.
А сам parse_command остаётся таким, каким вы его оставили в предыдущем уроке: Result<Command, CommandError>, пустая строка — Empty, незнакомое слово — UnknownCommand, а внутри ветки «добавить» два вопросительных знака (parse_amount(...)? и ok_or(...)?). Он ничего не печатает и не меняет траты: только превращает строку в Command или в ошибку. Эта чистота нам сейчас пригодится.
Главный цикл: отделяем «понять» от «сделать»
Сейчас main делает всё сразу: читает строку, разбирает, выполняет и печатает. Цикл растёт с каждым уроком, и в нём уже тяжело искать глазами. Разделим работу на две части: parse_command понимает, что хотел пользователь, а новая функция run_command это выполняет. Если выполнить нужно — она вернёт true, если пользователь попросил выход — false.
fn run_command(command: Command, expenses: &mut Vec<Expense>) -> bool {
match command {
Command::Add(expense) => {
println!("Записано: {}", expense.describe());
expenses.push(expense);
let spent = total(expenses);
println!("Потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
print_status(spent, MONTH_LIMIT);
}
Command::List => print_list(expenses),
Command::Report => print_report(expenses),
Command::Help => print_help(),
Command::Quit => return false,
}
true
}
Разбор по строкам
command: Command— команду забираем по значению: после выполнения она не нужна, а внутриAddлежит трата, которую мы переложим в вектор.expenses: &mut Vec<Expense>— изменяемая ссылка на вектор трат: функция может добавлять траты, но владельцем остаётсяmain.expenses.push(expense);— метод вызывается прямо через ссылку, разыменовывать вручную не нужно.total(expenses),print_list(expenses)— эти функции ждут срез&[Expense], а у нас&mut Vec<Expense>. Rust сам «сужает» ссылку на вектор до среза, переписывать ничего не надо.Command::Quit => return false,— выход из функции прямо из ветки; до конечной строкиtrueдело не дойдёт.trueв самом конце — значение всей функции: «продолжаем». Точки с запятой нет, это результат.
Теперь главный цикл читается как простая инструкция: прочитали строку, разобрали, выполнили или объяснили ошибку.
match parse_command(&line) {
Ok(command) => {
if !run_command(command, &mut expenses) {
break;
}
}
Err(err) => println!("Ошибка: {err}"),
}
Разбор по строкам
Ok(command)— разбор удался: вcommandлежит готовая команда.run_command(command, &mut expenses)— передаём команду и разрешаем менять траты; слова&mutпри вызове обязательны.if !run_command(...) { break; }— восклицательный знак перед вызовом означает «не»; если функция вернулаfalse, выходим из цикла.Err(err) => println!("Ошибка: {err}")— любая ошибка разбора получает человеческий текст черезDisplay, цикл идёт дальше.
Практика: собираем «Копилку 0.6»
Работаем в проекте kopilka, файл src/main.rs. Вы начинаете с кода, который получился в конце предыдущего модуля плюс изменения прошлых уроков (CommandError, Display, parse_command с Result). Ниже — пять шагов и в конце полный файл для сверки.
Шаг 1. Потолок суммы
Под строкой const MONTH_LIMIT: u32 = 30_000; добавьте:
const MAX_AMOUNT: u32 = 1_000_000;
И замените parse_amount на версию из предыдущего раздела.
Шаг 2. Проценты в u64
В функции print_report найдите строку с процентом и замените:
let share = *amount as u64 * 100 / spent as u64;
Разбор по строкам
*amount— в циклеfor (category, amount) in &rowsпеременнаяamountявляется ссылкой на число; звёздочка достаёт само число.as u64— знакомое по первому модулю приведение типа (там былas f64): делаем число «шире», чтобы умножение на сто не переполнилось.spent as u64— знаменатель тоже приводим кu64, потому что Rust не смешивает разные числовые типы в одной формуле. Остальнойprintln!менять не нужно:{:>5}красиво печатает иu64.
Шаг 3. Функция run_command
Добавьте run_command из предыдущего раздела над main.
Шаг 4. Упрощаем main
Замените в main весь match на версию с Ok(command) и Err(err). Заодно поменяйте заголовок на println!("Копилка 0.6");. Проверки «пустая строка — continue» в цикле быть не должно (её убрали в прошлом уроке): пустую строку разбирает parse_command.
Шаг 5. Полный файл и проверка
Вот весь src/main.rs целиком. Сверьте со своим.
use std::collections::HashMap;
use std::fmt;
use std::io;
const MONTH_LIMIT: u32 = 30_000;
const MAX_AMOUNT: u32 = 1_000_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Category {
Food,
Transport,
Home,
Fun,
Other,
}
impl Category {
fn from_word(word: &str) -> Option<Category> {
match word {
"еда" => Some(Category::Food),
"транспорт" => Some(Category::Transport),
"дом" => Some(Category::Home),
"отдых" => Some(Category::Fun),
"другое" => Some(Category::Other),
_ => None,
}
}
fn title(&self) -> &'static str {
match self {
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 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 => "",
}
}
#[derive(Debug, PartialEq)]
enum CommandError {
Empty,
UnknownCommand(String),
BadAmount(String),
UnknownCategory(String),
MissingArgs,
}
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, "Мало данных: добавить <сумма> <категория> [заметка]")
}
}
}
}
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) => {
if n > MAX_AMOUNT {
Err(CommandError::BadAmount(text.to_string()))
} else {
Ok(n)
}
}
}
}
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())),
}
}
fn print_help() {
println!("Команды:");
println!(" добавить <сумма> <категория> [заметка] - записать трату");
println!(" список - показать все траты");
println!(" отчёт - итоги по категориям");
println!(" помощь - показать эту подсказку");
println!(" выход - закончить работу");
println!("Категории: еда, транспорт, дом, отдых, другое");
}
fn print_status(spent: u32, limit: u32) {
if spent > limit {
println!("Статус: лимит превышен");
} else if spent * 100 >= limit * 80 {
println!("Статус: почти лимит");
} else {
println!("Статус: в норме");
}
}
fn print_list(expenses: &[Expense]) {
if expenses.is_empty() {
println!("Пока нет трат");
return;
}
for (i, expense) in expenses.iter().enumerate() {
println!("{}. {}", i + 1, expense.describe());
}
}
fn total(expenses: &[Expense]) -> u32 {
expenses.iter().map(|e| e.amount).sum::<u32>()
}
fn totals_by_category(expenses: &[Expense]) -> HashMap<Category, u32> {
let mut totals: HashMap<Category, u32> = HashMap::new();
for expense in expenses {
*totals.entry(expense.category).or_insert(0) += expense.amount;
}
totals
}
fn print_report(expenses: &[Expense]) {
if expenses.is_empty() {
println!("Пока нет трат");
return;
}
let mut rows: Vec<(Category, u32)> = totals_by_category(expenses).into_iter().collect();
rows.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.title().cmp(b.0.title())));
let spent = total(expenses);
for (category, amount) in &rows {
let label = format!("{}:", category.title());
let share = *amount as u64 * 100 / spent as u64;
println!("{:<11}{:>6} руб.{:>5}%", label, amount, share);
}
println!("Всего: {spent} руб. из {MONTH_LIMIT} руб.");
print_status(spent, MONTH_LIMIT);
}
fn run_command(command: Command, expenses: &mut Vec<Expense>) -> bool {
match command {
Command::Add(expense) => {
println!("Записано: {}", expense.describe());
expenses.push(expense);
let spent = total(expenses);
println!("Потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
print_status(spent, MONTH_LIMIT);
}
Command::List => print_list(expenses),
Command::Report => print_report(expenses),
Command::Help => print_help(),
Command::Quit => return false,
}
true
}
fn main() {
println!("Копилка 0.6");
println!("Наберите помощь, чтобы увидеть команды.");
let mut expenses: Vec<Expense> = Vec::new();
let mut line = String::new();
loop {
line.clear();
let bytes = io::stdin()
.read_line(&mut line)
.expect("не удалось прочитать строку");
if bytes == 0 {
break;
}
match parse_command(&line) {
Ok(command) => {
if !run_command(command, &mut expenses) {
break;
}
}
Err(err) => println!("Ошибка: {err}"),
}
}
println!(
"Итого потрачено: {} руб. из {MONTH_LIMIT} руб.",
total(&expenses)
);
}
Запустите программу и пройдите сценарий из первого раздела:
cargo run
Копилка 0.6
Наберите помощь, чтобы увидеть команды.
>
Ошибка: Пустая строка. Наберите помощь
> привет
Ошибка: Не знаю команду «привет»
> добавить
Ошибка: Мало данных: добавить <сумма> <категория> [заметка]
> добавить abc еда
Ошибка: «abc» — не сумма
> добавить 0 еда
Ошибка: «0» — не сумма
> добавить 99999999 еда
Ошибка: «99999999» — не сумма
> добавить 100 кофе
Ошибка: Не знаю категорию «кофе»
> добавить 350 еда обед в столовой
Записано: 350 руб., еда, «обед в столовой»
Потрачено: 350 руб. из 30000 руб.
Статус: в норме
> добавить 1200 транспорт
Записано: 1200 руб., транспорт, «без заметки»
Потрачено: 1550 руб. из 30000 руб.
Статус: в норме
> список
1. 350 руб., еда, «обед в столовой»
2. 1200 руб., транспорт, «без заметки»
> отчёт
транспорт: 1200 руб. 77%
еда: 350 руб. 22%
Всего: 1550 руб. из 30000 руб.
Статус: в норме
> выход
Итого потрачено: 1550 руб. из 30000 руб.
Символ > показывает строки, которые набираете вы; на экране его нет. Проценты в отчёте округляются вниз, поэтому 77% и 22% в сумме дают 99. Компилятор не должен выдавать ни одного предупреждения (warning); если он выдаёт, прочитайте текст: чаще всего это забытая или неиспользуемая функция после правок.
Как проверить без ручного набора
Тот же сценарий можно подать программе одним куском. В macOS и Linux: printf '\nпривет\nдобавить\nвыход\n' | cargo run -q. Первая пустая строка (\n сразу в начале) даёт ошибку Empty, а когда ввод заканчивается без команды выход, программа выходит сама благодаря bytes == 0.
Проверьте себя
- Что напечатает программа на строку из одних пробелов и почему паники нет? (Подсказка: посмотрите, что вернёт
first_wordдля такой строки.) - Введите
добавить 1000000 едаидобавить 1000001 еда. Чем отличаются ответы и где в коде проходит граница? - Уберите
&mutпри вызовеrun_command. Какую ошибку компилятора вы получите и что она советует? - Закончите ввод без команды
выход(Ctrl+Dв macOS и Linux,Ctrl+Zи Enter в Windows). Почему программа завершилась сама, и откуда взялась строка «Итого потрачено»?
Частые ошибки
Забыли &mut при вызове run_command
Что видно: error[E0308]: mismatched types, подпись expected `&mut Vec<Expense>`, found `Vec<Expense>` и совет consider mutably borrowing here. Если написать просто &expenses, текст другой: types differ in mutability.
Почему: функция просит изменяемую ссылку, а ей дали сам вектор или неизменяемую ссылку. Как исправить: вызов run_command(command, &mut expenses), и сама переменная должна быть объявлена как let mut expenses.
Ошибка печатается через {:?}
Что видно: вместо «Не знаю команду «привет»» программа пишет UnknownCommand("привет"). Почему: {:?} и {err:?} используют Debug, а не Display. Компилятор молчит, потому что у CommandError есть derive(Debug). Как исправить: println!("Ошибка: {err}").
Паника на огромной сумме
Что видно: thread 'main' panicked ... attempt to multiply with overflow после ввода большого числа и команды отчёт. Почему: формула с * 100 в u32 не вмещает результат. Как исправить: потолок MAX_AMOUNT в parse_amount и расчёт процента через u64, как в шагах 1–2.
В конце run_command поставили точку с запятой
Что видно: error[E0308]: mismatched types и пояснение expected `bool`, found `()`. Почему: строка true; с точкой с запятой ничего не возвращает. Как исправить: последняя строка функции — просто true.
Что важно запомнить
- Сначала ищем панику:
unwrap,expect,panic!, индексы, деление, переполнение. Ошибки пользователя объясняем, неустранимые сбои вроде сломанного ввода могут остановить программу. - Разбор и действие лучше разделять:
parse_commandпревращает строку вCommandилиCommandError,run_commandвыполняет команду. - Функции, которые меняют траты, получают
&mut Vec<Expense>, и при вызове пишем&mut expenses. - Ограничения на ввод (потолок суммы) и более широкий тип в формулах (
u64) защищают от переполнения, которое вызывает пользователь. - Ошибки печатаем через
{err}(Display), а не{err:?}.
Теперь «Копилка» не падает от плохого ввода, но весь код живёт в одном длинном файле, а траты исчезают при выходе. В следующем модуле разложим программу по модулям и научим её сохранять траты в файл.