$ sudo teach IT
Модуль 5 · Коллекции · Урок 5.4

Практикум: отчёт по категориям

Вы соберёте команды список и отчёт: траты хранятся в векторе, а суммы по категориям печатаются по убыванию.

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

Чего не хватает Копилке

Теперь у вас есть всё нужное: Vec хранит траты, итераторы считают сумму, HashMap копит деньги по категориям. Команда список уже печатает траты, а отчёт — суммы по категориям в самом простом виде: без процентов и выравнивания, а для пустого списка выдаёт неловкое «Всего: 0». Сегодня доведём обе команды до настоящего вида и соберём итоговую «Копилку 0.5» целиком.

Что мы сделаем

Сначала посмотрим на результат. Вы добавили несколько трат и набрали отчёт. Программа показывает категории от самой затратной к самой скромной, рядом — сумма и доля от всех трат, а внизу — итог и статус лимита:

Вывод
транспорт:   1200 руб.   60%
еда:          449 руб.   22%
дом:          350 руб.   17%
Всего: 1999 руб. из 30000 руб.
Статус: в норме

На экране четыре вещи, и каждой соответствует кусок кода. Данные — готовая функция totals_by_category плюс сортировка. Строки — выровненные колонки. Итог — total и знакомый print_status. Каркас — функция print_report, которая всё это склеивает и которую вызывает main. Пустой список обработаем отдельно: вместо пустой таблицы будет «Пока нет трат».

Данные: суммы по категориям и порядок

Функция totals_by_category из прошлого урока возвращает HashMap<Category, u32>. Представьте мешок с карточками «категория — сумма»: вы вытряхиваете его на стол, и карточки лежат в любом порядке, причём при каждом запуске в разном. Для отчёта это плохо: пользователь ждёт, что самая крупная категория будет первой, и что порядок не прыгает.

Поэтому перед печатью мы перекладываем карточки в вектор и сортируем. Официально это сортировка с компаратором: метод sort_by получает замыкание, которое сравнивает две записи и отвечает, какая из них должна стоять раньше.

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

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

  • totals_by_category(expenses) — получаем словарь сумм; expenses здесь уже ссылка на срез, поэтому передаём её как есть.
  • .into_iter().collect() — разбираем словарь на пары (категория, сумма) и собираем их в Vec; тип слева Vec<(Category, u32)> подсказывает collect, что собирать.
  • |a, b| — замыкание получает две записи, a и b; каждая — кортеж, поэтому .0 это категория, а .1 сумма.
  • b.1.cmp(&a.1) — сравнивает суммы наоборот (сначала b, потом a), поэтому большие суммы окажутся в начале. Результат сравнения — значение Ordering: «меньше», «равно» или «больше».
  • .then(...) — если суммы равны, берётся второе сравнение: по названию категории. Тогда порядок полностью предсказуем и не зависит от случайного порядка словаря.

Почему сравниваем названия, а не сами категории

У Category нет умения сравнивать «больше или меньше», мы его не выводили через derive. Зато у &str оно есть, а title() как раз возвращает строку. Так проще, чем учить enum новому умению ради одной сортировки.

Печать строк: выровненные колонки

Если печатать «категория, сумма, процент» через простые пробелы, колонки поплывут: «еда» короче, чем «транспорт». Выручает ширина поля в форматной строке. Вы уже встречали её в виде {:>6}, а теперь добавим вторую половину пары.

Правило такое: число после двоеточия — это минимальная ширина поля в символах. Знак < выравнивает текст по левому краю, знак > — по правому. Лишнее место заполняется пробелами. Для названия категорий нужно выравнивание влево, для сумм и процентов — вправо, чтобы разряды стояли друг под другом.

Rust · main.rs
for (category, amount) in &rows {
    let label = format!("{}:", category.title());
    let share = amount * 100 / spent;
    println!("{:<11}{:>6} руб.{:>5}%", label, amount, share);
}

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

  • for (category, amount) in &rows — обходим вектор по ссылке, чтобы он остался у нас; каждый кортеж сразу раскладывается на два имени. category и amount здесь ссылки на части кортежа, но печатать и умножать через них можно как обычно.
  • format!("{}:", category.title()) — склеиваем название и двоеточие в одну String, чтобы выровнять их вместе.
  • amount * 100 / spent — доля от всех трат в процентах; деление целое, остаток отбрасывается, как мы видели в первом модуле. Переменную spent (общую сумму) мы заведём в следующем разделе: let spent = total(expenses);.
  • {:<11} — название вместе с двоеточием, влево, ширина 11 символов — хватает на «транспорт:» (10 символов) и ещё остаётся зазор.
  • {:>6} руб. — сумма вправо в поле из 6 символов, затем слово «руб.».
  • {:>5}% — процент вправо в поле из 5 символов, затем знак процента.

