Глубокое погружение в Stream API Java: Понимание и Применение

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

· 18 минуты на чтение
Глубокое погружение в Stream API Java: Понимание и Применение

Версия Java 8 принесла множество новшеств, которые значительно упростили обработку и манипулирование данными. Одним из таких нововведений стал Stream API - эффективный инструмент для обработки коллекций в функциональном стиле.

Спонсор поста

Зачем нужен Stream API?

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

public void printSpecies(List<SeaCreature> seaCreatures) {
    Set<Species> speciesSet = new HashSet<>();
    for (SeaCreature sc : seaCreatures) {
        if (sc.getWeight() >= 10)
            speciesSet.add(sc.getSpecies());
    }
    List<Species> sortedSpecies = new ArrayList<>(speciesSet);
    Collections.sort(sortedSpecies, new Comparator<Species>() {
        public int compare (Species a, Species b) {
            return Integer.compare(a.getPopulation(), b.getPopulation());
        }
    });
    for (Species s : sortedSpecies)
        System.out.println(s.getName());
}

Он выглядит довольно громоздким, несмотря на то, что не выполняет ничего сложного. Теперь взгляните на тот же пример, но с использованием Stream API:

public void printSpecies(List<SeaCreature> seaCreatures) {
    seaCreatures.stream()
        .filter(sc -> sc.getWeight() >= 10)
        .map(SeaCreature::getSpecies)
        .distinct()
        .sorted(Comparator.comparing(Species::getPopulation))
        .map(Species::getName)
        .forEach(System.out::println);
}

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

Основы Stream API

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

  1. Источник данных.
  2. Выполнение преобразований.
  3. Сохранение результата в новую структуру данных.

Если ваша задача не соответствует этому шаблону, то, возможно, использование Stream API не будет оптимальным решением.

"Стримоз" головного мозга

Это условное заболевание может возникнуть у разработчиков, которые недавно узнали о существовании Stream API. Основной симптом - необузданное желание использовать Stream API для выполнения любых операций над коллекциями.

Пример из реального мира

Представьте, что Stream API - это конвейер на рыболовецком судне. Источник - это река, полная разнообразными морскими обитателями. Мы начинаем с этой "реки" и запускаем Stream, который можно сравнить с конвейером:

  1. Сначала мы используем filter() для отделения рыб от всех других морских существ. Это напоминает рыбаков, которые отбирают только нужные виды рыбы из всего множества разных созданий в реке.
  2. Затем, с помощью map(), мы преобразуем каждую рыбу в "упаковку с рыбой". Это подобно рыболовному сету, которое собирает рыбу и укладывает ее в контейнеры.
  3. В конце, с помощью collect(), мы складываем все упаковки с рыбой в "грузовик" для последующей транспортировки.

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

Компоненты Stream API

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

  1. Источник (Source) - откуда приходят данные. Это может быть коллекция, массив, файл, генератор или любой другой источник данных.
  2. Операции - преобразовывают и/или обрабатывают данные.
  3. Поток (Stream) - последовательность элементов, подлежащих параллельной или последовательной обработке.
  4. Пайплайн (Pipeline) - последовательность операций в потоке, применяемых к данным.
  5. Терминал (Terminal) - место выхода данных из потока. Терминальная операция означает окончание обработки потока и возвращает результат.

Источники даных для потоков

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

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

List<String> list = Arrays.asList("one", "two", "three");
Stream<String> streamFromList = list.stream();

String[] array = {"one", "two", "three"};
Stream<String> streamFromArray = Arrays.stream(array);
Создаем два стрима из разных источников

Операции над потоком

Все операции с потоками можно условно разделить на промежуточные и терминальные.

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

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

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

Важной характеристикой промежуточных операций является то, что каждая из них возвращает новый объект Stream. Это позволяет нам связывать несколько операций в одну "цепочку" (Pipeline).

List<String> list = Arrays.asList("one", "two", "three", "two");
Stream<String> distinctStream = list.stream().distinct();

В данном примере distinct() - это промежуточная операция, которая удаляет дубликаты из потока.

⚠️
Промежуточные операции не должны хранить какое-либо состояние и не должны вызывать побочных эффектов.
Терминальные операции

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

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

List<String> list = Arrays.asList("one", "two", "three");
long count = list.stream().count();

В этом примере count() является терминальной операцией, возвращающей количество элементов в потоке.

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

