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

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


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

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

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

Теперь, когда выяснили, чего в этом уроке точно не будет, посмотрим, что же в нем все-таки есть:)

 

Short-circuiting операции

Операции короткого замыкания в контексте терминальных – те, которые позволяют завершить обработку стрима, не обрабатывая все его элементы.

 

boolean anyMatch()

Данный метод принимает параметром Predicate (как и filter(), takeWhile() и т.д.). Применяет этот предикат к каждому элементу стрима. Как только хотя бы для одного элемента предикат вернет true – обработка Stream’а завершится с true. Если были обработаны все элементы, но предикат для всех вернул false – результатом выражения тоже будет false:

boolean isContainsEven = Stream.of(1, 2, 3, 4, 6)
  .anyMatch(i -> i % 2 == 0); // true для 2, больше проверок не будет

boolean isContains1 = Stream.of(2, 2, 2, 2)
  .anyMatch(i -> i == 1); // false, ни один из элементов не равен 1

Для пустого стрима данная операция вернет false – нет элементов => ни один не подходит.

 

boolean allMatch()

Также принимает предикат. Вернет true, если для всех элементов результат лямбда-выражения – true. Вернет false, как только встретит первый элемент, для которого результ ом лямбды будет false:

boolean isAll2 = Stream.of(2, 2, 2, 2)
  .allMatch(i -> i == 2); // true, все элементы равны 2

boolean isAll1 = Stream.of(1, 1, 2, 2)
  .allMatch(i -> i == 1); // false, третий элемент != 1, больше   
                          // проверок не будет

Для пустого стрима данная операция вернет true – нет элементов => нет тех, кто НЕ ВЫПОЛНЯЕТ условие предиката => все выполняют.

 

boolean noneMatch()

Еще одна операция, принимающая параметром предикат. Вернет true, если все элементы стрима НЕ подходят под условие предиката. Или false, если хотя бы 1 подойдет.

В целом, представляет собой allMatch() наоборот:

Плохие примеры. Работают как примеры выше, но, по сути, вводят двойное отрицание, чем усложняют восприятие:

boolean isAll2 = Stream.of(2, 2, 2, 2)
  .noneMatch(i -> i != 2); // true, все элементы НЕ равны 2

boolean isAll1 = Stream.of(1, 1, 2, 2)
  .noneMatch(i -> i != 1); // false, третий элемент != 1 
                           // (выполняет условие предиката), дальнейших 
                           // проверок не будет

Удачный пример:

boolean isOnlyOdds = Stream.of(1, 2, 3, 4, 6)
  .noneMatch (i -> i % 2 == 0); // false для 2, 
                                // дальнейших проверок не будет

Для пустого стрима данная операция вернет true – нет элементов => нет тех, кто ВЫПОЛНЯЕТ условие предиката => никто не выполняет.

noneMatch() и allMatch() взаимозаменяемы. Используйте ту операцию, которая делает условие (предикат) понятнее в конкретной ситуации. Особенно это может помочь со сложными выражениями в предикате, использующими && и/или ||. Помните, что двойное отрицание тяжело для осознания.
Эти операции в достаточной степени похожи и с anyMatch(). Поэтому при выборе метода, который превратит ваш стрим в boolean всегда стоит рассмотреть все три варианта. Как правило, лишь один будет по-настоящему удобен в конкретной ситуации. И не факт, что именно тот, который придет в голову первым.

!NB: будьте осторожны с предикатами для пустых стримов. Даже заведомо ложное условие в лямбде может вернуть результатом Stream’а true (для allMatch() и noneMatch()), а заведомо верное – false (для anyMatch()):
boolean alwaysTrue = Stream.empty()
  .allMatch(i -> 1 == 2);

boolean alwaysFalse = Stream.empty()
  .anyMatch(i -> 1 == 1);

Не забывайте, что пустой стрим может появиться в результате filter() или flatMap(). Наличие элементов в начале цепочки не гарантирует их наличия к моменту вызова терминальной операции.

 

Optional findFirst()

Возвращает первый элемент стрима, обернув его в Optional. Если порядок обработки элементов не определен – может вернуться любой элемент.

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

Для пустого стрима будет возвращен Optional.empty().

