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

Практикум: надёжный ввод

Вы соберёте «Копилку», которую не сломать вводом: любая строка получает понятный ответ, а паники нет.

Практикум~30 минутСредний

За этот модуль вы научились описывать сбои значениями: 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. Сверьтесь со своей версией.

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

Rust · main.rs
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 в самом конце — значение всей функции: «продолжаем». Точки с запятой нет, это результат.

Теперь главный цикл читается как простая инструкция: прочитали строку, разобрали, выполнили или объяснили ошибку.

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

Rust · main.rs
const MAX_AMOUNT: u32 = 1_000_000;

И замените parse_amount на версию из предыдущего раздела.

Шаг 2. Проценты в u64

В функции print_report найдите строку с процентом и замените:

Rust · main.rs
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 целиком. Сверьте со своим.

Rust · 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:?}.

Теперь «Копилка» не падает от плохого ввода, но весь код живёт в одном длинном файле, а траты исчезают при выходе. В следующем модуле разложим программу по модулям и научим её сохранять траты в файл.

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