Обложка: Что нужно знать о Java Stream API

Что нужно знать о Java Stream API

Роман Иванов
Роман Иванов

Java-разработчик

Всем привет! В этой статье я хочу познакомить вас, на мой взгляд, с одним из самых значительных нововведений в Java со времен ее появления — это Java Stream API.

Что такое Java Stream API? Зачем? И какие дает преимущества?

Очень часто, когда мы пишем программу, нам нужно обрабатывать наши данные. Для обработки данных мы используем циклы либо рекурсивные функции для обхода наших данных.

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

Если проводить параллели с реальным миром, то давайте представим, что у нас есть некий завод по производству мебели под заказ.

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

Последний элемент в этой цепи — покупатель, который приходит на завод и делает заказ.

Без покупателя нет смысла запускать все производство, поэтому весь процесс стартует во время запуска производства.

В мире Java такой завод называется Stream API. Этот API представляет собой библиотеку, которая помогает в функциональном стиле кратко, но емко описывать, как обработать данные.

Как и в примере про завод, у каждого стрима должен быть источник объектов. Этим источником информации чаще всего бывает коллекция, так как именно в них мы и храним наши данные, но это не обязательно — может быть и какой-то генератор, который генерирует объекты по заданному правилу, примеры мы рассмотрим позже.

В Java Stream API также предусмотрены промежуточные операции. Они выполняют роль рабочих. Операции описывают процесс обработки объектов.

В конце каждого стрима должна быть терминальная операция, которая должна поглотить все обработанные данные.

В примере про завод мы видели, что заказчик становится триггером начала производства и является последним звеном в работе завода — он забирает всю продукцию.

Рассмотрим простейший стрим. Создадим класс бревно и поместим несколько бревен в коллекцию:

class Log{
   String type;
   int count;
   // конструктор и гетеры опущены
}
List<Log> logs = List.of(
       new Log("Сибирская сосна", 10),
       new Log("Дуб монгольский", 30),
       new Log("Берёза карликовая", 5));

У коллекций есть метод stream(), который возвратит стрим для данного набора данных.

Stream<Log> stream = logs.stream();

Получив ссылку на стрим, мы можем начать обрабатывать поток наших данных.

Отфильтруем бревна, количество которых меньше 7 и оставим только те, которые не являются дубом. Выглядеть это будет так:

Stream<Log> filteredStream = stream.filter(x -> x.getCount() > 7)
       .filter(x -> !"Дуб монгольский".equalsIgnoreCase(x.getType()));

Мы добавили фильтры и получили стрим, в котором описан процесс обработки всех наших бревен. Теперь мы должны добавить к нему терминальную операцию, чтобы запустить поток данных из коллекции:

filteredStream.forEach(x -> System.out.println(x.getType()));

В этом примере конечная операция принимает оставшиеся элементы после фильтрации и распечатывает их. Стоит особо упомянуть, что второй раз вызвать терминальную операцию не получится — стрим является «одноразовым» объектом. Это сделано авторами библиотеки для того, чтобы можно было корректно обрабатывать данные, которые имеют ограниченное время жизни. Например, если обрабатывать пакеты из интернета, то данные в стрим могут попасть только один раз, поэтому повторный вызов теряет всякий смысл.

Как упоминалось ранее, создать источник данных можно разными способами. Рассмотрим самые популярные.

Способы создания источника данных

В начале пройдемся по методам объявленным в интерфейсе Stream.

Stream.of(). Метод принимает массив объектов и создает на их основе стрим.

Пример:

Stream<String> stringStream = Stream.of("asd", "aaa", "bbb");

Для создания пустого стрима существует метод:

Stream.empty()

Патерн строитель поддерживается библиотекой, потому получив объект строителя Stream.builder() мы можем сконструировать с помощью него новый стрим.

Если у нас есть два стрима, мы можем объеденить их в один вызвав метод:

Stream.concat()

Пример:

Stream.concat(Stream.of("aaa", "bbb", "ccc"), Stream.of("111", "222", "333"))

В итоге мы получим стрим, в котором будет находится шесть элементов.

Стрим не обязательно должен поглощать какие-то данные, можно создать генератор, который будет поставлять в наш стрим с помощью метода generate()

Stream.generate(() -> Math.random()).forEach(System.out::println);

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