Работу данного метода можно продемонстрировать через красивое (не вызывающее IndexOutOfBoundsException), но дорогое получение первого элемента списка, когда нет уверенности, что список содержит элементы:

var obj = list.get(0)

//is equals
var obj = list.stream()
  .findFirst()
  .orElseThrow()// или orElse(), или orElseGet()…

 

Optional findAny()

 Аналогична findFirst(), но вернет любой элемент даже в стриме с определенным порядком обработки элементов. Имеет значение в параллельных стримах. До тех пор, пока обработка осуществляется в одном потоке выполнения – методы можно считать идентичными.

!NB: И findFirst(), и findAny() бросят NPE, если элементом окажется null:
var res = Stream.of(null, 1)
  .findFirst(); // NullPointerException

Short-circuiting операции – мощный инструмент в умелых руках. Он позволяет писать легкочитаемый и эффективный код, императивный аналог которого будет избыточным и/или громоздким.

 

Фан-клуб «Джек Потрошитель» или «бэкдор – не баг, а фича»

Если вам по какой-то причине не хватило функциональности Stream API и вы хотите продолжить дальнейшую обработку элементов стрима с помощью других инструментов – вы можете получить Iterator для элементов стрима. Или Spliterator, в зависимости от ваших предпочтений. Методы:

·      Iterator iterator();

·      Spliterator spliterator().

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

В общем виде можно представить нечто подобное:

var spliterator = sthList.stream()
  .map(…)        // получили какие-то элементы на базе изначального 
                 // набора данных
  .filter(…)     // отфильтровали полученные элементы
  .flatMap(…)    // получили набор элементов из каждого элемента 
                 // предыдущего набора
  .spliterator();

//Ваша нетривиальная логика для элементов сплитератора 


Операции редукции

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

 

Object[] toArray()

Метод без параметров, возвращает массив объектов, созданный из элементов стрима.

У данного метода нет параметров.

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

 

T[] toArray()

Улучшенная версия предыдущего метода. Возвращает массив, параметризованный заданным типом. Особенность в том, что принимает параметром лямбда-выражение, выступающее «генератором». Данная лямбда должна возвращать объект массива. В целом, ее можно сократить до method reference:

Integer[] arr = Stream.of(1, 2, 10)
  .toArray(Integer[]::new);

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

String[] arr = Stream.of(1, 2, 10)
  .toArray(String[]::new);

Ошибки компиляции в таком случае не будет. Но при выполнении упадет исключение. Будьте внимательны при указании типа.

Для заинтересованных: лямбда-выражение, принимаемое параметром в данный метод достаточно специфичное. Оно принимает параметром число типа int, которое должно быть длиной формируемого массива. Таким, образом, в виде лямбда-выражения запись будет иметь следующий вид:
Integer[] arr = Stream.of(1, 2, 10)
  .toArray(length -> new Integer[length]);


Optional<T> reduce(), T reduce()

Метод reduce() – в каком-то смысле, самый главный метод данной группы. По сути, он является основой методой редукции, позволяя реализовывать любую логику, которая вернет объект на основании всех элементов стрима.

Имеет три перегруженные реализации. В целом, все они являются достаточно простыми для восприятия, но их описание обычно получается многословным. Поэтому предлагаю за разбором и примерами обратиться к забытому в последнее время метаниту – там данная операция разобрана вполне качественно: https://metanit.com/java/tutorial/10.5.php

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

 

collect()

Данная операция имеет две реализации – с использованием интерфейса Collector и более низкоуровневую – с 3 параметрами, которые, будучи инкапсулированы в один класс, и составляют основу Collector. Мы посвятим отдельный урок возможностям данных методов, главное, что стоит отметить сейчас:

1.    Данный метод может возвращать не только коллекцию. В широком смысле – он может возвращать вообще любой ссылочный тип;

2.    Наиболее базовые сценарии применения – создание коллекций из элементов стримов. Их можно создавать в разной конфигурации, но мы рассмотрим самые простые, с использованием характерных Collector’ов.

Пример 1. Получение списка (ArrayList) из стрима:

Stream.of(…)
  .collect(Collectors.toList());


Пример 2. Получение сета (HashSet) из стрима:

Stream.of(…)
  .collect(Collectors.toSet());


