Интерфейс Map в Java
Отображения ключ-значение в Java: интерфейс Map — put, get, remove, keySet, values, entrySet и методы Java 8.
В этой главе рассматривается контракт Map: семь основных методов, три представления для итерации, методы по умолчанию Java 8, делающие работу с картами лаконичной, правила обработки null в разных реализациях и сравнение карт на равенство. По окончании вы будете знать, какие идиомы использовать и какая стандартная реализация подходит для конкретной задачи.
Map<K, V> — вторая половина фреймворка коллекций. В отличие от интерфейса Collection, он не расширяет Collection — это отдельная иерархия, поскольку хранение ключей, связанных со значениями, является иной абстракцией, нежели хранение набора элементов. Внутренне большинство реализаций Set представляют собой просто Map, где значение игнорируется, поэтому в некотором смысле Map — это основная структура, а Set — её более простой аналог.
Контракт краток: каждому ключу сопоставляется не более одного значения, ключи образуют множество (без дубликатов), а значения — произвольную коллекцию (дубликаты значений допустимы). Различие между реализациями заключается в порядке итерации, обработке null, инвариантах порядка и потокобезопасности — но семь основных методов ниже ведут себя одинаково во всех из них.
Семь основных методов
V put(K key, V value); // insert or overwrite; returns previous value or null
V get(Object key); // lookup; returns null if missing
V remove(Object key); // delete; returns previous value or null
boolean containsKey(Object k); // does the key exist (even if value is null)?
boolean containsValue(Object v); // O(n) scan of values
int size();
boolean isEmpty();Несколько тонкостей, которые стоит усвоить:
-
putвозвращает предыдущее значение для данного ключа илиnull, если сопоставления не было. Именно так реализуются идиомы «вставить, если отсутствует» — хотя в этом нет необходимости, посколькуputIfAbsentделает именно это и более понятен. -
Возвращение
getзначенияnullозначает либо «ключа нет», либо «ключ есть, но его значение равноnull». Это неоднозначность, если карта допускает null-значения; используйтеcontainsKeyдля разграничения или — ещё лучше —getOrDefaultдля задания значения по умолчанию:int count = counts.getOrDefault("java", 0); // 0 if absent
Три представления
Map не является итерируемым напрямую. Для итерации нужно запросить одно из трёх представлений его содержимого:
Set<K> keys = map.keySet();
Collection<V> values = map.values();
Set<Map.Entry<K, V>> es = map.entrySet();Эти представления живые — они отражают изменения в базовой карте, и изменения через представление распространяются обратно. Удаление записи через entrySet() удаляет её из карты; итерация по keySet() с вызовом iterator.remove() удаляет запись. Добавить в keySet или values нельзя (нет пары — значения или ключа), но можно очищать или удалять элементы.
Для итерации почти всегда используется entrySet() — получение обеих частей каждой пары одновременно дешевле, чем вызов get(k) для каждого ключа:
for (Map.Entry<String, Integer> e : counts.entrySet()) {
System.out.println(e.getKey() + " -> " + e.getValue());
}Или лямбда-форма, добавленная в Java 8:
counts.forEach((k, v) -> System.out.println(k + " -> " + v));Методы по умолчанию Java 8, которые действительно важны
Java 8 добавила несколько методов Map, принимающих функцию и работающих атомарно. Они превращают многие трёхстрочные паттерны в однострочники:
getOrDefault(k, def)— какget(k), но возвращаетdefвместоnull.putIfAbsent(k, v)— выполняетputтолько если ключ отсутствует.computeIfAbsent(k, fn)— атомарно вычисляет значение при его отсутствии, сохраняет и возвращает его. Краеугольный камень паттерна «мемоизировать дорогостоящий вызов»:Map<String, List<Order>> byUser = new HashMap<>(); byUser.computeIfAbsent(order.user(), u -> new ArrayList<>()).add(order);computeIfPresent(k, biFn)— пересчитывает значение только если ключ уже существует. Полезно для счётчиков, которые должны игнорировать неизвестные ключи.compute(k, biFn)— универсальный: передаёт текущее значение (или null), получает новое. Удаляет запись, если функция возвращает null.merge(k, v, biFn)— объединяет новое значение с существующим, если оно есть. Классический счётчик:for (String w : words) { counts.merge(w, 1, Integer::sum); // first time: stores 1; subsequent: adds }
Именно эти операции делают современную работу с картами в Java лаконичной. Используйте их вместо пар get/put.
Null-ключи и null-значения
Правила зависят от реализации:
| Класс | null-ключ | null-значение |
|---|---|---|
HashMap | один допустим | допустимы |
LinkedHashMap | один допустим | допустимы |
TreeMap | нет | допустимы |
Hashtable | нет | нет |
ConcurrentHashMap | нет | нет |
Map.of(...) (неизменяемый) | нет | нет |
Общее правило для нового кода: не храните null в карте. Используйте Optional, сигнальное значение или просто не добавляйте запись. Фабрика Map.of принудительно соблюдает это правило.
Равенство между реализациями
Два объекта Map считаются equals, если их entrySet() равны — одинаковые ключи, одинаковые значения, независимо от порядка итерации или реализации. HashMap и TreeMap с одинаковыми парами ключ-значение будут равны. Это то же правило «структурного равенства», которому следует Set.
Стандартные реализации — обзор
| Класс | Базовая структура | Порядок итерации | Применение |
|---|---|---|---|
HashMap | хеш-таблица | не определён | по умолчанию |
LinkedHashMap | хеш-таблица + связный список | порядок вставки или доступа | LRU-кэши, предсказуемая итерация |
TreeMap | красно-чёрное дерево | сортировка по ключу | диапазонные запросы по ключам, сортированный вывод |
Hashtable | хеш-таблица, синхронизированная | не определён | устаревший; редко подходит |
ConcurrentHashMap | полосатая хеш-таблица | не определён | многопоточный код |
EnumMap | битовый массив | порядок enum | Map<MyEnum, V> |
Map.of(...) | неизменяемый | не определён | небольшие фиксированные карты |
Следующие главы подробно рассматривают повседневные варианты: HashMap, LinkedHashMap и TreeMap. ConcurrentHashMap и EnumMap рассматриваются в более поздних разделах.
Практический пример: счётчики, группировка и три представления
Программа ниже демонстрирует современные идиомы работы с картами: merge для подсчёта, computeIfAbsent для группировки, все три представления и различие между getOrDefault и get.
Что можно вынести из запуска:
merge(word, 1, Integer::sum)— современный идиоматичный подсчёт слов. Никакихget/put/проверок наnull.computeIfAbsentсоздаёт пустой список ровно один раз для каждого ключа — чистый способ построитьMap<K, List<V>>без повсеместныхif (m.get(k) == null) m.put(k, new ArrayList<>()).- Три представления — это живые окна в одну и ту же карту;
entrySet()— самый дешёвый способ итерации, когда нужны обе части каждой пары. getOrDefaultустраняет наиболее распространённую причину проверять на null. Используйте его всякий раз, когда есть разумное значение по умолчанию.HashMapиTreeMapс одинаковыми записямиequalsмежду собой; единственное, что меняется, — это порядок итерации.
Что дальше
Реализация по умолчанию — и та, которую вы увидите в 90% кода на Java, — основана на хеш-таблице. HashMap — следующая глава; мы рассмотрим массив бакетов, оптимизацию древовидной структуры в Java 8 и что делать, когда ключами являются ваши собственные классы.