Впечатляюще удобный 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)
, содержат List
s для ключей 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))));
Теперь, чтобы извлечь игроков из Optional
s, размер кода должен быть удвоен:
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.