Впечатляюще удобный API для преобразования списков в карты

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

Java имеет впечатляющий потоковый API, который упрощает преобразование списков в карты. Потоки Java не могут делать больше или быстрее, чем for-циклы, но код, использующий потоковый API, часто может быть короче и понятнее. В этом посте я рассматриваю разумные способы преобразования потоков в карты.

Есть четыре простых способа вернуть Map из Stream. Сборщик, созданный перегруженными методами toMap(), groupingBy() или partitioningBy() класса Collectors, можно использовать внутри терминального метода Stream.collect(collector). Его форма collect(supplier, accumulator, combiner) может напрямую возвращать карту без использования сборщиков. Таким образом, в зависимости от преобразования может быть несколько возможных способов создания карты. Параметры могут немного отличаться по размеру кода.

Чтобы проиллюстрировать этот пост, я использую List из 15 различных Player объектов. Каждый игрок имеет уникальное имя пользователя, пол, счет и принадлежит к команде:

public record Player(String name, String sex, int score, String team) {}

Я импортирую все статические методы класса Collectors, чтобы к методам не нужно было добавлять префикс с именем класса, и, таким образом, код был более точным:

import static java.util.stream.Collectors.*;

Списки со всеми элементами, создающими уникальные ключи

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

Карта, связывающая идентификаторы с объектами, может быть создана с помощью коллектора toMap(keyMapper,valueMapper):

Map<String, Player> playersByName = players.stream().collect(toMap(Player::name, p -> p));

Первые три связанных ключа и значения в созданной карте (ключи и значения разделены =>, не показанные записи обозначены ):

Charles =>Player[name=Charles, sex=M, score=2, team=Green]
Evelyn  =>Player[name=Evelyn, sex=F, score=7, team=Blue]
Madison =>Player[name=Madison, sex=F, score=9, team=Blue]
...

toMap(keyMapper,valueMapper) коллектор также можно использовать для создания карты, связывающей два свойства одного и того же объекта. Например, карта с оценками пользователей.

Map<String, Integer> playerScores = players.stream().collect(toMap(Player::name, Player::score));

Charles =>2
Evelyn  =>7
Madison =>9
..

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

Списки, производящие неуникальные ключи

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

Map<String, Player> playersByName2 = players.stream().collect(HashMap::new, (m, p) -> m.put(p.team(), p),(m1, m2) -> m1.putAll(m2));

Red =>Player[name=Mila, sex=F, score=0, team=Red]
Blue =>Player[name=Madison, sex=F, score=9, team=Blue]
Green =>Player[name=Isabella, sex=F, score=6, team=Green]

Более распространенный способ работы с объектами, создающими неуникальные ключи, — это собрать все значения с одним и тем же ключом в список. Этого можно добиться с помощью коллектора groupingBy(keyMapper):

Map<String, List<Player>> playersByTeam = layers.stream().collect(groupingBy(Player::team));

Первый ключ в произведенной карте:

Red =>[Player[name=Addison, sex=F, score=7, team=Red], Player[name=Lucy, sex=F, score=8, team=Red], Player[name=Mila, sex=F, score=0, team=Red]]
...

Аналогичный коллектор partitioningBy(predicate) позволяет разделить список на два списка, связанных с ключами true и false.

Map<Boolean, List<Player>> bestPlayers = players.stream().collect(partitioningBy(p -> p.score() > 1000));

false =>[Player[name=Lily, sex=F, score=2, team=Blue], Player[name=Addison, sex=F, score=7, team=Red], Player[name=Logan, sex=M, score=4, team=Green], ...]
true =>[]

Почти эквивалентное многозначное Map может быть получено с помощью groupingBy(keyMapper) с тем же предикатом:

Map<Boolean, List<Player>> bestPlayers2 = players.stream().collect(groupingBy(p -> p.score() > 1000));