Stream<String> stream = Stream.of("один", "два", "три");

stream.forEach(System.out::println); // Это терминальная операция.

// Попытка использовать стрим снова вызовет ошибку.
// Например, следующий код вызовет ошибку IllegalStateException.
stream.forEach(System.out::println);

Как работает Stream?

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

public static void main(String[] args) {
    final List<String> list = List.of("one", "two", "three");

    list.stream()
            .filter(s -> {
                System.out.println("filter: " + s);
                return s.length() <= 3;
            })
            .map(s1 -> {
                System.out.println("map: " + s1);
                return s1.toUpperCase();
            })
            .forEach(x -> {
                System.out.println("forEach: " + x);
            });
}

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

filter: one
map: one
forEach: ONE
filter: two
map: two
forEach: TWO
filter: three

Сначала первый элемент проходит через пайплайн, затем второй, а третий не проходит проверку в filter(), поэтому его обработка прекращается на этом этапе. Это важно для эффективности и надлежащего функционирования потоков в Java! Почему?

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

Stateless и Statefull операции

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

С другой стороны, операции, сохраняющие состояние (stateful), такие как sorted(), distinct() или limit(), требуют знания о других элементах для своей работы. Это означает, что им приходится учитывать все (или часть) элементы в потоке перед выдачей какого-либо результата.

Если ваш пайплайн содержит только операции без состояния, то он может быть обработан "в один проход". Если же он содержит операции с состоянием, то пайплайн разбивается на секции, где каждая секция заканчивается операцией с состоянием.

Посмотрите еще раз на пример. Как изменится вывод в консоль после добавления операции сортировки sorted()?

public static void main(String[] args) {
    final List<String> list = List.of("one", "two", "three");

    list.stream()
            .filter(s -> {
                System.out.println("filter: " + s);
                return s.length() <= 3;
            })
            .map(s1 -> {
                System.out.println("map: " + s1);
                return s1.toUpperCase();
            })
            .sorted()
            .forEach(x -> {
                System.out.println("forEach: " + x);
            });
}
filter: one
map: one
filter: two
map: two
filter: three
forEach: ONE
forEach: TWO

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

Spliterator

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

Что такое Java Collection Framework?
Рассказываю об иерархии Java Collection Framework, а также о реализации самых популярных коллекций, таких как ArrayList, HashMap, HashSet, LinckedList.

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

Методы Spliterator

Spliterator описывает 4 основных метода:

  • long estimateSize() возвращает количество элементов.
  • tryAdvance(Consumer) принимает функциональный интерфейс Consumer, который определяет действия, которые должны быть выполнены над текущим элементом.
  • int characteristics() возвращает набор характеристик текущего сплитератора.
  • Spliterator<T> trySplit() пытается разделить текущий сплитератор на два. Если операция успешна, то возвращает новый сплитератор, и уменьшает размер исходного сплитератора. Если разделение не возможно, то возвращает null.
Характеристики Spliterator

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

  • ORDERED: указывает, что элементы имеют определенный порядок.
  • DISTINCT: указывает, что каждый элемент уникален. Определяется по equals().
  • SORTED: указывает, что элементы отсортированы.
  • SIZED: указывает, что размер источника известен заранее.
  • NONNULL: указывает, что ни один элемент не может быть null.
  • IMMUTABLE: указывает, что элементы не могут быть модифицированы.
  • CONCURRENT: указывает, что исходные данные могут быть модифицированы без воздействия на Spliterator.
  • SUBSIZED: указывает, что размер разделенных Spliterator-ов также будет известен.

В зависимости от типа коллекции, из которой получен Spliterator, будут установлены разные характеристики. Например, для коллекции Collection будет установлен флаг SIZED, для Set добавится DISTINCT, а для SortedSet еще и SORTED.

Каждая операция может менять флаги характеристик. Это важно, поскольку каждый этап обработки данных будет знать об этих изменениях, что позволяет выполнить оптимальные действия. Например, операция map() сбросит флаги SORTED и DISTINCT, так как данные могут измениться, но всегда сохранит флаг SIZED, так как размер потока не изменяется при выполнении map().

Параллельное выполнение

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

Для выполнения потоков в параллельном режиме можно использовать методы parallelStream() или parallel(). Без явного вызова этих методов поток будет выполняться последовательно. Для разделения коллекции на части, которые могут быть обработаны отдельными потоками, Java использует метод Spliterator.trySplit().

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