Пример 3. Получение мапы (HashMap, но c ограничениями):

Stream.of(1, 2, 3)
  .collect(Collectors.toMap(String::valueOf, Function.identity()));

Результатом примера выше будет мапа, где ключом будет строковое представление числа, а значением – само число. Т.е. по ключу "1" будет доступно число 1. Пример исключительно демонстрационный.

Function.identity() – статический метод в функциональном интерфейсе Function. Буквально, возвращает параметр лямбды: return t -> t

Ограничения HashMap при использовании данного коллектора – значения (не ключи) должны быть != null, также не должно быть дублирующихся ключей. Нарушение любого из этих правил приведет к исключению. Их можно обойти, но для этого параметры collect() придется сильно переработать.

Вы могли заметить, что в примерах использовался класс Collectors. Полагаю, вы уже догадались, что он, по аналогии с Spliterators, Arrays и другими является вспомогательным и предоставляет готовые коллекторы для наиболее распространенных сценариев использования. Мы уделим ему особое внимание в уроке, посвященном разбору возможностей collect().

Пока же перейдем к другим операциям.

 

List toList()

Операция, которая выглядит как частный случай collect(). Возвращает неизменяемый список из элементов стрима. Как ни забавно, не использует collect() – данная операция работает на базе toArray().

В целом, достаточно удобная альтернатива collect(Collectors.toList()), если вам не требуется изменять дальнейший состав элементов у полученного списка.

 

Optional min() и Optional max()

Операции получения максимального и минимального элемента.

Как и для всех ситуаций, когда из стрима нужно получить единственный элемент – возвращают Optional. И также, как findFirst() и findAny() бросают NPE, если возвращаемый элемент оказывается null.

Оба этих элемента принимают параметром компаратор. Только min() вернет наименьший элемент, в соответствии с переданным компаратором, а max() – наибольший.

Обратите внимание: если ваш стрим уже отсортирован по соответствующему компаратору, вероятно, использование findFirst() будет дешевле, чем использование min(). И наоборот: если вы сортируете стрим лишь для получения наибольшего/наименьшего элемента – использование max()/min() будет более дешевой альтернативой.

 

long count()

Здесь все очевидно: возвращает количество элементов в стриме, если это возможно.

Помните, что данная операция может игнорировать промежуточные, если они не влияют на число элементов в стриме.

Side-effect операции

Операции с побочным эффектом не возвращают объект. Они лишь делают «что-то».

 

void forEach()

Выполняет лямбда выражение (Consumer) для каждого элемента стрима. Порядок обработки элементов не гарантирован даже если определен в самом стриме. Каждый элемент будет обработан единожды. В целом – аналогичен подобным методам у коллекций, наследующих Iterable (т.е. у всех, кроме Map).

 

void forEachOrdered()

Все то же самое, что и у forEach(), но элементы будут обработаны в том порядке, в котором они обрабатываются в стриме, если такой порядок задан.

 

Операция close()

Ее разбор мы, в сущности, сделали в рамках предыдущих уроков. Запускает все лямбда-выражения, переданные в данный стрим с помощью onClose(), является единственной операцией, доступной после вызова другой тернарной операции. Является реализацией метода close() у AutoCloseable. В сущности, вы можете создать стрим в блоке try-with-resource, тогда данная операция будет вызвана автоматически.

Де-факто, данный метод относится к side-effect операциям.


Заключение

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

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

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

Надеюсь, вам было интересно изучать те возможности, которые предоставляет Stream API. А мне удалось донести этот материал достаточно понятно.

Впереди остается буквально несколько уроков по Stream API. Но уже сейчас вы можете использовать его достаточно осознанно.


С теорией на сегодня все!
Не вижу большой ценности в том, чтобы придумывать задачи по Stream'ам самостоятельно. Однако крайне рекомендую продолжать набивать руку и подобные задачи искать. А также делиться интересными в комментариях.

Я буду искренне рад, если со временем в комментах сформируется сборник интересных задач по Stream API и Optional.

Если что-то непонятно или не получается – welcome в комменты к посту или в лс:)

Канал: https://t.me/+relA0-qlUYAxZjI6

Мой тг: https://t.me/ironicMotherfucker

 

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

Report Page