false =>[Player[name=Lily, sex=F, score=2, team=Blue], Player[name=Addison, sex=F, score=7, team=Red], Player[name=Logan, sex=M, score=4, team=Green], ...]

Единственная разница между partitioningBy(predicate) и groupingBy(keyMapper) заключается в том, что карты, возвращаемые partitioningBy(predicate), содержат Lists для ключей false и true, даже если predicate никогда не возвращает true или false.

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

Вложенные коллекторы в следующем примере имеют понятные имена.

Map<String, Integer> totalScoreByTeam = players.stream().collect(groupingBy(Player::team, summingInt(Player::score)));

Red   =>15
Blue  =>32
Green =>25

Map<String, Double> avgScoreByTeam = players.stream().collect(groupingBy(Player::team, averagingInt(Player::score)));

Red   =>5.0
Blue  =>5.333333333333333
Green =>4.166666666666667

Map<String, Long> teamMemberCount = players.stream().collect(groupingBy(Player::team, counting()));

Red   =>3
Blue  =>6
Green =>6

Сгруппированные значения не нужно уменьшать, их можно дополнительно сгруппировать в дополнительную карту с помощью другой функции keyMapper:

Map<String, Map<String, Long>> sexCountInTeams = players.stream().collect(groupingBy(Player::team, groupingBy(Player::sex, counting())));

Red
 F =>3
Blue
 F =>4
 M =>2
Green
 F =>3
 M =>3

Вложенный сборщик может быть обернут вспомогательным сборщиком, mapping(valueMapper, collector) преобразующим значения перед передачей их вложенному сборщику:

Map<String, List<String>> namesBySex = players.stream().collect(groupingBy(Player::sex, mapping(Player::name, toList())));

F =>[Lily, Addison, Emilia, Olivia, Lucy, Mila, Eleanor, Evelyn, Isabella, Madison]
M =>[Logan, Elijah, Charles, Asher, Wyatt]

Map<String, Set<Integer>> teamUniqueScores = players.stream().collect( groupingBy(Player::team, mapping(Player::score, toSet())));

Red =>[0, 7, 8]
Blue =>[2, 3, 5, 6, 7, 9]
Green =>[2, 3, 4, 5, 6]

Map<String, Set<Integer>> sortedUniqueScoresBySex = players.stream().collect(groupingBy(Player::sex, mapping(Player::score, toCollection(TreeSet::new))));

F =>[0, 2, 3, 5, 6, 7, 8, 9]
M =>[2, 3, 4, 5, 6]

Map<String, String> teamMemberNames = players.stream().collect(groupingBy(Player::team,mapping(Player::name, joining(", "))));

Red =>Addison, Lucy, Mila
Blue =>Lily, Emilia, Elijah, Evelyn, Wyatt, Madison
Green =>Logan, Olivia, Eleanor, Charles, Asher, Isabella

Последний результат можно также получить с помощью toMap(keyMapper, valueMapper, mergeFunction):

Map<String, String> teamMemberNames2 = players.stream().collect(
toMap(Player::team, Player::name, (t, v) -> t + ", "+ v));

Red =>Addison, Lucy, Mila
Blue =>Lily, Emilia, Elijah, Evelyn, Wyatt, Madison
Green =>Logan, Olivia, Eleanor, Charles, Asher, Isabella

Последние два фрагмента кода создают один и тот же Map, в котором имя команды связано с конкатенированными именами членов команды. Такие карты неприемлемы, потому что имена не отсортированы. Как их сортировать?

Довольно некрасивым вариантом было бы использование коллектора collectingAndThen(collector, finisher). Он просто передает значения вложенному сборщику и, после обработки всех значений, передает результат, возвращенный вложенным сборщиком, в функцию финишера:

Map<String, String> teamMemberNames4 = players.stream().collect(groupingBy(Player::team,mapping(Player::name, collectingAndThen(toList(), l -> l.stream().sorted().collect(joining(", "))))));