Место расположение .parallel() в Pipline

Может возникнуть вопрос, влияет ли расположение метода .parallel() в пайплайне на поведение потока?

В действительности, место, где указан .parallel(), не имеет значения, поскольку это просто устанавливает характеристику CONCURRENT. В свою очередь, sequential() удаляет эту характеристику.

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

Что же такое Stream?

Центральной концепцией Stream API является потоковые операции, представляющие собой ряд последовательных действий, выполняемых над данными.

Основные свойства потоков:

  1. Декларативность: Потоки в Java описывают, что должно быть сделано, а не конкретный способ его выполнения.
  2. Ленивость: Это означает, что потоки не выполняют никакой работы, пока не будет вызвана терминальная операция.
  3. Одноразовость: После того как терминальная операция была вызвана на потоке, этот поток больше не может быть использован. Если необходимо применить другую операцию к данным, потребуется новый поток.
  4. Параллельность: Несмотря на то, что потоки в Java по умолчанию выполняются последовательно, их можно легко распараллелить.
Рандомный блок

Методы Stream

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

Создание Stream

Есть несколько способов создать поток с использованием Stream API. Например, вы можете создать поток из коллекции с помощью метода stream() или из массива с помощью метода of(). Как только у вас есть поток, вы можете выполнять различные операции с помощью методов, предоставляемых Stream API.

От коллекции

Вы можете создать поток из любой коллекции Java, например, списка или множества, с помощью метода stream().

Этим способом вы будете создавать 90% своих стримов.
Collection<Integer> list = new ArrayList<>();
Stream<Integer> stream = list.stream();

Из массива

Поток может быть создан из массива с помощью метода Arrays.stream().

int[] numbers = {1, 2, 3};
Stream<Integer> stream = Arrays.stream(numbers).boxed();

Из строки

Поток может быть создан из строки с помощью метода chars(), который возвращает IntStream.

String str = "Hello";
IntStream stream = str.chars();

Из файла

Поток может быть создан из строк файла с помощью метода Files.lines().

Path path = Paths.get("file.txt");
Stream stream = Files.lines(path);
Stream из Iterator-а

Многие источники данных хорошо делятся на части, что позволяет использовать преимущества параллельной обработки. Однако такие источники, как Files.lines(), Files.find(), Files.walk(), Files.list(), BufferedReader().lines(), Pattern.splitAsStream(), создают вначале Iterator, который затем трансформируется в Spliterator.

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

Генерирование

Поток может быть создан с помощью метода Stream.generate(Supplier). Supplier должен возвращать новое значение при каждом вызове.

Stream stream = Stream.generate(() -> new Random().nextInt());

Билдер

Поток может быть создан с помощью Stream.Builder.

Stream.Builder builder = Stream.builder();
builder.add(1);
builder.add(2);
builder.add(3);
Stream stream = builder.build();

Промежуточные методы

Мы разобрались с большинством методов создания стрима. Теперь давайте рассмотрим методы для обработки элементов.

filter(Predicate)

Этот метод используется для создания нового потока, включающего только элементы, которые удовлетворяют определенному условию. В качестве аргумента метод принимает функциональный интерфейс Predicate, задающий условие фильтрации.

Вот пример использования метода filter() для создания нового потока, который включает только четные числа из списка целых чисел.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

Stream<Integer> evenNumbersStream = numbers.stream()
        .filter(n -> n % 2 == 0);

evenNumbersStream.forEach(System.out::println); // prints 2, 4, 6, 8, 10
Stateless операция.

ORDERED: обычно сохраняется.
DISTINCT, SORTED, NONNULL, IMMUTABLE, CONCURRENT: сохраняются, если были в исходном Spliterator.
SIZED, SUBSIZED: теряются, поскольку количество элементов после фильтрации неизвестно.

map(Function)

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

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

List<String> words = Arrays.asList("apple", "banana", "orange", "peach");

Stream<Integer> lengthsStream = words.stream()
        .map(String::length);

lengthsStream.forEach(System.out::println); // prints 5, 6, 6, 5

В данном примере мы с помощью map() преобразовали строку в количество символов в строке, используя короткую запись лямбды (String::length), так называемую ссылку на метод.

Stateless операция.

