Stream. Виды операций
В данном уроке мы познакомимся с классификацией операций у Stream’ов. Верхнеуровнево она совпадает с таковой у Optional, но имеет свои нюансы, от понимания которых зависит эффективность использования Stream API.
О ленивых Stream’ах и порядке обработки
При работе с Stream API крайне важно понимать одно из ключевых отличий в сравнении с Optional: Stream не будет обработан до вызова терминальной операции.
В качестве примера сравним вызов одного и того же лямбда-выражения для Optional и Stream с идентичными данными:
Function<Integer, Integer> function = o -> {
System.out.println(o);
return o;
};
Optional.of(1)
.map(function); //выведет '1' в консоль
Stream.of(1)
.map(function); //ничего не произойдет - нет терминальной операции
Как видите, Stream игнорируются, если терминальной операции нет.
Второе важное различие заключается в том, что Stream нельзя переиспользовать – для повторной обработки исходного набора данных придется создавать новый Stream. В теме о сплитераторах мы затрагивали причины такого поведения.
Напомню: сплитератор, который является основной Stream, может обработать каждый элемент источника лишь единожды.
Опять же, рассмотрим на примере:
Optional<Integer> optional = Optional.of(1); optional.ifPresent(System.out::println); //1 optional.ifPresent(System.out::println); //1 Stream<Integer> stream = Stream.of(1); stream.forEach(System.out::println); //1 stream.forEach(System.out::println); //IllegalStateException - stream уже закрыт
Это не очень критично на простых операциях. Но в ситуациях, когда требуется обработать два разных процесса на данных, полученных в результате множества преобразований исходной коллекции (например) – накладывает свои ограничения. Стандартным решением, чтобы не совершать все преобразования снова, будет создание новой коллекции из уже преобразованных данных и создание двух новым стримов на ее основе, но это все еще создание отдельной коллекции, а значит, лишние расходы на память и усложнение кода.
Иногда подобные ситуации можно решить не возвращаясь в императивную парадигму, но это выходит за пределы данной статьи.
Возвращаясь к особенностям Stream API, стоит также понимать порядок обработки элементов в Stream.
Представим задачу вывести каждый из элементов списка в консоль трижды.
Сравним идентичные реализации этой задачи через цикл, и с использованием Stream API.
Императивная реализация:
for (Integer i : List.of(1, 2, 3)) {
System.out.print(i);
System.out.print(i);
System.out.print(i);
}
И реализация через Stream API:
List.of(1, 2, 3) .stream() .peek(System.out::print) .peek(System.out::print) .forEach(System.out::print);
*peek() – выполняет лямбду для каждого элемента стрима. Как forEach(), только промежуточный.
Вывод в консоль в обоих случаях:
11222333
Таким образом, несложно догадаться, что при обработке элементов через Stream, каждый элемент проходит всю цепочку прежде, чем второй элемент начнет прохождение по той же цепочке. Это не всегда верно де-факто, но отражает порядок обработки элементов в целом. И такой подход имеет свои бенефиты, о которых мы поговорим ниже.
Классификация промежуточных операций
Оговорюсь сразу, что данный урок не ставит целью охватить весь перечень операций, доступных в рамках Stream API. Его задача – рассказать о видах операций и привести примеры. Полный набор операций, доступных с использованием Stream мы произведем в ближайших уроках.
Напомню, промежуточными операциями для Stream’ов считаются те, результатом которых является новый (или нет) объект стрима.
Итак, промежуточные операции мы можем разделить на три категории:
1. Без сохранения состояния (stateless). Сюда относятся хорошо известные нам map(), flatMap(), filter() и т.д. Они характерны тем, что их результат для конкретного элемента никак не зависит от других элементов в стриме;
2. С сохранением состояния (stateful). Сюда относятся операции, которым необходимо знать о других элементах стрима. Например, sorted() и distinct().
Такие операции подразумевают изменение порядка и/или состава элементов. Таким образом, им требуется та или иная информация о элементах, обрабатываемых стримом.
Эти операции стоит использовать осторожно – они могут приводить к избыточной обработке элементов и, как следствию, ухудшению производительности. Иногда такая жертва обоснована, чаще – нет.
Ниже рассмотрим несколько примеров (здесь и в дальнейшем – предлагаю вместо указания типа использовать var, если это не вредит читаемости кода):
Найдем первое имя из 4 букв в списке персон:
var foundName = persons.stream() .map(Person::getName) .filter(n -> n.length() == 4) .findFirst();
При такой обработке, map() и filter() будет вызван 1 раз, если подходящее имя будет 1-м обрабатываемым элементом, N раз – если такое имя будет N-м.
А теперь напишем реализацию, которая сортирует имена по длине, прежде чем искать нужное:
var foundName = persons.stream() .map(Person::getName) .sorted(Comparator.comparingInt(String::length)) .filter(n -> n.length() == 4) .findFirst();
В такой реализации filter() будет вызван N раз, где N – порядковый номер первого подходящего имени в отсортированном массиве из всех имен.
А map() вызовется вообще для всех персон. Таким образом, даже если в отсортированном списке из 1000 имен подходящее окажется первым, лямбда из map() все равно будет вызвана 1000 раз.
3. Операции короткого замыкания (short-circuiting operation). В контексте промежуточных операций, такие операции всегда stateful. Для промежуточных операций short-circuiting характерен следующим: эти операции могут из бесконечного стрима сделать конечный. Сюда относятся операции limit(), takeWhile().
!NB: в данных уроках мы стараемся минимизировать информацию о «параллельных» стримах. Они накладывают свои особенности в ряде случаев. Но эти особенности не выглядят критичными на данном этапе, поэтому знакомство с ними отложим до раздела «Многопоточность».
Классификация терминальных операций
Терминальные операции можно разделить на 4 категории:
1. Операции короткого замыкания. В отличии от промежуточных операций такого рода, характерны тем, что прерывают обработку стрима, не обрабатывая все элементы. Примеры таких операций – findFirst(), anyMatch() и др.
Данные операции, проводя аналогии, схожи с вызовом break или return внутри цикла – они вызываются, когда цель обработки (стримом или циклом) достигнута и дальнейшая обработка не имеет смысла.
Соответственно, они могут сильно оптимизировать алгоритм, избегая «холостые» итерации. Такой подход возможен благодаря обработке каждого элемента цепочкой Stream'а до перехода к следующему элементу (о чем бы говорили в начале статьи);
2. Операции фан-клуба «Джек Потрошитель».
Сюда отнесем терминальные операции, возвращающие сплитератор для элементов стрима или эквивалентный (имеющий тот же источник данных) итератор. Разработчики Java не дают этой группе собственного названия, но предоставляют методы spliterator() и iterator() на случай, если вам не хватило функциональности Stream API для завершения решения вашей задачи.
3. Операции редукции. К этой группе относятся все терминальные операции, возвращающие значение (не void) на основании всех элементов стрима: max(), collect(), count(), reduce()...
Эти операции могут давать совершенно разный (по типу возвращаемого значения) результат, но всех их объединяет одно: для получения этого результата требуются все элементы Stream’а;
4. Операции с побочным эффектом (side-effect). Терминальные операции, которые не возвращают значения, но выполняют какую-то логику. Сюда можно отнести forEach(), forEachOrdered().
5. Операция close(). Выполняет все лямбда-выражения, переданные в данный стрим через метод onClose(). Можно рассматривать как одну из операций с побочным эффектом. Ее особенность в том, что это единственная терминальная операция, которую можно вызвать после вызова другой терминальной операции.
К слову, это тот самый close(), известный нам по I/O Streams – стримы (в контексте Stream API) тоже имплементируют AutoCloseable.
Немного о side-effect операциях
Такая категория есть и у промежуточных операций. Но в случае с промежуточными она входит в категорию stateless и упоминать о ней как об отдельной группе вряд ли имеет смысл.
Промежуточными операциями с побочным эффектом можно считать peek() и onClose(). Эти операции бывают крайне полезны, хоть и имеют свои недостатки.
Основной – отсутствие гарантии, что они будут вызваны. В целом, это характерно для любых промежуточных операций, но практика использования говорит о том, что болезненно бывает именно на операциях с side-effect.
onClose()
В данном случае все просто. Это операция, позволяющая передать лямбда-выражение (Runnable), которое будет выполнено при вызове close(). Нюанс в том, что close() не обязателен для вызова в Stream API. Соответственно, если close() не вызван, лямбда-выражения, переданные через onClose(), не будут выполнены.
Второй интересный нюанс, связанный с close(): если он является единственной терминальной операцией данного Stream’а, никакие операции, кроме onClose() не будут обработаны.
Благо, close() используется крайне редко, поэтому можно не забивать себе голову:)
peek()
Рассмотрим пример:
var i = List.of(1, 2, 3) .stream() .peek(System.out::println) .count();
count() – терминальная операция, возвращающая количество элементов в стриме.
Полагаю, очевидно, что в данном случае количество элементов известно без операции peek(). Собственно, оно известно даже без создания Stream’а. И в таких ситуациях вполне вероятно, что действия, не влияющие на результат, будут опущены – Java допускает подобные «оптимизации».
Справедливости ради, операция map() тоже была бы проигнорирована в данном примере – она не может изменить число элементов в стриме.
Но в отличии от map(), peek() используется для операций, не влияющих на состав элементов стрима: логирование, изменение внешнего состояния (это не очень хорошо, но иногда требуется) или изменение значения полей у элементов стрима (тоже сомнительная практика, но тоже иногда применяется).
И если игнорирование map(), при верном его использовании (для получения элемента нового типа), никак не отразится на логике программы, то игнорирование peek() может привести к некорректному поведению. Поэтому не рекомендуется вкладывать в данную операцию критически важную логику и в целом использовать ее, если нет уверенности, что она будет вызвана.
Сладкой пилюлей можно считать то, что подобные ситуации с игнорированием операций – не слишком частый сценарий. Даже в примере выше peek() отработал бы, добавь мы в цепочку filter() или другую операцию, которая могла бы изменить количество элементов в стриме.
Подводя итог пункта о промежуточных side-effect операциях: их корректное использование вряд ли будет грозить проблемами. Но помните, что нюансы есть и они могут стать причиной неожиданных багов в вашем коде.
С теорией на сегодня все!
Глава теоретическая, практики по ней не предполагается. Желающим рекомендую набивать руку на задачах по Stream API, благо, в открытом доступе их множество и найти их легко.

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