Red =>Addison, Lucy, Mila
Blue =>Elijah, Emilia, Evelyn, Lily, Madison, Wyatt
Green =>Asher, Charles, Eleanor, Isabella, Logan, Olivia

Менее неудобный способ — отсортировать значения перед группировкой:

Map<String, String> teamMemberNames5 = players.stream().sorted(Comparator.comparing(Player::name)).collect(groupingBy(Player::team,mapping(Player::name, joining(", "))));

Red =>Addison, Lucy, Mila
Blue =>Elijah, Emilia, Evelyn, Lily, Madison, Wyatt
Green =>Asher, Charles, Eleanor, Isabella, Logan, Olivia

Еще один довольно распространенный пример с вложенными коллекторами groupingBy():

Map<String,Map<String,List<String>>> namesByTeamAndSex = players.stream().collect(groupingBy(Player::team, groupingBy(Player::sex, mapping(Player::name, toList()))));

Red
 F =>[Addison, Lucy, Mila]
Blue
 F =>[Lily, Emilia, Evelyn, Madison]
 M =>[Elijah, Wyatt]
Green
 F =>[Olivia, Eleanor, Isabella]
 M =>[Logan, Charles, Asher]

Одним из источников боли в потоковом API обычно является бесполезный или нежелательный тип Optional. Некоторые встроенные сборщики возвращают Optional, что, за исключением исключительных случаев, излишне удлиняет код:

Map<String, Optional<Player>> highestScoreByTeam = players.stream().collect(groupingBy(Player::team, maxBy(Comparator.comparing(Player::score))));

Теперь, чтобы извлечь игроков из Optionals, размер кода должен быть удвоен:

Map<String, Player> highestScoreByTeam2 = players.stream().collect(groupingBy(Player::team,maxBy(Comparator.comparing(Player::score)))).entrySet().stream().collect(toMap(e -> e.getKey(), e -> e.getValue().get()));

Red =>Player[name=Lucy, sex=F, score=8, team=Red]
Blue =>Player[name=Madison, sex=F, score=9, team=Blue]
Green =>Player[name=Isabella, sex=F, score=6, team=Green]

Благо Optional производят всего три не обязательных сборщика: reducing(binaryOperator), minBy(comparator) и maxBy(comparator).

Тот же результат с меньшим количеством кода можно получить, если использовать toMap(keyMapper, valueMapper, mergeFunction). mergeFunction объединяет значения одних и тех же ключей.

Map<String, Player> highestScoreInTeam3 = players.stream().collect(toMap(Player::team, p -> p,(p1, p2) -> p1.score() > p2.score()? p1 : p2));

Но для одновременного подсчета лучших и худших игроков самое короткое решение — с minBy(comparator) и maxBy(comparator):

record MinMax(Player min, Player max) {};

Map<String, MinMax> highestScoreInTeam4 = players.stream().collect(groupingBy(Player::team, teeing( minBy(Comparator.comparing(Player::score)), maxBy(Comparator.comparing(Player::score)),(a, b) -> new MinMax(a.get(), b.get()))));

Red =>MinMax[min=Player[name=Mila, sex=F, score=0, team=Red], max=Player[name=Lucy, sex=F, score=8, team=Red]]
Blue =>MinMax[min=Player[name=Lily, sex=F, score=2, team=Blue], max=Player[name=Madison, sex=F, score=9, team=Blue]]
Green =>MinMax[min=Player[name=Charles, sex=M, score=2, team=Green], max=Player[name=Isabella, sex=F, score=6, team=Green]]

teeing(collector1, collector2, mergeFunction) это обертка для двух сборщиков. Он передает элементы им обоим и в конце передает их результаты в mergeFunction.

reducing(mergeFunction) очень полезен для разложения на сущностные соединения, загруженные из базы данных. Но трудно представить содержательные примеры с reducing(mergeFunction), которые дают результаты, отличные от приведенных выше, или короче, чем приведенный выше код.

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

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

Код можно скачать с https://github.com/marianc000/ListToMap.