Stream API. collect(), Collector, Collectors. Теория. Часть II
Дорогу осилит идущийПредыдущая часть
https://telegra.ph/Stream-API-collect-Collector-Collectors-03-17
Подробнее о downstream
Общая концепция, полагаю, уже понятна. Downstream – это коллектор, который вызываются внутри других коллекторов и применяются для подмножества данных. В случае с groupingBy() – подмножество определяется классификатором. В других коллекторах (рассматриваемых ниже), классификатор может заменяться другим параметром, но суть остается прежней – даунстрим будет применен к каждому подмножеству по отдельности.
Важно понимать, что даунстримом может стать любой коллектор. Есть популярные варианты на эту роль – как toList(). Есть узкоориентированные, которые за пределами использования в качестве downstream не слишком нужны (counting() и ряд других). Такие мы будем помечать в процессе разбора.
Но вместе с тем, ничто не мешает использовать в качестве даунстрима другой коллектор с даунстримом. Пример ниже:
Stream<User> userStream = …;
Map<String, Map<Integer, Long>> map = userStream.collect(
Collectors.groupingBy(
User::getName,
Collectors.groupingBy(
User::getAge,
Collectors.counting())));
В данном случае, результатом будет структура данных, из которой можно получить число юзеров с определенным именем (первый ключ) и возрастом (ключ вложенной мапы).
Даунстрим – очень дает мощный и очень гибкий инструментарий, расширяя границы применения Collector.
Но вместе с тем стоит понимать, что как и любой гибкий инструмент, он дает меньшую защиту от ошибок и поддерживать цепочку вложенных Collector’ов сложнее, чем цепочку последовательных вызовов промежуточных операций. Поэтому до тех пор, пока цена за использование простого (вроде toList(), toMap() и подобных) Collector’а – лишняя промежуточная операция, лучше использовать дополнительную промежуточную операцию.
Как правило, даунстрим стоит использовать в ситуациях, когда решение без него предполагает использование внешних коллекций и другие варианты с сохранением промежуточного состояния во внешних (по отношению к стриму) объектах. Иными словами, вы не можете обойтись без него, оставаясь в функциональной парадигме. В таких случаях выбор в пользу даунстрима оправдан. Но только если ваш проект претендует на функциональный стиль, а вы имеете достаточно сильную команду, которую ваш мастер-класс не ввергнет в шок.
Помните, что проблема гибких решений еще и в том, что их сложнее поддерживать, но легче сломать. Я никоим образом не хочу отпугнуть или заставить отказаться от даунстримов, но рекомендую использовать их с умом и избегать там, где можно дешево решить задачу без них. В особенности если даунстрим – результат желания не писать лишний map()/flatMap()/filter().
Ниже мы рассмотрим ряд коллекторов, которые предполагается использовать именно в качестве downstream. Далее вернемся к остальным Collector’ам, в т.ч. принимающих downstream параметром.
reducing()
Как reduce() – наиболее гибкая операция из терминальных операций редукции, так и reducing() – наиболее гибкий из коллекторов-даунстримов. По сути, является аналогом reduce() для подмножества.
Также, как и reduce(), имеет три перегруженных версии. По смысловой нагрузке они идентичны, как и по параметрам (за незначительным исключением). По сути, любой коллектор-даунстрим можно описать, используя reducing(). Как, впрочем, и реализовать самопальный collect(), используя reduce().
Ссылки на примеры использования (описание на английском, но примеры понятны и без пояснений):
· https://stackabuse.com/guide-to-java-8-collectors-reducing/
counting()
Его мы уже использовали в примерах выше. Считает число элементов в подмножестве. Если применить вне downstream – будет эквивалентен Stream.count():
Long amount = Stream.of("1", "2", "3")
.collect(Collectors.counting());
равносильно
Long amount = Stream.of("1", "2", "3")
.count();
mapping()
Немного специфичный коллектор (как и несколько следующих) – вне downstream его использовать бесполезно, но он сам принимает downstream.
Полезен в ситуациях, когда нужно получить набор сгруппированных значений одного поля по другому.
Рассмотрим на примере. Требуется получить список возрастов юзеров по имени:
Stream<User> userStream = …;
Map<String, List<Integer>> map = userStream.collect(
Collectors.groupingBy(
User::getName,
Collectors.mapping(
User::getAge,
Collectors.toList())));
В данном случае, ключ мапы – имя юзера, значение – список возрастов пользователей с именем как в ключе.
Заменив Collectors.toList() на другой коллектор, мы могли бы, например, посчитать средний возраст пользователя с заданным именем. Или произвести другие вычисления.
В целом, ситуации, в которых нужен mapping() достаточно распространены. И решения без использования mapping() зачастую более громоздкие и сложночитаемые, нежели с ним.
Если рассматривать mapping() не как downstream, он равноценен использованию map() + collect(), с коллектором, переданным downstream’ом в сам mapping():
List<Integer> ages = userStream.collect( Collectors.mapping(User::getAge, Collectors.toList()));
равносильно
List<Integer> ages = userStream.map(User::getAge) .collect(Collectors.toList());
flatMapping()
Если mapping() является объединением map() + collect() для подмножества, то flatMapping() – объединение flatMap() + collect(). В качестве самостоятельного коллектора вырождается именно в такую связку.
С точки зрения применения актуален для ситуаций (не самых частых в практике), когда в качестве подмножества обрабатывается новый стрим.
Общий случай применения – из одного элемента подмножества нужно получить несколько, при этом нужна информация из оригинального объекта, что делает невозможным дешевую реализацию с вызовом flatMap() до терминальной операции.
Пример. Найти самый длинный тег для пользователей каждого возраста:
Stream<User> userStream = …
Map<Integer, Optional<String>> longestTagByAge = userStream.collect(
Collectors.groupingBy(
User::getAge,
Collectors.flatMapping(
user -> user.getTags().stream(),
Collectors.maxBy(Comparator.comparing(String::length)))));
filtering()
Полагаю, вы уже догадались. filter() + collect(). Все также имеет смысл в качестве downstream, иначе стоит использовать как самостоятельные операции.
Пример. Получение списка пользователей, старше 30 лет, по каждому имени:
Stream<User> userStream = …;
Map<String, List<User>> usersOlder30ByName = userStream.collect(
Collectors.groupingBy(
User::getName,
Collectors.filtering(
user -> user.getAge() > 30,
Collectors.toList())));
minBy() и maxBy()
Аналоги терминальных операций min() и max() от мира downstream. Принимают параметром Comparator. Пример использования можно увидеть выше.
summarizingInt(), summarizingLong(), summarizingDouble()
Коллекторы, реализующие для подмножества связку вроде mapToInt()/mapToLong()/mapToDouble() + summaryStatistics().
За пределами downstream имеет право на жизнь, если лямбда-маппер достаточно лаконичная.
summingInt(), summingLong(), summingDouble()
mapToInt()/mapToLong()/mapToDouble() + sum().
Опять же, не вижу проблемы использовать за пределами downstream, если лямбда в параметре простая.
averagingInt(), averagingLong(), averagingDouble()
НЕ РАВНОСИЛЬНО mapToInt()/mapToLong()/mapToDouble() + average().
Достаточно похоже, но с отличием: average() у стримов примитивов возвращают OptionalDouble, а averagingInt() и аналоги возвращают Double.
Таким образом, для пустого стрима средним значением в averagingInt() будет считаться 0 (нуль), против OptionalDouble.empty() в оригинальном IntStream.average().
Оба варианта имеют право на жизнь. Рекомендую отталкиваться от конкретной задачи. Но конкретно в данном случае лучше комментировать нюанс с нулем для пустого стрима – коллектор и близкая ему терминальная операция не самые популярные. Такие нюансы мало кто знает, еще меньше тех, кто их постоянно держит в голове.
На этом подпункт downstream можно считать завершенным и у нас осталось буквально несколько коллекторов, которые мы еще не рассмотрели.
partitioningBy()
Коллектор, который разделяет стрим на мапу из двух элементов: первый – с ключом true, второй – с ключом false.
Имеет две реализации. Первая – с одним параметром типа Predicate (как у filter()). Те элементы, которые выполняют условие предиката – становятся элементами списка, доступного по ключу true, остальные – элементами списка, дуступного по ключу false.
Пример. Разделим элементы стрима по признаку четности:
Map<Boolean, List<Integer>> map = Stream.of(1, 2, 3, 4)
.collect(Collectors.partitioningBy(i -> i % 2 == 0));
//[true={2, 4}, false={1, 3}]
Вторая реализация partitioningBy() добавляет параметр-downstream. В первой версии неявно вызывается Collectors.toList().
Пример. Посчитаем сумму четных и сумму нечетных элементов стрима:
Map<Boolean, Integer> map = Stream.of(1, 2, 3, 4)
.collect(Collectors.partitioningBy(
i -> i % 2 == 0,
Collectors.summingInt(i -> i)));
//[true=6, false=4]
В целом, является достаточно узконаправленным коллектором, но имеет право на жизнь.
teeing()
Вероятно, самый своеобразный коллектор, позволяющий получить два разных результата из одного стрима и объединить их в рамках одного результирующего объекта. Не забывайте, может применяться и как downstream, т.е. не ко всем элементам стрима одновременно.
В целом, далеко не самый популярный коллектор. Еще меньше ситуаций, в которых он бы применялся без Map в качестве результирующего типа.
Но в качестве примера предлагаю рассмотреть экзотику. Создадим на базе стрима юзеров нового юзера – с самым длинным именем и самым большим возрастом:
Stream<User> userStream = …;
User superUser = userStream.collect(Collectors.teeing(
Collectors.mapping(
User::getAge,
Collectors.maxBy(Comparator.naturalOrder())),
Collectors.mapping(
User::getName,
Collectors.maxBy(Comparator.comparing(String::length))),
(ageOptional, nameOptional) -> {
var user = new User();
ageOptional.ifPresent(user::setAge);
nameOptional.ifPresent(user::setName);
return user;
}
));
collectingAndThen()
Интересный коллектор, который позволяет прикрутить к другому коллектору свой finisher.
Первым параметром принимает downstream – он тут очень условный, по факту, он является основным коллектором.
Вторым – лямбду типа Function, которая будет применена к результату downstream-collector’а.
Хороший пример – Обертывание результата stream’а в Optional. Например, мы хотим бросить эксепшн, если коллекция пуста, но не хотим переходить к императивному стилю. В данном случае «не хотим» == «имеем причины не делать». Иначе подобное решение выглядит спорным:
List<Integer> list = Stream.of(1, 2, 3, 4)
.collect(Collectors.collectingAndThen(
Collectors.toList(),
Optional::of))
.filter(Predicate.not(List::isEmpty)) // уже Optional.filter()
.orElseThrow();
В качестве заключения
На этом мы, по сути, завершаем изучение Stream API. Какие-то отдельные моменты мы будем разбирать позже – как, например, особенности параллельных стримов. И, безусловно, впереди еще бесконечное пространство для практического применения. Но теоретический базис можно считать завершенным.
В целом, на этом я планировал завершить и первое знакомство с функциональной парадигмой в Java, но в итоге решил задержаться «рядом» с ней еще на две статьи:
1. В этом уроке не будет практических задач, он и так получился очень объемным. Зато следующая статья будет полностью посвящена практическим заданиям на закрепления работы с коллекторами. Если промежуточные и большая часть терминальных операций и так не является чем-то слишком сложным, то на освоении возможностей collect() спотыкаются почти все. Постараемся минимизировать этот риск;
2. Методы коллекций, использующие лямбда-выражения. В ряде случаев нет смысла использовать стримы – по крайней мере, если хорошо владеешь коллекциями. Разберем методы, упрощающие жизнь и код, которые упустили на этапе знакомства с коллекциями.
В целом, хочу поздравить тех, кто более-менее освоил материал по Stream API. Этот раздел давался глубже, чем большинству джунов и, возможно, глубже, чем следовало бы для первого знакомства. Но я все еще надеюсь, что это было интересно и познавательно.
Ссылки на альтернативное изложение темы
Metanit в данном разделе дает более поверхностную сводку. Но, возможно, более легкую для восприятия новичками. Если чувствуете, что не вытянули материал урока – попробуйте следующие статьи:
https://metanit.com/java/tutorial/10.6.php
https://metanit.com/java/tutorial/10.7.php
С теорией на сегодня все! Практика и только практика в следующей статье

Если что-то непонятно или не получается – welcome в комменты к посту или в лс:)
Канал: https://t.me/+relA0-qlUYAxZjI6
Мой тг: https://t.me/ironicMotherfucker
Дорогу осилит идущий!