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

HashMap

Вы научитесь хранить пары «ключ — значение» в HashMap и копить суммы по категориям одной строкой через entry.

Теория + практика~25 минутСредний

Вектор трат и итераторы уже позволяют посчитать общую сумму или сумму по одной категории. Но отчёт хочет другого: сразу увидеть, сколько ушло на еду, сколько на дом, сколько на отдых. Для этого нужна не очередь трат, а таблица «категория — сумма», и в Rust её роль играет HashMap.

Таблица вместо очереди: что такое HashMap

Представьте гардероб с номерками. Вы не перебираете все куртки подряд: называете номерок, и гардеробщик сразу приносит нужную вещь. В Vec вещи лежат по порядку, и искать приходится по месту или перебором. В таблице-гардеробе вы находите значение по имени, а не по позиции.

Официально такая коллекция называется HashMap (хэш-таблица, словарь). Она хранит пары: ключ (то, по чему ищем, у нас — категория) и значение (то, что нашли, у нас — сумма). Ключи в таблице уникальны: одному ключу соответствует одно значение. В стандартной библиотеке HashMap лежит в std::collections, поэтому её нужно подключить строкой use. Тип записывается как HashMap<&str, u32>: в угловых скобках сначала тип ключа, потом тип значения. Мы пока только читаем такие записи, свои обобщённые типы писать не будем.

Создаём пустую таблицу через HashMap::new(), добавляем пары методом insert, считаем записи методом len:

Rust · main.rs
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) достаёт значение или подставляет запасное.

Rust · main.rs
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 в модуле про заимствование) и прибавить.

Rust · main.rs
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, придётся оснастить. Самое время дать перечислению полный набор:

Rust · main.rs
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 сортирует вектор по правилу, которое записывается замыканием, знакомым по итераторам.

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

Rust · main.rs
use std::collections::HashMap;
use std::io;
Rust · main.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Category {

Eq и Hash делают Category годным ключом таблицы; остальные умения вы уже знаете, они остаются.

Шаг 2. Функция totals_by_category

Добавьте её над fn main(), под total_in:

Rust · main.rs
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: теперь еда — одна из строк отчёта.

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

Суммы по категориям уже лежат в таблице, но печатаем мы их пока по-простому. В следующем уроке соберём из этого настоящую команду отчёт: с выровненными колонками, итогом и статусом лимита.

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