Ширина считается в символах, а не в байтах

Кириллица в UTF-8 занимает по два байта на букву, но выравнивание считает именно символы, поэтому «еда:» и «транспорт:» выровняются правильно. А вот ширина поля меньше длины текста ничего не обрежет: текст просто напечатается целиком, и колонка съедет.

Итог и статус лимита

Остаётся нижняя часть отчёта. Сумма всех трат — это функция total из урока про итераторы, лимит — константа MONTH_LIMIT, а статус печатает print_status, написанная ещё в модуле про функции. Ничего нового писать не нужно: мы просто вызываем готовые части.

Rust · main.rs
println!("Всего: {spent} руб. из {MONTH_LIMIT} руб.");
print_status(spent, MONTH_LIMIT);

Теперь о пустом списке. Если трат нет, spent равно нулю, а делить на него нельзя: в работающей программе это паника attempt to divide by zero. В нашем цикле деление стоит внутри for, а для пустого вектора тело цикла не выполнится ни разу, так что формально бы обошлось. Но пустая таблица с итогом «0 из 30000» выглядит странно, и стоит кому-то вынести расчёт доли за цикл, как программа упадёт. Поэтому в начале print_report стоит ранний выход.

Rust · main.rs
if expenses.is_empty() {
    println!("Пока нет трат");
    return;
}

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

  • expenses.is_empty() — метод среза и вектора: true, если элементов нет.
  • return; — досрочно завершает функцию, которая ничего не возвращает: всё, что ниже, не выполнится. Такую же проверку получит и print_list: в уроке про векторы она была записана через else, ниже станет короче.

Практика: полный main.rs

Ниже полный src/main.rs «Копилки 0.5». Если вы шли по урокам, у вас уже почти всё так: сверьте свой файл с этим, а если что-то расходится, замените содержимое целиком. Что изменилось по сравнению с прошлым уроком: появилась функция print_report (раньше отчёт печатался прямо в ветке Report); print_list записана через ранний return; в total дописано sum::<u32>() (результат тот же); в справке print_help у список и отчёт пропало «(скоро)»; заголовок стал «Копилка 0.5». Как и раньше, нужны use std::collections::HashMap; сверху и Eq, Hash в derive у Category.

Rust · main.rs
use std::collections::HashMap;
use std::io;

const MONTH_LIMIT: u32 = 30_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 => "",
    }
}

fn parse_command(line: &str) -> Option<Command> {
    match first_word(line) {
        "добавить" => {
            let rest = rest_after_first(line);
            let Some(amount) = first_word(rest).parse::<u32>().ok() else {
                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,
    }
}

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 * 100 / spent;
        println!("{:<11}{:>6} руб.{:>5}%", label, amount, share);
    }
    println!("Всего: {spent} руб. из {MONTH_LIMIT} руб.");
    print_status(spent, MONTH_LIMIT);
}

fn main() {
    println!("Копилка 0.5");
    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;
        }
        if line.trim().is_empty() {
            continue;
        }

        match parse_command(&line) {
            Some(Command::Add(expense)) => {
                println!("Записано: {}", expense.describe());
                expenses.push(expense);
                let spent = total(&expenses);
                println!("Потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
                print_status(spent, MONTH_LIMIT);
            }
            Some(Command::List) => print_list(&expenses),
            Some(Command::Report) => print_report(&expenses),
            Some(Command::Help) => print_help(),
            Some(Command::Quit) => break,
            None => println!("Не понял команду. Наберите помощь"),
        }
    }

    println!("Итого потрачено: {} руб. из {MONTH_LIMIT} руб.", total(&expenses));
}