ORDERED: обычно сохраняется.
DISTINCT, SORTED: могут быть потеряны.
SIZED, SUBSIZED: обычно сохраняются.
NONNULL: может быть потеряно.
IMMUTABLE, CONCURRENT: сохраняются.

flatMap()

Метод flatMap() используется для создания одного потока из множества потоков. Он принимает функцию в качестве аргумента, которая применяется к каждому элементу исходного потока. Эта функция принимает элемент исходного потока и возвращает новый поток.

List<List<Integer>> listOfLists = Arrays.asList(
        Arrays.asList(1, 2, 3),
        Arrays.asList(4, 5, 6),
        Arrays.asList(7, 8, 9)
);

Stream<Integer> flattenedStream = listOfLists.stream()
        .flatMap(Collection::stream);

flattenedStream.forEach(System.out::println); // prints 1, 2, 3, 4, 5, 6, 7, 8, 9

В данном примере мы начинаем со списка списков целых чисел и создаем поток с помощью метода stream(). Затем мы используем метод flatMap() для создания нового потока, включающего все целые числа из вложенных списков, путем применения метода stream() к каждому из вложенных списков. Наконец, мы используем метод forEach() для вывода каждого элемента нового потока.

Stateless операция.

Влияние на характеристики:
ORDERED: обычно сохраняется.
DISTINCT, SORTED: обычно теряются.
SIZED, SUBSIZED: теряются.
NONNULL: может быть потеряно.
IMMUTABLE, CONCURRENT: сохраняются.
Разница между map() и flatMap()

Функция map() преобразует элемент исходного потока из одного типа в другой. В отличие от этого, функция flatMap() позволяет получить новый поток из элементов коллекций, которые были внутри элементов первого потока.

distinct()

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

Вот пример, в котором функция distinct() используется для удаления дубликатов из потока целых чисел:

List<Integer> numbers = Arrays.asList(1, 2, 3, 2, 1, 4, 5, 3, 5);

List<Integer> uniqueNumbers = numbers.stream()
                                     .distinct()
                                     .collect(Collectors.toList());

System.out.println(uniqueNumbers); // prints [1, 2, 3, 4, 5]
Statefull операция.

ORDERED: обычно сохраняется.
DISTINCT: всегда устанавливается после операции.
SORTED: сохраняется, если исходный Spliterator был SORTED.
SIZED, SUBSIZED: теряются, поскольку количество уникальных элементов неизвестно.
NONNULL, IMMUTABLE, CONCURRENT: сохраняются.

limit(n)

Метод limit(n) возвращает новый поток, содержащий не более n элементов исходного потока. Если исходный поток содержит меньше n элементов, новый поток будет содержать все элементы исходного потока.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

List<Integer> limitedNumbers = numbers.stream()
                                       .limit(5)
                                       .collect(Collectors.toList());

System.out.println(limitedNumbers); // prints [1, 2, 3, 4, 5]
Statefull операция.

ORDERED: сохраняется, если исходный Spliterator был упорядочен.
DISTINCT, SORTED, NONNULL, IMMUTABLE, CONCURRENT: сохраняются, если были в исходном Spliterator.
SIZED, SUBSIZED: могут быть установлены, если размер исходного стрима был известен и больше значения limit(), иначе могут быть потеряны.

skip(n)

Метод skip(n) возвращает новый поток, который содержит все элементы исходного потока, исключая первые n элементов. Если исходный поток содержит меньше n элементов, новый поток будет пустым.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

List<Integer> skippedNumbers = numbers.stream()
                                       .skip(5)
                                       .collect(Collectors.toList());

System.out.println(skippedNumbers); // prints [6, 7, 8, 9, 10]
Statefull операция.

ORDERED: сохраняется, если исходный Spliterator был упорядочен.
DISTINCT, SORTED, NONNULL, IMMUTABLE, CONCURRENT: сохраняются, если были в исходном Spliterator.
SIZED, SUBSIZED: могут быть потеряны, если количество пропускаемых элементов неизвестно или исходный Spliterator не был SIZED или SUBSIZED. Если размер исходного стрима известен и больше значения skip, эти характеристики сохраняются.

sorted()

Метод sorted() создает новый поток, содержащий элементы исходного потока, отсортированные в порядке возрастания.

При вызове метода sorted() возвращается новый поток, содержащий те же элементы, что и исходный поток, но в отсортированном порядке.