Аналогичную функциональность предоставляет класс Random. В нем уже есть методы которые создают стримы из случайных чисел.

new Random().ints()
new Random().doubles()
new Random().longs()

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

Поэтому создатели стримов добавили специальные типы стримов для примитивных типов:

LongStream()
DoubleStream()
IntStream()

Это такие же стримы, но как понятно из названия оперируют они только одним типом данных.

Также получить стрим из примитивов можно воспользовавшись методами утилитного класса Arrays.stream(). Этот перегруженный метод позволяет обернуть наш массив и получить из него стрим. Стоить отметить, что в нем есть метод static <T> Stream<T> stream(T[] array), то есть можно получить стрим из массива объектов, а не только примитивных типов.

Теперь мы перейдем к самому интересному — в интерфейсе Collection добавлен дефолтный метод, который возвращает нам стрим. То есть любая коллекция дает нам возможность превратить ее в стрим:

List.of("a", "b", "c").stream().forEach(System.out::println);

Просто вызвав метод у коллекции мы получили стрим. Это самый частый способ получить стрим из набора данных.

Познакомившись с основными методами создания теперь мы можем перейти к промежуточным операциям. Именно они позволят нам обработать наш поток данных.

Промежуточные операции

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

Но на нашем заводе мы делаем намного больше чем просто фильтруем бревна. Для того, чтобы дерево превратилось в мебель его нужно преобразовать.

Для этого пригодится самая популярная функция — map().

Возьмем наш пример выше и попробуем преобразовать

List<Log> logs = List.of(
                       new Log("Сибирская сосна", 10),   
                       new Log("Дуб монгольский", 30),
                       new Log("Берёза карликовая", 5));
logs.stream().map(x -> x.getType()).forEach(System.out::println);

Функция map принимает реализацию функционального интерфейса Function<T, R>.

На вход мы получаем объект типа T, а возвращаем объект типа R. То есть наш стрим, который был типизирован объектом Log становится типизирован объектом, который возвращает x.getType(). Мы получаем набор строк с названиями деревьев.

Промежуточные операции можно конкатенировать между собой, то есть мы можем добавить еще несколько преобразований:

logs.stream().map(Log::getType).map(x -> x.split(" "))
.forEach(System.out::println);

Во втором преобразовании мы разбили каждую строку на массив строк. Но если мы запустим приложение, мы увидим, что на экран не вывелись строки, а вывелось toString() массивов. Нам хочется чтобы стрим был плоский — то есть только из объектов, а не из других стримов/массивов в которых есть объекты. Для этого авторы Java Stream API придумали еще одну промежуточную операцию — flatMap. Вот как она позволит изменить нам наш стрим (для более краткой записи я заменил прошлые операции на метод референс):

logs.stream().map(Log::getType).map(x -> x.split(" ")).flatMap(x -> 
Arrays.stream(x)).forEach(System.out::println);

На вход flatMap() поступает функция, задача которой получить из объекта стрим и конкатенировать его с другими. Таким образом, мы создаем стримы из массивов строк и соединяем их вместе. Попробуем теперь получить список всех букв, которые встречаются в нашем стриме. Для этого воспользуемся методом chars(). Метод x.chars() класса String возвращает стрим примитивов IntStream. Каждому символу в строке она сопоставляет int значение.

logs.stream().map(Log::getType)
.map(x -> x.split(" ")).flatMap(Arrays::stream)
.map(String::chars).forEach(System.out::println);

Но запустив пример выше мы получили стрим стримов — Stream<IntStream>

Но так с ним работать не удобно, а обычный flatMap не сработает, поэтому для примитивных стримов существуют специальные операции для их преобразований:

IntStream chars = logs.stream().map(Log::getType).map(x -> x.split("
")).flatMap(Arrays::stream).map(String::chars).flatMapToInt(x -> x);
chars.forEach(x1 -> System.out.println((char)x1));

Значение функции выше x -> x говорит нам о том, что для того чтобы склеить наши стримы нам не нужно никаких дополнительных преобразований. В терминальной операции forEach мы привели значение x1 к символьной записи.

В итоге предыдущий пример вывел нам на экран побуквенно каждое название типа дерева. Что делать, если мы хотим вывести на экран только по одной букве, убрав повторяющиеся буквы? Для этого мы можем воспользоваться промежуточной операцией distinct(). Сама по себе операция очень похожа на фильтр, только разница в том, что она в себе запоминает все числа, которые через нее прошли и в следующий раз «пропускает» только те объекты, которые еще не прошли.

Для того чтобы отсортировать буквы воспользуемся операцией sorted():

IntStream chars = logs.stream().map(Log::getType).map(x -> x.split("
")).flatMap(Arrays::stream).
    map(String::chars).flatMapToInt(x -> x).distinct().sorted();

Стоит отметить, что операция sorted() таит в себе некоторые проблемы. Так для того чтобы отсортировать объекты, поступающие из стрима, она должна аккумулировать в себе все объекты, которые есть в стриме и только потом приступить к сортировке. Но что делать, если стрим бесконечный либо в стриме огромное количество элементов? Вызов такой операции приведет к OutOfMemoryException.

Для того, чтобы ограничить бесконечные операции существует операция limit(). В нее в качестве аргумента мы можем передать число элементов, которых мы хотим взять из стрима.

IntStream chars = logs.stream().map(Log::getType).map(x -> x.split("
")).flatMap(Arrays::stream).
    map(String::chars).flatMapToInt(x -> x).distinct().limit(3).sorted()

В примере выше мы с помощью функции limit ограничили наш стрим до трех элементов, которые уже позже попали в sorted().

Противоположная limit() операция называется skip(). Она принимает в качестве параметра число элементов, которые надо пропустить. То есть если логика нашей программы говорит нам начать обрабатывать элементы только после какого-то.

Порой это не особо удобно, а порой невозможно указать заранее сколько элементов пропустить или поглотить. Поэтому в стримы были добавлены дополнительные промежуточные операции, которые принимают предикат: takeWhile(Predicate<T> predicate) и dropWhile(Predicate<T> predicate).

Простой пример приведен ниже:

new Random().ints().takeWhile(x -> x % 7 != 0).forEach(System.out::print);

Стрим будет генерировать новые значения, пока остаток от деления на 7 сгенерированного значения не будет равен 0.

И последняя операция, которую хотелось бы описать — это boxed. Ее стоит применять в том случае, если мы хотим превратить наш стрим примитивов в объектный стрим. То есть в примере выше, если добавить ее, то наш стрим перестанет быть IntStream, а станет Stream<Integer>.

Есть еще несколько промежуточных операций, но я расскажу только об одной, на мой взгляд, самой важной. Это операция parallel(). Разместив ее в любом месте нашего стрима мы волшебным образом запускаем очень сложный механизм внутри JVM. Мы получаем возможность многопоточной обработки нашего стрима. То есть Stream API постарается максимально эффективно выполнить все операции на разных ядрах процессора.

Терминальные операции

После знакомства с основными промежуточными операциями мы плавно подошли к заключению. Осталось рассмотреть терминальные операции. Это операции, которые как бы «запускают» наш стрим. Мы можем создать стрим и добавить в него любое количество промежуточных операций, но они не будут выполнены пока не будут добавлена терминальная операция.

Выше мы уже применяли одну из самых популярных операций — forEach(Consumer<T> action). В нее попадают все прошедшие через стрим объекты и обрабатываются в соответствие с тем алгоритмом, что будет указан в Consumer.

Кроме этого терминальная операция может и возвращать значение. Рассмотрим самые распространенные — findFirst(), findAny(), anyMatch(), allMatch(), noneMatch().

Функции findFirst() и findAny() возвращают единственное значение, если оно есть, обернутое в Optional. Как нетрудно догадаться, в первом случае мы получим первый элемент нашего стрима, а во втором произвольный элемент из него, при условии, конечно, что элемент существует, иначе вернется Optional.empty().

Функции anyMatch(Predicate<T> predicate), allMatch(Predicate<T> predicate), noneMatch(Predicate<T> predicate) позволяют проверить элементы стрима на определенное условие и вернуть true или false. Первая функция пробегается по элементам стрима до тех пор пока хотя бы для одного элемента не будет выполнено условие, если таких элементов нет, то возвращается false. Противоположным образом работает allMatch() - true возвращается только в том случае, если все элементы подходят, если хотя бы для одного элемента предикат вернул false, то терминальная операция сразу же  вернет тоже самое. noneMath представляет из себя тоже самое, что и allMatch, только с инвертированной функцией предиката.

Теперь стоит перейти к более сложным функциям. Часто в качестве результата стрима мы хотим получить набор из новых объектов, которые были созданы в результате обработки. Для этого удобно поместить их в массив или коллекцию.

В Java Stream API было добавлено несколько методов, которые дают соответствующую функциональность.

Вызвав терминальную операцию Object[] toArray() мы получим ссылку на массив, в котором будет находится все объекты. Если нужно вернуть массив определенного типа, то в метод стоить передать IntFunction<A[]> generator на вход функции поступит число элементов, а внутри нее мы должны создать нужный нам тип массива.

Следующая операция, которую стоить упомянуть T reduce(T identity, BinaryOperator<T> accumulator) — в нее передается начальное значение и бинарная функция, которая задает алгоритм объединения двух объектов.

Для того, чтобы получить сумму первых 100 членов стрима из произвольных значений, запишем:

new Random().ints(100).reduce(0, (x, y) -> x + y);

Мы передаем начальный элемент для сложения, в нашем случае он 0 и бинарную функцию, которая описывает как объединить два значения из стрима.

Если же мы хотим перенести этот набора чисел в коллекцию, то для этого нам надо будет указать как создать коллекцию и как в нее помещать элементы:

List<Integer> ints = new Random().ints(100)
      .boxed() // оборачиваем, так как коллекции не работают с примитивами
      .reduce(new ArrayList<>(),
                           (x, y) -> {
                               x.add(y);
                               return x;
                           },
                           (a, b) -> {
                             a.addAll(b);
                             return a
                             });

В функции reduce мы передали наш начальный аргумент — новую пустую коллекцию. Потом описали правило, по которому будем объединять коллекцию и элементы стрима.

И в конце описали как мы будем объединять две коллекции.

Чтобы сократить подобную запись, была создана терминальная операция collect(Collector<T, A, R> collector).Наше выражение выше перепишется так:

List<Integer> ints = new Random().ints(100)
.boxed() 
.collect(Collectors.toList());

То есть вся логика комбинирования элементов хранится в структуре данных под названием коллектор.

Создатели Java Stream API добавили в библиотеку большое количество коллекторов, рассмотрим их.

Выше мы уже познакомились с коллектором, который комбинирует элементы в список. Если нам нужно собрать элементы в коллекцию типа Set, то достаточно просто использовать коллектор Collectors.toSet().

Существует более общий метод Collectors.toCollection(). В качестве аргумента в нее можно передать коллекцию, в которую будут помещены элементы стрима.

Более сложный коллектор — toMap(). Коллектору надо объяснить как собирать словарь: что нужно делать с объектом, чтобы получить ключ и значение, а так же как себя вести в случае если ключи совпадают.

Подсчитаем количество букв, которые мы получили в стриме chars().

chars.collect(Collectors.toMap(x -> x, x -> 1, Integer::sum))

Для ключа мы используем саму букву без изменений x->x. Далее каждой новой букве сопоставляем число 1. Если буквы совпали, то складываем для них числа в значении values.

Операция partitionBy() позволяет разделить стрим на два множества по определенному условию. Например мы хотим разделить наш буквенный стрим на две группы с большими буквами и прописными:

chars.collect(Collectors.partitioningBy(Character::isLowerCase))

Коллекторы могут быть скомбинированы друг с другом, что дает большую гибкость.

В примере выше мы видим, что некоторые буквы повторяются, мы этого не хотим поэтому добавим еще один коллектор, который соберет все в Set:

chars.collect(Collectors.partitioningBy(Character::isLowerCase, Collectors.toSet()))

Метод groupingBy()выполняет похожую работу, что и toMap() c той лишь разницей, что в него можно дополнительно передать цепочку коллекторов, как и в случае с partitioningBy().

Чтобы самостоятельно реализовать коллектор можно воспользоваться статическим методом:

Collector<T, R, R> of(Supplier<R> supplier,
                      BiConsumer<R, T> accumulator,
                      BinaryOperator<R> combiner,
                      Characteristics... characteristics)

Очень похоже на метод reduce(), который мы рассматривали ранее с той лишь, разницей, что нужно добавить в стрим характеристики коллектора. Они указывают на свойства коллектора, которые могут быть использованы для оптимизации. Например, может ли быть коллектор параллельным, что позволит существенно ускорить комбинирование.

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

Удачного кодинга! )