Чего не хватает Копилке
Теперь у вас есть всё нужное: 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 получает замыкание, которое сравнивает две записи и отвечает, какая из них должна стоять раньше.
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}, а теперь добавим вторую половину пары.
Правило такое: число после двоеточия — это минимальная ширина поля в символах. Знак < выравнивает текст по левому краю, знак > — по правому. Лишнее место заполняется пробелами. Для названия категорий нужно выравнивание влево, для сумм и процентов — вправо, чтобы разряды стояли друг под другом.
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, написанная ещё в модуле про функции. Ничего нового писать не нужно: мы просто вызываем готовые части.
println!("Всего: {spent} руб. из {MONTH_LIMIT} руб.");
print_status(spent, MONTH_LIMIT);
Теперь о пустом списке. Если трат нет, spent равно нулю, а делить на него нельзя: в работающей программе это паника attempt to divide by zero. В нашем цикле деление стоит внутри for, а для пустого вектора тело цикла не выполнится ни разу, так что формально бы обошлось. Но пустая таблица с итогом «0 из 30000» выглядит странно, и стоит кому-то вынести расчёт доли за цикл, как программа упадёт. Поэтому в начале print_report стоит ранний выход.
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.
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 еда получает один и тот же ответ «Не понял команду», и программа не говорит, что именно не так. В следующем модуле научимся возвращать настоящие ошибки с объяснением.