List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");

List<String> sortedNames = names.stream()
                                .sorted()
                                .collect(Collectors.toList());

System.out.println(sortedNames); // prints ["Alice", "Bob", "Charlie", "David"]
Если элементы исходного потока не реализуют интерфейс Comparable, может возникнуть исключение ClassCastException. Чтобы избежать этого, можно предоставить собственный компаратор в качестве аргумента метода sorted().
Statefull операция.

ORDERED: всегда устанавливается после операции sorted(), так как элементы теперь упорядочены в соответствии с естественным порядком или порядком, определенным компаратором.
DISTINCT: сохраняется, если был в исходном Spliterator.
SORTED: всегда устанавливается.
SIZED, SUBSIZED: сохраняются, если были в исходном Spliterator.
NONNULL: сохраняется, если был в исходном Spliterator.
IMMUTABLE, CONCURRENT: сохраняются, если были в исходном Spliterator.

takeWhile(Predicate)

Метод takeWhile() создает новый поток, содержащий элементы исходного потока до тех пор, пока они удовлетворяют указанному условию. Если первый элемент потока не соответствует предикату, новый поток будет пустым.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

List<Integer> takenNumbers = numbers.stream()
                                     .takeWhile(n -> n < 5)
                                     .collect(Collectors.toList());

System.out.println(takenNumbers); // prints [1, 2, 3, 4]
Statefull операция

ORDERED: сохраняется, если исходный Spliterator был упорядочен.
DISTINCT, SORTED, NONNULL, IMMUTABLE, CONCURRENT: сохраняются, если были в исходном Spliterator.
SIZED, SUBSIZED: могут быть потеряны, поскольку количество элементов после takeWhile() неизвестно.

dropWhile(Predicate)

Метод dropWhile() возвращает новый поток, который включает все элементы исходного потока, начиная с первого элемента, не удовлетворяющего указанному условию. В момент, когда предикат возвращает false, все последующие элементы из исходного потока включаются в новый поток.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

List<Integer> droppedNumbers = numbers.stream()
                                       .dropWhile(n -> n < 5)
                                       .collect(Collectors.toList());

System.out.println(droppedNumbers); // prints [5, 6, 7, 8, 9, 10]
Statefull операция

ORDERED: сохраняется, если исходный Spliterator был упорядочен.
DISTINCT, SORTED, NONNULL, IMMUTABLE, CONCURRENT: сохраняются, если были в исходном Spliterator.
SIZED, SUBSIZED: могут быть потеряны, поскольку количество элементов после takeWhile() неизвестно.

peek(Consumer)

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

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

numbers.stream()
       .peek(System.out::println)
       .collect(Collectors.toList());

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

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

Поскольку peek() – это промежуточная операция, которая не предназначена для изменения элементов потока, непреднамеренные изменения могут вызвать непредсказуемые результаты при параллельном выполнении потока.

В общем случае, рекомендуется использовать peek() редко и, преимущественно, для отладки, а не как средство модификации элементов потока. Если требуется изменить элементы потока, предпочтительнее использовать метод map().

На моей практике был всего 1 случай, когда реализовать по другому было невозможно и пришлось использовать peek().

Терминальные методы

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

forEach(Consumer)

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

public int getSum (Stream<Integer> s) {
	int [] sum = new int [1];
	s.forEach ( i -> sum [0] += i);
	return sum [0];
}
Пример побочного результата выполнения метода forEach. Никогда так не делайте.

сollect(Collector)

Этот метод часто используется в конвейере потока. Его применяют для сбора результата потока в определенную структуру: строку, коллекцию (List, Set, Map).

Метод принимает объект типа Collector в качестве аргумента, который определяет способ осуществления операции подсчета.

Класс Collector

Интерфейс Collector инкапсулирует процесс комбинирования элементов потока в одну итоговую структуру. Коллекторы можно использовать с различными методами потока, такими как collect(), groupingBy(), joining(), partitioningBy() и др.

Класс Collectors содержит набор статических методов-коллекторов, которые упрощают выполнение общих операций, таких как преобразование элементов в списки, множества и другие структуры данных.

Вот некоторые наиболее популярные методы класса Collectors:

  1. toList(): Этот метод возвращает коллектор, который накапливает входные элементы в новый List.
  2. toSet(): Этот метод возвращает коллектор, который накапливает входные элементы в новый Set.
  3. joining(): Возвращает коллектор, который объединяет элементы потока в единую строку.
  4. counting(): Возвращает коллектор, который подсчитывает количество элементов в потоке.

