Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Хеш-таблицы: ключи и значения

Последняя из наших основных коллекций — хеш-таблица. Тип HashMap<K, V> хранит соответствие ключей типа K значениям типа V и использует для этого хеш-функцию, которая определяет, как ключи и значения размещаются в памяти. Такая структура данных есть во многих языках, но называется по-разному: hash, map, object, hash table, dictionary (словарь) или associative array (ассоциативный массив) — и это далеко не всё.

Хеш-таблицы удобны, когда данные нужно искать не по индексу, как в векторе, а по ключу, который может быть любого типа. Например, в игре можно хранить счёт каждой команды в хеш-таблице, где ключ — название команды, а значение — её счёт. Зная название команды, можно получить её счёт.

В этом разделе мы рассмотрим основной API хеш-таблиц, но в функциях, которые стандартная библиотека определяет для HashMap<K, V>, спрятано ещё много полезного. Как обычно, подробности ищите в документации стандартной библиотеки.

Создание хеш-таблицы

Один из способов создать пустую хеш-таблицу — вызвать new, а элементы добавлять методом insert. В листинге 8-20 мы отслеживаем счёт двух команд — Blue (синих) и Yellow (жёлтых). У синих сначала 10 очков, у жёлтых — 50.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);
}
Листинг 8-20: Создаём новую хеш-таблицу и добавляем в неё ключи и значения

Обратите внимание: сначала нужно подключить HashMap через use из раздела коллекций стандартной библиотеки. Из трёх наших основных коллекций эта используется реже всего, поэтому её нет среди того, что прелюдия автоматически подключает в область видимости. Да и поддержки в стандартной библиотеке у хеш-таблиц меньше: например, нет встроенного макроса для их создания.

Как и векторы, хеш-таблицы хранят данные в куче. У этой HashMap ключи типа String и значения типа i32. И, как векторы, хеш-таблицы однородны: все ключи должны быть одного типа, и все значения — тоже одного типа.

Доступ к значениям в хеш-таблице

Получить значение из хеш-таблицы можно, передав его ключ методу get, как в листинге 8-21.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    let team_name = String::from("Blue");
    let score = scores.get(&team_name).copied().unwrap_or(0);
}
Листинг 8-21: Получаем из хеш-таблицы счёт синей команды

Здесь score получит значение, связанное с синей командой, — то есть 10. Метод get возвращает Option<&V>: если в хеш-таблице нет значения для этого ключа, get вернёт None. Программа обрабатывает Option так: сначала вызывает copied, чтобы получить Option<i32> вместо Option<&i32>, а затем unwrap_or, чтобы записать в score ноль, если в scores нет записи для этого ключа.

Перебрать все пары «ключ — значение» в хеш-таблице можно так же, как элементы вектора, — циклом for:

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    for (key, value) in &scores {
        println!("{key}: {value}");
    }
}

Этот код выведет все пары в произвольном порядке:

Yellow: 50
Blue: 10

Хеш-таблицы и владение

Значения типов, реализующих трейт Copy, вроде i32, копируются в хеш-таблицу. А значения, которые владеют своими данными, вроде String, перемещаются, и владельцем этих значений становится хеш-таблица, как показано в листинге 8-22.

fn main() {
    use std::collections::HashMap;

    let field_name = String::from("Favorite color");
    let field_value = String::from("Blue");

    let mut map = HashMap::new();
    map.insert(field_name, field_value);
    // здесь field_name и field_value уже недействительны; попробуйте их
    // использовать и посмотрите, какую ошибку выдаст компилятор!
}
Листинг 8-22: После вставки ключи и значения принадлежат хеш-таблице

После того как вызов insert переместил переменные field_name и field_value в хеш-таблицу, пользоваться ими уже нельзя.

Если вставлять в хеш-таблицу ссылки на значения, сами значения в неё перемещаться не будут. Но значения, на которые указывают ссылки, должны оставаться действительными как минимум столько же, сколько и хеш-таблица. Подробнее об этом мы поговорим в разделе «Проверка ссылок с помощью времён жизни» главы 10.

Изменение хеш-таблицы

Количество пар «ключ — значение» может расти, но с каждым уникальным ключом в каждый момент связано только одно значение (обратное неверно: например, и у синих, и у жёлтых в хеш-таблице scores может храниться значение 10).

Когда нужно поменять данные в хеш-таблице, приходится решить, что делать, если у ключа уже есть значение. Можно заменить старое значение новым, совсем не обращая внимания на старое. Можно оставить старое и проигнорировать новое, добавляя новое значение, только если у ключа значения ещё нет. А можно объединить старое значение с новым. Посмотрим, как сделать каждое из этого!

Перезапись значения

Если вставить в хеш-таблицу ключ со значением, а потом вставить тот же ключ с другим значением, значение, связанное с ключом, будет заменено. Хотя код в листинге 8-23 вызывает insert дважды, в хеш-таблице окажется только одна пара «ключ — значение»: оба раза мы вставляем значение для ключа синей команды.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Blue"), 25);

    println!("{scores:?}");
}
Листинг 8-23: Заменяем значение, хранящееся по определённому ключу

