Вектор трат и итераторы уже позволяют посчитать общую сумму или сумму по одной категории. Но отчёт хочет другого: сразу увидеть, сколько ушло на еду, сколько на дом, сколько на отдых. Для этого нужна не очередь трат, а таблица «категория — сумма», и в Rust её роль играет HashMap.
Таблица вместо очереди: что такое HashMap
Представьте гардероб с номерками. Вы не перебираете все куртки подряд: называете номерок, и гардеробщик сразу приносит нужную вещь. В Vec вещи лежат по порядку, и искать приходится по месту или перебором. В таблице-гардеробе вы находите значение по имени, а не по позиции.
Официально такая коллекция называется HashMap (хэш-таблица, словарь). Она хранит пары: ключ (то, по чему ищем, у нас — категория) и значение (то, что нашли, у нас — сумма). Ключи в таблице уникальны: одному ключу соответствует одно значение. В стандартной библиотеке HashMap лежит в std::collections, поэтому её нужно подключить строкой use. Тип записывается как HashMap<&str, u32>: в угловых скобках сначала тип ключа, потом тип значения. Мы пока только читаем такие записи, свои обобщённые типы писать не будем.
Создаём пустую таблицу через HashMap::new(), добавляем пары методом insert, считаем записи методом len:
use std::collections::HashMap;
fn main() {
let mut totals: HashMap<&str, u32> = HashMap::new();
println!("Записей: {}", totals.len());
totals.insert("еда", 350);
totals.insert("транспорт", 60);
println!("Записей: {}", totals.len());
totals.insert("еда", 400);
println!("Записей: {}", totals.len());
}
Разбор по строкам
use std::collections::HashMap;— подключаем тип из стандартной библиотеки; без этой строки компилятор не знает словаHashMap.let mut totals: HashMap<&str, u32> = HashMap::new();— пустая таблица: ключи — строковые срезы (названия категорий), значения — суммы в рублях.mutнужен, потому что мы будем добавлять записи.totals.len()— сколько пар лежит в таблице, как и у вектора; сейчас их 0.totals.insert("еда", 350);— кладём пару «еда — 350». После второгоinsert(с транспортом) в таблице две записи.totals.insert("еда", 400);— ключ"еда"уже есть, поэтому новая запись не появляется: значение 350 заменяется на 400. Записей по-прежнему две.
Записей: 0
Записей: 2
Записей: 2
Таблица не копит, а заменяет
Запомните это поведение: insert по существующему ключу перезаписывает значение. Для подсчёта сумм нам понадобится другой приём, и он впереди — entry.
Достаём значения: get, contains_key и Option
Положить пару мало, нужно ещё её достать. Метод get принимает ключ и отвечает на вопрос «что лежит под этим именем?». А если под таким именем ничего нет? Ответ придётся честно сказать «ничего», и здесь снова выручает знакомый Option: get возвращает Some(...), когда ключ найден, и None, когда нет. Паники при отсутствии ключа не будет.
Есть одна тонкость. Внутри Some лежит не само число, а ссылка на него: тип ответа — Option<&u32>. Таблица только «даёт заглянуть» в своё значение, а не вынимает его (помните заимствование: смотреть можно, не забирая). Печатать такую ссылку можно как обычное число, а вот складывать её с числом напрямую нельзя. Если нужен сам u32, пользуются методом copied(): он превращает Option<&u32> в Option<u32>, то есть делает копию числа. А unwrap_or(0) достаёт значение или подставляет запасное.
use std::collections::HashMap;
fn main() {
let mut totals: HashMap<&str, u32> = HashMap::new();
totals.insert("еда", 350);
println!("{:?}", totals.get("еда"));
println!("{:?}", totals.get("дом"));
println!("{}", totals.contains_key("еда"));
if let Some(sum) = totals.get("еда") {
println!("На еду: {} руб.", sum);
}
let taxi = totals.get("транспорт").copied().unwrap_or(0);
println!("На транспорт: {} руб.", taxi);
}
Разбор по строкам
totals.get("еда")— ищем ключ. Ключ есть, поэтому ответSome(350)(внутри — ссылка, но при печати через{:?}она выглядит как число).totals.get("дом")— такого ключа нет, ответNone.totals.contains_key("еда")— «есть ли такой ключ?»; возвращаетtrueилиfalse, когда само значение не нужно.if let Some(sum) = totals.get("еда")— выполняем блок только если ключ найден; внутриsum— ссылка на сумму, иprintln!спокойно её печатает..copied().unwrap_or(0)— для ключа"транспорт"получилсяNone,copiedничего не меняет, аunwrap_or(0)подставляет 0: так получается обычное число безmatch.
Some(350)
None
true
На еду: 350 руб.
На транспорт: 0 руб.
Сведём основные методы в таблицу, чтобы вы могли в неё подглядывать:
| Метод | Что делает | Что возвращает |
|---|---|---|
insert(ключ, значение) | кладёт пару; если ключ был — заменяет значение | Option<V>: прежнее значение, если ключ уже был, иначе None |
get(&ключ) | заглядывает в значение по ключу | Option<&V> |
contains_key(&ключ) | проверяет, есть ли ключ | bool |
len() | считает пары | usize |
Строки и ссылки
Методы get и contains_key принимают ключ по ссылке: они лишь смотрят, а не забирают ключ. Строковый срез "еда" уже ссылка, поэтому писать & перед ним не нужно. Для остальных ключей (например, для Category дальше в уроке) амперсанд ставим сами: get(&Category::Food). Если ключи в таблице — String, искать всё равно можно по &str: get("еда"), создавать лишнюю строку не нужно.
Копим суммы: entry и or_insert
Теперь главное для «Копилки»: как копить суммы. Допустим, потратили 350 на еду, потом 60 на транспорт, потом ещё 120 на еду. Вручную пришлось бы каждый раз выяснять: «а есть ли уже запись про еду? Если нет, создать с нулём. Если есть, прочитать, прибавить, записать обратно». Слишком много шагов для простой мысли «прибавь к этой категории».
Представьте учётчика с тетрадью, в которой по странице на категорию. Просьба звучит так: «открой страницу "еда"; если страницы ещё нет, заведи её и запиши 0; и допиши сюда новую сумму». Это и есть entry («запись», интерфейс одной ячейки таблицы). Метод entry(ключ) находит ячейку для ключа, or_insert(0) говорит «если она пуста — положи 0» и отдаёт изменяемую ссылку (&mut u32) на число внутри ячейки. Остаётся пройти по ссылке к самому числу звёздочкой (как *spent += amount в модуле про заимствование) и прибавить.
use std::collections::HashMap;
fn main() {
let spent = [("еда", 350), ("транспорт", 60), ("еда", 120)];
let mut totals: HashMap<&str, u32> = HashMap::new();
for (category, amount) in spent {
*totals.entry(category).or_insert(0) += amount;
}
println!("{:?}", totals.get("еда"));
println!("{:?}", totals.get("транспорт"));
}
Разбор по строкам
let spent = [("еда", 350), ...];— массив кортежей: у каждой траты есть категория и сумма. Так мы быстро получаем пробные данные без структур.for (category, amount) in spent— цикл берёт кортеж за кортежем и сразу раскладывает его на две переменные:categoryполучает первый элемент,amount— второй.totals.entry(category)— шаг 1: найти ячейку под этим ключом (даже если ячейки ещё нет)..or_insert(0)— шаг 2: если ячейка пуста, положить в неё 0; в любом случае получить&mut u32— ссылку на число в ячейке.*— шаг 3: пройти по ссылке к самому числу, ведь прибавлять нужно к числу, а не к ссылке.+= amount— шаг 4: прибавить сумму. Для еды сначала получится 0 + 350, затем 350 + 120.
Some(470)
Some(60)
Подсчёт слов тем же способом
Хотите считать не суммы, а количество? Прибавляйте единицу: *counts.entry(word).or_insert(0) += 1;. Если ключ в таблице — String, то в entry отдайте именно строку-владельца, например word.to_string(): entry забирает ключ себе. Это пригодится вам в первой задаче.
Какие ключи годятся: Hash и derive
Откуда таблица знает, в какую ячейку положить ключ? Она превращает его в число-отпечаток (хэш) и по нему выбирает место. Чтобы это работало, у типа ключа должны быть два умения. Первое — Hash: «умею превращаться в отпечаток». Второе — Eq: «умею точно сказать, равны ли два значения». Слово трейт в нашем курсе значит просто «умение типа», и подробно мы в них не копаем: нам достаточно, что умения подключаются одной строкой #[derive(...)] над типом — её вы уже писали для Debug.
Строки (&str, String), целые числа и bool эти умения имеют сразу. А вот наши собственные типы, например Category, придётся оснастить. Самое время дать перечислению полный набор:
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Category {
Food,
Transport,
Home,
Fun,
Other,
}
fn main() {
let mut totals: HashMap<Category, u32> = HashMap::new();
*totals.entry(Category::Food).or_insert(0) += 350;
*totals.entry(Category::Food).or_insert(0) += 120;
println!("{:?}", totals.get(&Category::Food));
println!("{:?}", totals.get(&Category::Home));
}
Разбор по строкам
Debug— печать через{:?}, знакомое умение.Clone, Copy— значение можно копировать, как число. БлагодаряCopyмы отдадимexpense.categoryв таблицу, и трата не потеряет свою категорию.PartialEq, Eq— сравнение через==;Eqдобавляет гарантию, что равенство «настоящее», без исключений.Hash— умение давать хэш-отпечаток; именно его требует таблица от ключа.HashMap<Category, u32>— ключи теперь не строки, а варианты перечисления: опечатку в названии категории компилятор не пропустит.get(&Category::Food)— ключ передаём по ссылке, поэтому ставим&. Для еды получаемSome(470), для дома —None: трат на дом не было.
Some(470)
None
Не всё годится в ключи
Числа с дробной частью (f64) ключами быть не могут: у них нет умения Eq и Hash. Для денег в «Копилке» это не проблема — мы держим суммы целыми рублями (u32).
Порядок и сортировка для отчёта
Высыпьте карточки из коробки на стол: порядок получится случайный, и при следующем высыпании может быть другим. Так ведёт себя и HashMap. Она быстро находит значение по ключу, но не обещает никакого порядка при обходе: ни порядка вставки, ни алфавитного. Печатая таблицу в цикле, вы можете получить строки в разном порядке от запуска к запуску. Для отчёта это неудобно, поэтому мы сначала переносим пары в вектор, а потом сортируем.
Для этого нужны три шага. into_iter() разбирает таблицу на пары (ключ, значение) — саму таблицу он при этом забирает, она больше не нужна. collect() собирает пары в Vec; нужный тип мы подсказываем слева у переменной. А sort_by сортирует вектор по правилу, которое записывается замыканием, знакомым по итераторам.
use std::collections::HashMap;
fn main() {
let mut totals: HashMap<&str, u32> = HashMap::new();
totals.insert("еда", 470);
totals.insert("дом", 900);
totals.insert("отдых", 200);
let mut rows: Vec<(&str, u32)> = totals.into_iter().collect();
rows.sort_by(|a, b| b.1.cmp(&a.1));
for (title, sum) in rows {
println!("{}: {}", title, sum);
}
}
Разбор по строкам
totals.into_iter().collect()— разбираем таблицу на пары и складываем их в вектор. ТипVec<(&str, u32)>— вектор кортежей «название — сумма».let mut rows—mut, потому что сортировка меняет порядок элементов прямо в векторе.rows.sort_by(|a, b| ...)— замыкание получает два элементаaиb(две пары) и говорит, кто из них должен идти раньше.b.1.cmp(&a.1)—.1— второй элемент кортежа, то есть сумма (как.0и.1в первом модуле).cmpсравнивает два числа и возвращает порядок. Мы ставимbслева, аaсправа — поэтому сортировка идёт по убыванию: крупные суммы первыми. Для возрастания напишитеa.1.cmp(&b.1).for (title, sum) in rows— обходим отсортированный вектор, раскладывая каждую пару на две переменные.
дом: 900
еда: 470
отдых: 200
Равные суммы
Если у двух категорий одинаковая сумма, их порядок между собой не определён: он может меняться от запуска к запуску. Для нашей учебной копилки это не страшно, а в практике ниже суммы разные. В следующем уроке мы всё же сделаем порядок строгим: добавим вторую проверку на случай ничьей.
Практика: суммы по категориям в Копилке
Применим всё это в проекте kopilka. Откройте src/main.rs — он такой, каким вы оставили его в прошлом уроке. Цикл команд не трогаем: суммы по категориям появятся прямо в нём, в ответе на отчёт. Менять будем четыре места: подключим HashMap, дадим Category нужные умения, напишем totals_by_category и заменим ветку отчёта.
Шаг 1. Подключение и derive
Первой строкой файла, над use std::io;, добавьте use для словаря, а у Category замените строку derive:
use std::collections::HashMap;
use std::io;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Category {
Eq и Hash делают Category годным ключом таблицы; остальные умения вы уже знаете, они остаются.
Шаг 2. Функция totals_by_category
Добавьте её над fn main(), под total_in:
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
}
Разбор по строкам
expenses: &[Expense]— срез: функция только читает траты; вызываем с&expenses.-> HashMap<Category, u32>— возвращаем таблицу «категория — сумма»; владение таблицей уходит вызывающему.for expense in expenses— перебираем траты по ссылке;expense.categoryкопируется, ведь уCategoryестьCopy.*totals.entry(expense.category).or_insert(0) += expense.amount;— та самая строка сentry: находим ячейку категории, при необходимости создаём её с нулём и прибавляем сумму траты.totalsбез точки с запятой в конце — готовая таблица и есть результат функции.
Шаг 3. Отчёт по категориям
В match замените содержимое ветки Some(Command::Report). Строка «Из них еда» была нужна только для проверки total_in: теперь еда — одна из строк отчёта.
Some(Command::Report) => {
let mut rows: Vec<(Category, u32)> = totals_by_category(&expenses).into_iter().collect();
rows.sort_by(|a, b| b.1.cmp(&a.1));
for (category, sum) in &rows {
println!("{}: {} руб.", category.title(), sum);
}
let spent = total(&expenses);
println!("Всего: {} руб. из {} руб.", spent, MONTH_LIMIT);
print_status(spent, MONTH_LIMIT);
}
Разбор по строкам
totals_by_category(&expenses).into_iter().collect()— переносим таблицу в вектор пар «категория — сумма»; саму таблицу мы больше не используем, поэтому она может уйти вinto_iter.rows.sort_by(|a, b| b.1.cmp(&a.1))— сортируем по сумме, самые большие вверху.for (category, sum) in &rows— печатаем строки отчёта:category.title()даёт русское название.total(&expenses),print_status— итог и статус остались как в прошлом уроке.
Шаг 4. Убираем лишнее
Теперь total_in нигде не вызывается, и компилятор предупредит: warning: function `total_in` is never used. Отчёт по категориям эту функцию заменил, поэтому удалите её целиком: лишнего кода в «Копилке» держать не будем.
Запуск
Выполните cargo run и введите: добавить 350 еда обед, добавить 60 транспорт метро, добавить 120 еда ужин, добавить 900 дом свет, добавить 200 отдых кино, отчёт, выход. Две первые строки вывода (заголовок и подсказка) в примере опущены.
Записано: 350 руб., еда, «обед»
Потрачено: 350 руб. из 30000 руб.
Статус: в норме
Записано: 60 руб., транспорт, «метро»
Потрачено: 410 руб. из 30000 руб.
Статус: в норме
Записано: 120 руб., еда, «ужин»
Потрачено: 530 руб. из 30000 руб.
Статус: в норме
Записано: 900 руб., дом, «свет»
Потрачено: 1430 руб. из 30000 руб.
Статус: в норме
Записано: 200 руб., отдых, «кино»
Потрачено: 1630 руб. из 30000 руб.
Статус: в норме
дом: 900 руб.
еда: 470 руб.
отдых: 200 руб.
транспорт: 60 руб.
Всего: 1630 руб. из 30000 руб.
Статус: в норме
Итого потрачено: 1630 руб. из 30000 руб.
Строки отчёта идут по убыванию сумм: дом, еда, отдых, транспорт. Категории Other в отчёте нет: по ней трат не было, и в таблице нет такой ячейки. Если ввести отчёт до всяких трат, напечатаются только «Всего: 0 руб.» и статус: пустой отчёт мы тоже приведём в порядок в следующем уроке.
Проверьте себя
- Вывод совпадает с примером, а строки отчёта идут по убыванию сумм: дом, еда, отдых, транспорт.
- Уберите
Hashизderive. Какая ошибка появилась и в какой функции? ВернитеHashобратно. - Добавьте ещё одну трату:
добавить 80 еда кофе. Как изменилась строка про еду в отчёте и сколько в ней рублей теперь? - Поменяйте
b.1.cmp(&a.1)наa.1.cmp(&b.1). В каком порядке теперь идут категории? - Добавьте трату в категорию
другое. Появилась ли в отчёте новая строка и где она встала?
Частые ошибки
Ключ без Hash
Что видно: error[E0599]: the method entry exists for struct HashMap<Category, u32>, but its trait bounds were not satisfied, а в пояснении — Category: Hash и подсказка про #[derive(Hash)]. Почему: тип ключа не умеет давать хэш. Тот же текст вы увидите и для insert. Как исправить: добавьте Eq и Hash в #[derive(...)] над перечислением или структурой, которая служит ключом.
Ждут порядок вставки
Что видно: категории печатаются не в том порядке, в каком вы их добавляли, а при другом запуске — ещё иначе. Почему: HashMap не хранит порядок. Как исправить: перенесите пары в вектор (into_iter().collect()) и отсортируйте его (sort_by) перед печатью.
get вернул не число, а Option
Что видно: error[E0308]: mismatched types с пояснением expected u32, found Option<&u32>; при сложении — error[E0369]: cannot add {integer} to Option<&u32>. Почему: get возвращает Option, а внутри лежит ссылка. Как исправить: разберите ответ через match или if let, либо превратите его в число: totals.get(&key).copied().unwrap_or(0).
Забыли звёздочку у entry
Что видно: error[E0368]: binary assignment operation += cannot be applied to type &mut u32. Почему: or_insert возвращает не число, а изменяемую ссылку на него, и прибавить число к ссылке нельзя. Как исправить: поставьте * в начале выражения — *totals.entry(key).or_insert(0) += amount;.
Что важно запомнить
HashMap<K, V>хранит пары «ключ — значение»; подключается строкойuse std::collections::HashMap;, создаётся черезHashMap::new().insertпо существующему ключу заменяет значение, поэтому ключи в таблице уникальны;lenсчитает пары.get(&ключ)возвращаетOption<&V>: найдено —Some, не найдено —None. Число достаём черезcopied().unwrap_or(0),matchилиif let.- Копить суммы нужно через
*map.entry(ключ).or_insert(0) += значение;:entryнаходит ячейку,or_insertсоздаёт её с нулём,*проходит к числу. - Ключ должен уметь
EqиHash; для своих типов это#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]. Ключом не может бытьf64. - У HashMap нет порядка. Для упорядоченного отчёта:
into_iter().collect()вVecпар иsort_by(|a, b| b.1.cmp(&a.1))по убыванию. - В «Копилке» теперь есть
fn totals_by_category(expenses: &[Expense]) -> HashMap<Category, u32>иCategoryс полнымderive.
Суммы по категориям уже лежат в таблице, но печатаем мы их пока по-простому. В следующем уроке соберём из этого настоящую команду отчёт: с выровненными колонками, итогом и статусом лимита.