Stream API. collect(), Collector, Collectors. Теория. Часть II

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/

·       http://www.java2s.com/Tutorials/Java/java.util.stream/Collectors/Collectors.reducing_BinaryOperator_T_op_.htm

 

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

 

Дорогу осилит идущий!

Report Page