Этот код выведет {"Blue": 25}. Исходное значение 10 перезаписано.

Добавление ключа, только если его ещё нет

Часто нужно проверить, есть ли уже в хеш-таблице такой ключ со значением, и дальше поступить так: если ключ есть — оставить существующее значение как есть; если нет — вставить ключ и значение для него.

Для этого у хеш-таблиц есть специальный API — метод entry, который принимает параметром ключ, который нужно проверить. Метод entry возвращает перечисление Entry, представляющее значение, которое может существовать, а может и нет. Допустим, мы хотим проверить, есть ли значение у ключа жёлтой команды. Если нет — вставить значение 50; то же самое для синей команды. С API entry код выглядит как в листинге 8-24.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();
    scores.insert(String::from("Blue"), 10);

    scores.entry(String::from("Yellow")).or_insert(50);
    scores.entry(String::from("Blue")).or_insert(50);

    println!("{scores:?}");
}
Листинг 8-24: Метод entry вставляет значение, только если у ключа его ещё нет

Метод or_insert у Entry устроен так: если ключ существует, он возвращает изменяемую ссылку на значение этого ключа; если нет — вставляет свой параметр как новое значение для ключа и возвращает изменяемую ссылку на это новое значение. Это куда чище, чем писать такую логику самим, да и с анализатором заимствований так ладить проще.

Код из листинга 8-24 выведет {"Yellow": 50, "Blue": 10}. Первый вызов entry вставит ключ жёлтой команды со значением 50, потому что значения у жёлтых ещё нет. Второй вызов entry хеш-таблицу не изменит: у синих уже есть значение 10.

Обновление значения на основе старого

Ещё один частый сценарий — найти значение по ключу и обновить его, опираясь на старое значение. Например, в листинге 8-25 код подсчитывает, сколько раз каждое слово встречается в тексте. Мы используем хеш-таблицу, где ключи — слова, и увеличиваем значение, чтобы отслеживать, сколько раз нам встретилось это слово. Если слово встретилось впервые, сначала вставляем значение 0.

fn main() {
    use std::collections::HashMap;

    let text = "hello world wonderful world";

    let mut map = HashMap::new();

    for word in text.split_whitespace() {
        let count = map.entry(word).or_insert(0);
        *count += 1;
    }

    println!("{map:?}");
}
Листинг 8-25: Подсчёт слов с помощью хеш-таблицы, которая хранит слова и количество их повторений

Этот код выведет {"world": 2, "hello": 1, "wonderful": 1}. У вас пары могут появиться в другом порядке: как мы говорили в разделе «Доступ к значениям в хеш-таблице», хеш-таблица перебирается в произвольном порядке.

Метод split_whitespace возвращает итератор по фрагментам строки text, разделённым пробельными символами. Метод or_insert возвращает изменяемую ссылку (&mut V) на значение указанного ключа. Мы сохраняем эту ссылку в переменной count, поэтому, чтобы присвоить значение, сначала нужно разыменовать count звёздочкой (*). Изменяемая ссылка выходит из области видимости в конце итерации цикла for, так что все эти изменения безопасны и разрешены правилами заимствования.

Хеш-функции

По умолчанию HashMap использует хеш-функцию SipHash, которая устойчива к атакам типа «отказ в обслуживании» (DoS), направленным на хеш-таблицы1. Это не самый быстрый алгоритм хеширования, но выигрыш в безопасности стоит потери в производительности. Если вы профилируете код и обнаружите, что хеш-функция по умолчанию для ваших задач слишком медленная, можно переключиться на другую, указав другой хешер (hasher) — тип, реализующий трейт BuildHasher. О трейтах и о том, как их реализовывать, мы поговорим в главе 10. Писать свой хешер с нуля не обязательно: на crates.io есть библиотеки от других пользователей Rust с хешерами для многих распространённых алгоритмов хеширования.

Итоги

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

  1. Дан список целых чисел. С помощью вектора найдите медиану (значение, которое окажется посередине после сортировки) и моду (значение, которое встречается чаще всего; здесь пригодится хеш-таблица).
  2. Переведите строки на «поросячью латынь» (Pig Latin). Первая согласная каждого слова переносится в конец слова, и к ней добавляется ay: first превращается в irst-fay. К словам, которые начинаются с гласной, в конец добавляется hay (apple превращается в apple-hay). Не забывайте про особенности кодировки UTF-8!
  3. С помощью хеш-таблицы и векторов сделайте текстовый интерфейс, через который пользователь может добавлять сотрудников в отделы компании: например, «Add Sally to Engineering» или «Add Amir to Sales». Затем дайте пользователю возможность получить список всех сотрудников отдела или всех сотрудников компании по отделам, отсортированный по алфавиту.

В документации по API стандартной библиотеки описаны методы векторов, строк и хеш-таблиц, которые пригодятся для этих упражнений!

Мы переходим к более сложным программам, в которых операции могут завершаться неудачей, — самое время поговорить об обработке ошибок. Этим мы и займёмся!


  1. https://en.wikipedia.org/wiki/SipHash ↩