Что изменилось в main. Вместо let mut spent: u32 = 0; теперь let mut expenses: Vec<Expense> = Vec::new(); — пустой вектор трат. В ветке Add сначала печатается запись, и только потом expenses.push(expense): push забирает трату во владение вектора, и после него expense использовать уже нельзя. Счётчик spent больше не копится вручную: в ветке Add сумма считается заново через let spent = total(&expenses);, а итог в конце берёт total(&expenses) прямо в println!. Заглушки Some(_) уже нет (её убрали в уроке про итераторы), а временная строка «Из них еда» и функция total_in ушли в прошлом уроке: отчёт по категориям их заменил. Ветки List и Report вызывают print_list(&expenses) и print_report(&expenses); ссылка на вектор сама превращается в нужный срез &[Expense].

Запустите программу из папки проекта:

Терминал
cargo run

Проверьте сценарий. Вводите строки по порядку и сверяйте ответы:

Вы вводитеПрограмма отвечает
отчёт (в самом начале)Пока нет трат
добавить 350 еда обед в столовойЗаписано: 350 руб., еда, «обед в столовой»; потрачено 350, статус в норме
добавить 1200 транспорт проезднойЗаписано; потрачено 1550
добавить 99 едаЗаписано: 99 руб., еда, «без заметки»; потрачено 1649
список1. 350 руб., еда, «обед в столовой» / 2. 1200 руб., транспорт, «проездной» / 3. 99 руб., еда, «без заметки»
отчёттранспорт: 1200 руб. 72% / еда: 449 руб. 27% / Всего: 1649 руб. из 30000 руб. / Статус: в норме
добавить abc едаНе понял команду. Наберите помощь
выходИтого потрачено: 1649 руб. из 30000 руб.

Как проверить статус лимита

Чтобы увидеть «почти лимит», введите добавить 25000 дом ремонт: 25000 из 30000 — это больше 80 процентов. Ещё добавить 6000 отдых даст «лимит превышен».

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

  • отчёт на пустой Копилке печатает «Пока нет трат», а не пустую таблицу.
  • После нескольких трат список нумерует их с единицы, а отчёт ставит самую крупную категорию первой.
  • Если две категории имеют равные суммы, их порядок при каждом запуске одинаков.
  • Добавьте трату в уже существующую категорию: в отчёте её сумма выросла, а новая строка не появилась.
  • cargo run не показывает предупреждений (warnings).

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

Отчёт идёт по возрастанию или упал с E0599

Что видно: самая маленькая категория стоит первой. Если же вы сравнили сами категории, a.0.cmp(&b.0), компилятор ругается error[E0599]: `Category` is not an iterator. Почему: в записи (Category, u32) поле .1 — это сумма, а .0 — категория, и сравнивать её без отдельного умения нельзя. А порядок a.1.cmp(&b.1) даёт возрастание. Как исправить: сравнивайте b.1.cmp(&a.1): «b» первым означает «по убыванию».

Трату использовали после push

Что видно: error[E0382]: borrow of moved value: `expense` на строке с expense.describe(). Почему: expenses.push(expense) отдал трату вектору. Как исправить: сначала напечатайте describe(), потом делайте push.

Не нашли HashMap

Что видно: error[E0425]: cannot find type `HashMap` in this scope. Почему: словарь живёт не в предварительно подключённой части стандартной библиотеки. Как исправить: добавьте в начало файла use std::collections::HashMap;.

Деление на ноль в процентах

Что видно: при пустом списке программа падает с attempt to divide by zero. Почему: процент считается как amount * 100 / spent, а spent равно нулю. Как исправить: проверьте expenses.is_empty() в начале print_report и выйдите через return до всяких расчётов.

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

  • Все траты живут в одном Vec<Expense>; суммы считаются из него функциями total и totals_by_category, отдельный счётчик не нужен.
  • HashMap не хранит порядок. Чтобы отчёт был предсказуемым, переложите пары в Vec и отсортируйте через sort_by.
  • b.1.cmp(&a.1) сортирует по убыванию, .then(...) разрешает ничью.
  • {:<11} выравнивает влево, {:>6} — вправо; число задаёт минимальную ширину в символах.
  • Ранний return при пустом списке убирает деление на ноль и лишние пустые таблицы.
  • push забирает значение во владение вектора, поэтому используйте трату до него.
  • Функции принимают &[Expense]: срез читает данные и не отнимает вектор.

Но пока добавить abc еда получает один и тот же ответ «Не понял команду», и программа не говорит, что именно не так. В следующем модуле научимся возвращать настоящие ошибки с объяснением.

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