1. Обзор

Сегодня мы рассмотрим доступные варианты обработки карты с дублирующимися ключами или, другими словами, карты, которая позволяет хранить несколько значений для одного ключа.

2. Стандартные карты

Java имеет несколько реализаций интерфейса Map, каждая из которых имеет свои особенности.

Однако ни одна из существующих реализаций ядра Java не позволяет Map обрабатывать несколько значений для одного ключа.

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

Map<String, String> map = new HashMap<>();
assertThat(map.put("key1", "value1")).isEqualTo(null);
assertThat(map.put("key1", "value2")).isEqualTo("value1");
assertThat(map.get("key1")).isEqualTo("value2");

Как мы можем достичь желаемого поведения тогда?

3. Коллекция значений

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

Map<String, List<String>> map = new HashMap<>();
List<String> list = new ArrayList<>();
map.put("key1", list);
map.get("key1").add("value1");
map.get("key1").add("value2");
 
assertThat(map.get("key1").get(0)).isEqualTo("value1");
assertThat(map.get("key1").get(1)).isEqualTo("value2");

Однако это подробное решение имеет множество недостатков и подвержено ошибкам. Это означает, что нам нужно создавать экземпляр Collection для каждого значения, проверять его наличие перед добавлением или удалением значения, удалять его вручную, когда не осталось значений, и так далее.

С Java 8 мы могли бы использовать compute() методы и улучшить код выше:

Map<String, List<String>> map = new HashMap<>();
map.computeIfAbsent("key1", k -> new ArrayList<>()).add("value1");
map.computeIfAbsent("key1", k -> new ArrayList<>()).add("value2");

assertThat(map.get("key1").get(0)).isEqualTo("value1");
assertThat(map.get("key1").get(1)).isEqualTo("value2");

Хотя это то, что стоит знать, мы должны избегать этого, если только у нас нет для этого веских оснований, например ограничительные политики компании, запрещающие нам использовать сторонние библиотеки.

В противном случае, прежде чем писать собственную реализацию Map и заново изобретать колесо, мы должны выбрать один из нескольких вариантов, доступных вне коробки.

4. Apache Commons Collections

Как обычно, у Apache есть решение нашей проблемы.

Давайте начнем с импорта последней версии Common Collections:

<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-collections4</artifactId>
  <version>4.1</version>
</dependency>

4.1. MultiMap

Интерфейс org.apache.commons.collections.MultiMap определяет карту, которая содержит коллекцию значений для каждого ключа.

Он реализован классом org.apache.commons.collections.map.MultiValueMap, который автоматически обрабатывает большую часть шаблонов под капотом.

MultiMap<String, String> map = new MultiValueMap<>();
map.put("key1", "value1");
map.put("key1", "value2");
assertThat((Collection<String>) map.get("key1"))
  .contains("value1", "value2");

Хотя этот класс доступен начиная с CC 3.2, он не является потокобезопасным, и его использование в CC 4.1 не рекомендуется. Мы должны использовать его только тогда, когда мы не можем перейти на более новую версию.

4.2. MultiValuedMap

Преемником MultiMap является интерфейс org.apache.commons.collections.MultiValuedMap. Он имеет несколько реализаций, готовых к использованию.

Давайте посмотрим, как сохранить несколько значений в ArrayList, который сохраняет дубликаты:

MultiValuedMap<String, String> map = new ArrayListValuedHashMap<>();
map.put("key1", "value1");
map.put("key1", "value2");
map.put("key1", "value2");
assertThat((Collection<String>) map.get("key1"))
  .containsExactly("value1", "value2", "value2");

В качестве альтернативы, мы могли бы использовать HashSet, который удаляет дубликаты:

MultiValuedMap<String, String> map = new HashSetValuedHashMap<>();
map.put("key1", "value1");
map.put("key1", "value1");
assertThat((Collection<String>) map.get("key1"))
  .containsExactly("value1");

Обе вышеупомянутые реализации не являются потокобезопасными.

Давайте посмотрим, как мы можем использовать декоратор UnmodifiableMultiValuedMap, чтобы сделать их неизменяемыми:

@Test(expected = UnsupportedOperationException.class)
public void givenUnmodifiableMultiValuedMap_whenInserting_thenThrowingException() {
    MultiValuedMap<String, String> map = new ArrayListValuedHashMap<>();
    map.put("key1", "value1");
    map.put("key1", "value2");
    MultiValuedMap<String, String> immutableMap =
      MultiMapUtils.unmodifiableMultiValuedMap(map);
    immutableMap.put("key1", "value3");
}

5. Guava Multimap

Guava - это Google Core Библиотеки для Java API.

Интерфейс com.google.common.collect.Multimap существует с версии 2. На момент написания статьи последняя версия была 25, но поскольку после версии 23 она была разделена на несколько веток для jre и android, мы все еще используем версию 23 для наших примеров.

Начнем с импорта Guava в наш проект:

<dependency>
  <groupId>com.google.guava</groupId>
  <artifactId>guava</artifactId>
  <version>23.0</version>
</dependency>

Гуава следовал пути нескольких реализаций с самого начала.

Наиболее распространенным является com.google.common.collect.ArrayListMultimap, который использует HashMap, поддерживаемый ArrayList для каждого значения.

Multimap<String, String> map = ArrayListMultimap.create();
map.put("key1", "value2");
map.put("key1", "value1");
assertThat((Collection<String>) map.get("key1"))
  .containsExactly("value2", "value1");

Как всегда, мы должны предпочесть неизменные реализации интерфейса Multimap com.google.common.collect.ImmutableListMultimap и com.google.common.collect.ImmutableSetMultimap.

5.1. Общие реализации карт

Когда нам нужна конкретная реализация Map, первое, что нужно сделать, это проверить, существует ли она, потому что, вероятно, Guava уже реализовал ее.

Например, мы можем использовать com.google.common.collect.LinkedHashMultimap, который сохраняет порядок вставки ключей и значений

Multimap<String, String> map = LinkedHashMultimap.create();
map.put("key1", "value3");
map.put("key1", "value1");
map.put("key1", "value2");
assertThat((Collection<String>) map.get("key1"))
  .containsExactly("value3", "value1", "value2");

В качестве альтернативы мы можем использовать com.google.common.collect.TreeMultimap, который выполняет итерацию ключей и значений в их естественном порядке.

Multimap<String, String> map = TreeMultimap.create();
map.put("key1", "value3");
map.put("key1", "value1");
map.put("key1", "value2");
assertThat((Collection<String>) map.get("key1"))
  .containsExactly("value1", "value2", "value3");

5.2.. Создание нашего собственного MultiMap

Доступно много других реализаций.

Однако мы можем захотеть улучшить Map и/или List.

К счастью, у Guava есть фабричный метод, позволяющий нам декорировать любую карту или список - Multimap.newMultimap().

6. Заключение

Мы видели, как хранить несколько значений ключа на карте всеми основными способами.

Мы изучили наиболее популярные реализации Apache Commons Collections и Guava, которые должны быть предпочтительнее пользовательских решений, когда это возможно.