Понять Java Stream API

Java 8 вышла уже 7 лет назад, привнеся в жизнь разработчиков множество новых инструментов для решения старых задач. Конечно не все приняли такие изменения, потому что они в первую очередь заставляют разработчика менять парадигму мышления. И старый добрый OOP тогда начал становиться не таким уж и однозначным.

Тем не менее, новые инструменты, позволили писать более изящные решения для типовых задач: работа с потенциальными null-значениями, более удобный API работы с датами и временными зонами, лямбда, метод-референсы и, пожалуй, самое главное - Java Streams.

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

Stream vs. Collections

Для начала, нужно провести четкую линию между коллекциями и Stream Java. В отличии от коллекций, у Stream есть набор состояний, в которых он может находиться. Например он может быть “исполненным”, чо означает что попытки повторной его обработки приведут к ошибке времени исполнения.

С другой стороны Stream, как и коллекции подразумевает некоторое количество однородных элементов, однако здесь их однородность соблюдается только в рамках одного метода, на выходе могут быть совсем другие элементы.

Так же, в отличии от коллекции, нельзя сказать что Stream - это хранилище элементов, это больше набор “процедур”, которые применяются к хранилищу и результатом которого является другая коллекция или значение, причем исходная коллекция модифицироваться не будет.

У Stream есть своя структура:

  • Создание Stream
  • Intermediate операторы
  • Deterministic оператор

Создание Stream

Создать Stream можно вызвав метод .stream() у любой коллекции, так как этот метод наследуется от java.util.Collection

Существуют также способы, посредством которых можно создать Stream для примитивнх типов: IntStream, LongStream, DoubleStream. Эти конструкции позволяют работать с элементами без их Boxing’а, что позволяет избежать такой ресурсозатратной процедуры.

Примитивные Stream могут быть созданы преобразованием Объектного Stream посредством оператора преобразования (см. далее), либо с помощью генераторов - статических методов, позволяющих создавать Stream не из существующей коллекции, а посредством генерации последовательности, например:

public static IntStream range(int startInclusive, int endExclusive)

- создающий набор данных от startInclusive до endExclusive, или

public static IntStream generate(IntSupplier s)

- бесконечный поток значений, выдаваемых Supplier’ом, переданным в параметр.

Существуют также методы, создающие Stream в некоторых утильных классах. Например класс Random, в восьмой Java обрел методы double(), ints() и longs(), значение которых понятно из их названий. А класс BufferedReader - метод lines() - превращающий оборачиваемый им поток в Stream, каждый элемент которого будет представлен считываемой строкой, причем чтение потока будет ленивым - производиться только в момент вызова Детерминирующего оператора (см. далее)

Чтобы создать Stream из массива, его нужно обернуть в Arrays.stream(массив) или Stream.of(массив). К слову - у второго способа есть перегруженный “двойник” которому можно передать vararg - массив.

Intermediate операторы

После того, как Stream создан, к нему можно “применить” один или последовательность из нескольких последовательных операторов. Здесь я приведу только наиболее популярные операторы, которые можно использовать в самых простых задачах, даже на самом раннем этапе использования Stream.

.map()

Оператор .map() используется для преобразования элементов Stream. То есть в него входят одни элементы, а выходят - другие. При этом оператор применяется к элементам отдельно, элемент Stream попавший в метод, “не знает” о других элементах.

Сигнатура оператора выглядит так:

Stream map(Function mapper);

Здесь видно, что метод принимает функцию mapper, которая выглядит так:

R apply(T t);

То есть принимает на вход объект T а возвращает R

На рисунке - Преобразование Stream в Integer Yerlan Akzhanov
На рисунке - Преобразование Stream в Integer Yerlan Akzhanov

Основной момент в том, что если не вход в метод .map(...) приходит Stream одного типа, но следующим операторам будет передан Stream другого типа.

Как я и говорил ранее, существуют методы, преобразующие объектный Stream в примит ивный: mapToInt(...), mapToLong(...), mapToDouble(...). Для обратного преобразования, используется общий оператор - .boxed().

.filter()

Оператор .filter(...) позволяет определить какие из элементов будет переданы следующему оператору.

Сигнатура метода следующая:

Stream filter(Predicate predicate);

Метод принимает Predicate - функциональный интерфейс который выглядит следующим образом:

boolean test(T t);

То есть внутри оператора filter, для каждого элемента вызывается метод test(...) и если он возвращает true - элемент проходит дальше, и если false - то нет