Вы можете быстро реализовать метод collect(Collector<? super T, A, R> collector) для сбора элементов в какую-то конкретную структуру.

Stream<?> stream;
List<?> list = stream.collect(Collectors.toList());

//Коллектор выше аналогичен данному коду
list = stream.collect(
        () -> new ArrayList<>(), // определяем структуру
        (list, t) -> list.add(t), // определяем, как добавлять элементы
        (l1, l2) -> l1.addAll(l2) // и как объединять две структуры в одну
);

reduce()

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

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
Optional<Integer> sum = numbers.stream().reduce((a, b) -> a + b);
System.out.println(sum.get());
суммирование чисел

Optional<T> findFirst()

Метод findFirst() возвращает Optional<T> и служит для извлечения первого элемента из потока. Он обычно используется в сочетании с методом filter().

Большой гайд по Optional в Java
Разбираемся, как уменьшить шанс получить NullPointerException, используя класс Optional.

Optional<T> findAny()

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

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

boolean anyMatch(Predicate)

Метод anyMatch(Predicate) используется для проверки, соответствует ли хотя бы один элемент потока указанному предикату.

boolean allMatch(Predicate)

Возвращает true, если все элементы потока удовлетворяют предикату.

Short-circuiting

Рассмотрим так называемые операции "короткого замыкания", которые прекращают обработку, как только находят нужный результат. Это значительно повышает производительность, особенно при работе с большими потоками данных. Примерами операций короткого замыкания могут служить методы anyMatch(), allMatch(), noneMatch(), findFirst(), findAny().

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

Продвинутые советы и использование

В этом разделе будут собраны различные продвинутые подходы для работы со Stream API.

Возвращать Stream<T> вместо коллекций

Это позволит защитить вашу коллекцию внутри сущности, не позволяя ее модифицировать извне.

Так же потребитель вашего API сможет сам выбрать, какая коллекция ему нужна.

Обратите внимание, что из методов мы возвращаем не коллекции, а Stream

Группировка элементов

Чтобы сгруппировать данные по какому-нибудь признаку, нам надо использовать метод collect() и метод Collectors.groupingBy().

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

Группировка списка рабочих по их должности (деление на списки)

 Map<String, List<Worker>> map1 = workers.stream()
       .collect(Collectors.groupingBy(Worker::getPosition));

Группировка списка рабочих по их должности (деление на множества)

Map<String, Set<Worker>> map2 = workers.stream()
      .collect(
      		Collectors.groupingBy(
            	Worker::getPosition, Collectors.toSet()
       		)
       );

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

Map<String, Long> map3 = workers.stream()
       .collect(
       		Collectors.groupingBy(
            	Worker::getPosition, Collectors.counting()
            )
       );

Группировка списка рабочих по их должности, при этом нас интересуют только имена

Map<String, Set<String>> map4 = workers.stream()
       .collect(
       		Collectors.groupingBy(
            	Worker::getPosition, 
              	Collectors.mapping(
                	Worker::getName, 
                    Collectors.toSet()
                )
            )
       );

Расчет средней зарплаты для данной должности

Map<String, Double> map5 = workers.stream()
       .collect(
       		Collectors.groupingBy(
            	Worker::getPosition,
              	Collectors.averagingInt(Worker::getSalary)
            )
       );

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

Map<String, String> map6 = workers.stream()
       .collect(
       		Collectors.groupingBy(
            	Worker::getPosition,
              	Collectors.mapping(
                	Worker::getName, 
                    Collectors.joining(", ", "{","}")
                )
            )
       );

Группировка списка рабочих по их должности и по возрасту.

Map<String, Map<Integer, List<Worker>>> collect = workers.stream()
       .collect(
       		Collectors.groupingBy(
            	Worker::getPosition, 
              	Collectors.groupingBy(Worker::getAge)
            )
       );

Заключение

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

Вместе с тем, важно помнить, что он не подходит для всех задач. Если ваша задача не соответствует шаблону "источник-преобразование-сбор", возможно, стоит обратиться к другим инструментам Java.

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

Struchkov Mark
Struchkov Mark
Задавайте вопросы, если что-то осталось не понятным👇