Элементы A и C прошли "сквозь" оператор .filter, а B - нет Yerlan Akzhanov
Элементы A и C прошли "сквозь" оператор .filter, а B - нет Yerlan Akzhanov

.flatMap()

Данный оператор преобразует каждый элемент в Stream, объединяет Stream всех элементов в один и передает его следующему оператору.

Сигнатура оператора следующая:

Stream flatMap(Function> mapper);

То есть к каждому элементу применяется функция, преобразующая его в Stream другого типа

Yerlan Akzhanov
Yerlan Akzhanov

.sorted()

Оператор sorted позволяет отсортировать элементы Stream так, что следующему оператору они будут переданы в отсортированном порядке.

Существует два метода: .sorted() и sorted(Comparator comparator).

Deterministic операторы

Важно особенностью Stream является то, что все операторы, приведенные выше, являются не только промежуточными, но и “ленивыми”, то есть они не будут применяться к элементам Stream пока у него не будет вызван Детерминирующий оператор.

Детерминирующий оператор - такой метод, который:

  • Запускает в исполнение всю цепочку промежуточных операторов,
  • На выходе возвращает структуру данных, Optional или примитивное значение.

Детерминирующий оператор, у Stream - конструкции можно вызвать только единожды, что означает, что повторный вызов любого детерминирующего оператора приведет к ошибке времени исполнения - IllegalStateException - stream has already been operated upon or closed.

Stream предлагает огромное множество детерминирующих операторов, с помощью которых, например можно собрать элементы в коллекцию или Map, можно найти среднее значение или просто извлечь первый попавшийся элемент и т.д. Здесь я приведу только те, которые использовал сам в реальных проектах.

.findAny() или .findFirst()

Этот оператор возвращает Optional - обертку, над одним элементом, которая предоставляет множество инструментов работы с ним, без опасения получить NullPointerException. Optional - это скорее даже концепт работы с данными, который точно заслуживает отдельной статьи.

Для нас, в рамках этой статьи достаточно знать что первый метод - .findAny() вернет любой элемент из Stream, а .findFirst() - тот который должен быть первым (разница ощущается в Stream’ах выполняемых параллельно)

.forEach()

Данный метод имеет следующую сигнатуру:

void forEach(Consumer action);

То есть каждый элемент Stream он передаст параметром в Consumer и исполнит его. Сам оператор ничего не возвращает.

.sum() и .average()

Данные deterministic операторы применимы только к примитивным Stream.

.count()

Возвращает количество элементов Stream

.min() и max()

Для объектных Stream эти операторы принимают Comparator. Возвращают соответственно минимальное и максимальное значение элементов.

.collect()

Это самый богатый deterministic оператор в Stream. Он имеет два перегруженных варианта:

<R> R collect(Supplier<R> supplier, BiConsumer<R, ? super T> accumulator, BiConsumer<R, R> combiner);

Или

<R, A> R collect(Collector<? super T, A, R> collector);

Особенностью этого оператора является то, что к нему прилагается огромная библиотека статических Collector’ов, которые находятся в классе java.util.stream.Collectors, каждый из которых - предмет отдельного, увлекательного изучения. Здесь я также приведу самые популярные:

Collectors.toCollection()

Данный коллектор собирает все элементы в коллекцию, параметром он принимает Supplier, который должен вернуть реализацию интерфейса Collection. Например:

Collectors.toCollection(() -> new LinkedList<>())

Collectors.toList()

Это частный случай toCollection() - оператора, который все элементы соберет в ArrayList<>

Collectors.groupingBy()

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

Например, если нам надо сгруппировать всех сотрудников по их имени:

.collect(Collectors.groupingBy(employee -> employee.getName()));

Заключение

Безусловно 8 версия Java изменила многое, причем не только логику написания кода, но даже и то как мы пишем код - из горизонтального он стал вертикальным:

var toPay = list.stream() .map(Department::getEmployees) .flatMap(List::stream) .filter(employee -> employee.getAge() < RETIREMENT_AGE) .map(RetirementRequest::of) .mapToDouble(RetirementRequest::getSalary) .sum();

И да, такие перемены нравятся не всем, тем более Java, как один из достаточно старых языков программирования достаточно долго поощряла стагнацию.

Тем не менее, те, кто уже испытал при написании кода все преимущества легкости работы с совершенно новой Java заново влюбились в ее новую форму, поэтому не стоит оставаться в стороне, лучше закрыть глаза и совершить прыжок веры, тем более что разочарования это не принесет!

22
Начать дискуссию