1. Введение в лямбда-выражения

1.1. Введение

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

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

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

Рассмотрим пример:

public class LambdaApp {
    public static void main(String[] args) {
        Operationable operation;
        operation = (x, y) -> x + y;

        int result = operation.calculate(10, 20);
        System.out.println(result); //30
    }
}

interface Operationable {
    int calculate(int x, int y);
}

В роли функционального интерфейса выступает интерфейс Operationable, в котором определен один метод без реализации - метод calculate. Данный метод принимает два параметра - целых числа, и возвращает некоторое целое число.

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

public class LambdaApp {
    public static void main(String[] args) {
        Operationable op = new Operationable() {
            public int calculate(int x, int y) {
                return x + y;
            }
        };
        int z = op.calculate(20, 10);
        System.out.println(z); // 30
    }
}

interface Operationable {
    int calculate(int x, int y);
}

Чтобы объявить и использовать лямбда-выражение, основная программа разбивается на ряд этапов:

  • Определение ссылки на функциональный интерфейс:

Operationable operation;
  • Создание лямбда-выражения:

operation = (x, y) -> x + y;
  • Использование лямбда-выражения в виде вызова метода интерфейса:

int result = operation.calculate(10, 20);

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

Так, в методе интерфейса оба параметра представляют тип int, значит, в теле лямбда-выражения мы можем применить к ним сложение. Результат сложения также представляет тип int, объект которого возвращается методом интерфейса.

На третьем этапе результатом будет сумма чисел 10 и 20, так как в лямбда-выражении определена операция сложения параметров.

При этом для одного функционального интерфейса мы можем определить множество лямбда-выражений. Например:

Operationable operation1 = (int x, int y)-> x + y;
Operationable operation2 = (int x, int y)-> x - y;
Operationable operation3 = (int x, int y)-> x * y;

System.out.println(operation1.calculate(20, 10)); //30
System.out.println(operation2.calculate(20, 10)); //10
System.out.println(operation3.calculate(20, 10)); //200

1.2. Отложенное выполнение

Одним из ключевых моментов в использовании лямбд является отложенное выполнение (deferred execution). То есть мы определяем в одном месте программы лямбда-выражение и затем можем его вызывать при необходимости неопределенное количество раз в различных частях программы. Отложенное выполнение может потребоваться, к примеру, в следующих случаях:

  • выполнение кода отдельном потоке

  • выполнение одного и того же кода несколько раз

  • выполнение кода в результате какого-то события

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

1.2.1. Передача параметров в лямбда-выражение

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

operation = (int x, int y) -> x + y;

Если метод не принимает никаких параметров, то пишутся пустые скобки, например:

() -> 30 + 20;

Если метод принимает только один параметр, то скобки можно опустить:

n -> n * n;

1.3. Терминальные лямбда-выражения

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

interface Printable {
    void print(String s);
}

public class LambdaApp {
    public static void main(String[] args) {
        Printable printer = s -> System.out.println(s);
        printer.print("Hello Java!");
    }
}

1.4. Лямбды и локальные переменные

Лямбда-выражение может использовать переменные, которые объявлены во вне в более общей области видимости - на уровне класса или метода, в котором лямбда-выражение определено. Однако в зависимости от того, как и где определены переменные, могут различаться способы их использования в лямбдах. Рассмотрим первый пример - использования переменных уровня класса:

public class LambdaApp {
    static int x = 10;
    static int y = 20;

    public static void main(String[] args) {
        Operation op = () -> {
            x = 30;
            return x + y;
        };
        System.out.println(op.calculate()); // 50
        System.out.println(x); // 30 - значение x изменилось
    }
}

interface Operation {
    int calculate();
}

Переменные x и y объявлены на уровне класса, и в лямбда-выражении мы их может получить и даже изменить. Так, в данном случае после выполнения выражения изменяется значение переменной x.

Теперь рассмотрим другой пример - локальные переменные на уровне метода:

public static void main(String[] args) {
    int n = 70;
    int m = 30;
    Operation op = () -> {
        // n = 100; - так нельзя сделать
        return m + n;
    };
    // n = 100;  - так тоже нельзя
    System.out.println(op.calculate()); // 100
}

Локальные переменные уровня метода мы также может использовать в лямбдах, но изменять их значение мы уже не сможем. Если мы попробуем это сделать, то среда разработки может нам высветить ошибку и то, что такую переменную надо пометить с помощью ключевого слова final, то есть сделать константой: final int n = 70;. Однако это необязательно.

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

1.5. Блоки кода в лямбда-выражениях

Существуют два типа лямбда-выражений:

  • однострочное выражение

  • блок кода

Примеры однострочных выражений демонстрировались выше. Блочные выражения обрамляются фигурными скобками. В блочных лямбда-выражениях можно использовать внутренние вложенные блоки, циклы, конструкции if, switch, создавать переменные и т.д. Если блочное лямбда-выражение должно возвращать значение, то явным образом применяется оператор return:

Operationable operation = (int x, int y) -> {
    if(y == 0)
        return 0;
    else
        return x / y;
};

System.out.println(operation.calculate(20, 10)); //2
System.out.println(operation.calculate(20, 0)); //0

1.6. Обобщенный функциональный интерфейс

Функциональный интерфейс может быть обобщенным, однако в лямбда-выражении использование обобщений не допускается. В этом случае нам надо типизировать объект интерфейса определенным типом, который потом будет применяться в лямбда-выражении. Например:

public class LambdaApp {
    public static void main(String[] args) {
        Operationable<Integer> operation1 = (x, y) -> x + y;
        Operationable<String> operation2 = (x, y) -> x + y;

        System.out.println(operation1.calculate(20, 10)); //30
        System.out.println(operation2.calculate("20", "10")); //2010
    }
}

interface Operationable<T> {
    T calculate(T x, T y);
}

Таким образом, при объявлении лямбд-выражения ему уже известно, какой тип параметры будут представлять и какой тип они будут возвращать.

2. Лямбды как параметры и результаты методов

2.1. Лямбды как параметры методов

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

public class LambdaApp {
    public static void main(String[] args) {
        Expression func = (n) -> n % 2 == 0;
        int[] nums = {1, 2, 3, 4, 5, 6, 7, 8, 9};
        System.out.println(sum(nums, func)); // 20
    }

    private static int sum(int[] numbers, Expression func) {
        int result = 0;
        for (int i : numbers) {
            if (func.isEqual(i))
                result += i;
        }
        return result;
    }
}

interface Expression {
    boolean isEqual(int n);
}

Функциональный интерфейс Expression определяет метод isEqual(), который возвращает true, если в отношении числа n действует какое-нибудь равенство.

В основном классе программы определяется метод sum(), который вычисляет сумму всех элементов массива, соответствующих некоторому условию. А само условие передается через параметр Expression func. Причем на момент написания метода sum мы можем абсолютно не знать, какое именно условие будет использоваться. Само же условие определяется в виде лямбда-выражения:

Expression func = (n) -> n % 2 == 0;

То есть в данном случае все числа должны быть четными или остаток от их деления на 2 должен быть равен 0. Затем это лямбда-выражение передается в вызов метода sum.

При этом можно не определять переменную интерфейса, а сразу передать в метод лямбда-выражение:

int[] nums = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
int x = sum(nums, (n)-> n > 5); // сумма чисел, которые больше 5
System.out.println(x);  // 30

2.2. Ссылки на метод как параметры методов

Начиная с JDK 8 в Java можно в качестве параметра в метод передавать ссылку на другой метод. В принципе данный способ аналогичен передаче в метод лямбда-выражения.

Ссылка на метод передается в виде имя_класса::имя_статического_метода (если метод статический) или объект_класса::имя_метода (если метод нестатический). Рассмотрим на примере:

interface Expression {
    boolean isEqual(int n);
}

class ExpressionHelper {
    static boolean isEven(int n) {
        return n % 2 == 0;
    }

    static boolean isPositive(int n) {
        return n > 0;
    }
}

public class LambdaApp {
    public static void main(String[] args) {
        int[] nums = {-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5};
        System.out.println(sum(nums, ExpressionHelper::isEven));

        Expression expr = ExpressionHelper::isPositive;
        System.out.println(sum(nums, expr));
    }

    private static int sum(int[] numbers, Expression func) {
        int result = 0;
        for (int i : numbers) {
            if (func.isEqual(i))
                result += i;
        }
        return result;
    }
}

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

В основном классе программы LambdaApp определен метод sum(), который возвращает сумму элементов массива, соответствующих некоторому условию. Условие передается в виде объекта функционального интерфейса Expression.

В методе main два раза вызываем метод sum, передавая в него один и тот же массив чисел, но разные условия. Первый вызов метода sum:

System.out.println(sum(nums, ExpressionHelper::isEven));

На место второго параметра передается ExpressionHelper::isEven, то есть ссылка на статический метод isEven() класса ExpressionHelper. При этом методы, на которые идет ссылка, должны совпадать по параметрам и результату с методом функционального интерфейса.

При втором вызове метода sum отдельно создается объект Expression, который затем передается в метод:

Expression expr = ExpressionHelper::isPositive;
System.out.println(sum(nums, expr));

Использование ссылок на методы в качестве параметром аналогично использованию лямбда-выражений.

Если нам надо вызвать нестатические методы, то в ссылке вместо имени класса применяется имя объекта этого класса:

interface Expression {
    boolean isEqual(int n);
}

class ExpressionHelper {
    boolean isEven(int n) {
        return n % 2 == 0;
    }
}

public class LambdaApp {
    public static void main(String[] args) {
        int[] nums = {-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5};
        ExpressionHelper exprHelper = new ExpressionHelper();
        System.out.println(sum(nums, exprHelper::isEven)); // 0
    }

    private static int sum(int[] numbers, Expression func) {
        int result = 0;
        for (int i : numbers) {
            if (func.isEqual(i))
                result += i;
        }
        return result;
    }
}

2.3. Ссылки на конструкторы

Подобным образом мы можем использовать конструкторы: название_класса::new. Например:

public class LambdaApp {
    public static void main(String[] args) {
        UserBuilder userBuilder = User::new;
        User user = userBuilder.create("Tom");
        System.out.println(user.getName());
    }
}

interface UserBuilder {
    User create(String name);
}

class User {
    private String name;

    String getName() {
        return name;
    }

    User(String n) {
        this.name = n;
    }
}

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

2.4. Лямбды как результат методов

Также метод в Java может возвращать лямбда-выражение. Рассмотрим следующий пример:

interface Operation {
    int execute(int x, int y);
}

public class LambdaApp {
    public static void main(String[] args) {
        Operation func = action(1);
        int a = func.execute(6, 5);
        System.out.println(a); // 11

        int b = action(2).execute(8, 2);
        System.out.println(b); // 6
    }

    private static Operation action(int number) {
        switch (number) {
            case 1:
                return (x, y) -> x + y;
            case 2:
                return (x, y) -> x - y;
            case 3:
                return (x, y) -> x * y;
            default:
                return (x, y) -> 0;
        }
    }
}

В данном случае определен функциональный интерфейс Operation, в котором метод execute принимает два значения типа int и возвращает значение типа int.

Метод action принимает в качестве параметра число и в зависимости от его значения возвращает то или иное лямбда-выражение. Оно может представлять либо сложение, либо вычитание, либо умножение, либо просто возвращает 0. Стоит учитывать, что формально возвращаемым типом метода action является интерфейс Operation, а возвращаемое лямбда-выражение соответствует этому интерфейсу.

В методе main мы можем вызвать этот метод action. Например, сначала получить его результат - лямбда-выражение, которое присваивается переменной Operation. А затем через метод execute выполнить это лямбда-выражение:

Operation func = action(1);
int a = func.execute(6, 5);
System.out.println(a); // 11

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

int b = action(2).execute(8, 2);
System.out.println(b); // 6

3. Встроенные функциональные интерфейсы

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

  • Predicate<T>

  • Consumer<T>

  • Function<T, R>

  • Supplier<T>

  • UnaryOperator<T>

  • BinaryOperator<T>

3.1. Predicate<T>

Функциональный интерфейс Predicate<T> проверяет соблюдение некоторого условия. Если оно соблюдается, то возвращается значение true. В качестве параметра лямбда-выражение принимает объект типа T:

public interface Predicate<T> {
    boolean test(T t);
}

Например:

import java.util.function.Predicate;

public class LambdaApp {
    public static void main(String[] args) {
        Predicate<Integer> isPositive = x -> x > 0;

        System.out.println(isPositive.test(5)); // true
        System.out.println(isPositive.test(-7)); // false
    }
}

3.2. BinaryOperator<T>

BinaryOperator<T> принимает в качестве параметра два объекта типа T, выполняет над ними бинарную операцию и возвращает ее результат также в виде объекта типа T:

public interface BinaryOperator<T> {
    T apply(T t1, T t2);
}

Например:

import java.util.function.BinaryOperator;

public class LambdaApp {
    public static void main(String[] args) {
        BinaryOperator<Integer> multiply = (x, y) -> x * y;

        System.out.println(multiply.apply(3, 5)); // 15
        System.out.println(multiply.apply(10, -2)); // -20
    }
}

3.3. UnaryOperator<T>

UnaryOperator<T> принимает в качестве параметра объект типа T, выполняет над ними операции и возвращает результат операций в виде объекта типа T:

public interface UnaryOperator<T> {
    T apply(T t);
}

Например:

import java.util.function.UnaryOperator;

public class LambdaApp {
    public static void main(String[] args) {
        UnaryOperator<Integer> square = x -> x * x;
        System.out.println(square.apply(5)); // 25
    }
}

3.4. Function<T, R>

Функциональный интерфейс Function<T, R> представляет функцию перехода от объекта типа T к объекту типа R:

public interface Function<T, R> {
    R apply(T t);
}

Например:

import java.util.function.Function;

public class LambdaApp {
    public static void main(String[] args) {
        Function<Integer, String> convert = x -> String.valueOf(x) + " долларов";
        System.out.println(convert.apply(5)); // 5 долларов
    }
}

3.5. Consumer<T>

Consumer<T> выполняет некоторое действие над объектом типа T, при этом ничего не возвращая:

public interface Consumer<T> {
    void accept(T t);
}

Например:

import java.util.function.Consumer;

public class LambdaApp {
    public static void main(String[] args) {
        Consumer<Integer> printer = x -> System.out.printf("%d долларов \n", x);
        printer.accept(600); // 600 долларов
    }
}

3.6. Supplier<T>

Supplier<T> не принимает никаких аргументов, но должен возвращать объект типа T:

public interface Supplier<T> {
    T get();
}

Например:

import java.util.Scanner;
import java.util.function.Supplier;

public class LambdaApp {
    public static void main(String[] args) {
        Supplier<User> userFactory = () -> {
            Scanner in = new Scanner(System.in);
            System.out.println("Введите имя: ");
            String name = in.nextLine();
            return new User(name);
        };

        User user1 = userFactory.get();
        User user2 = userFactory.get();

        System.out.println("Имя user1: " + user1.getName());
        System.out.println("Имя user2: " + user2.getName());
    }
}

class User {
    private String name;

    String getName() {
        return name;
    }

    User(String n) {
        this.name = n;
    }
}

4. Введение Stream API

Начиная с JDK 8 в Java появился новый API - Stream API. Его задача - упростить работу с наборами данных, в частности, упростить операции фильтрации, сортировки и другие манипуляции с данными. Вся основная функциональность данного API сосредоточена в пакете java.util.stream.

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

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

Java Stream

При ближайшем рассмотрении можно найти в других технологиях программирования аналоги подобного API. В частности, в языке C# некоторым аналогом Stream API будет технология LINQ.

Рассмотрим простейший пример. Допустим, у нас есть задача: найти в массиве количество всех чисел, которые больше 0. До JDK 8 мы бы могли написать что-то наподобие следующего:

public class Test {
    public static void main(String[] args) {
        int[] numbers = {-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5};
        int count = 0;
        for (int i : numbers) {
            if (i > 0) count++;
        }
        System.out.println(count);
    }
}

Теперь применим Stream API:

import java.util.stream.IntStream;

public class Test {
    public static void main(String[] args) {
        long count = IntStream.of(-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5)
            .filter(w -> w > 0)
            .count();
        System.out.println(count);
    }
}

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

При работе со Stream API важно понимать, что все операции с потоками бывают либо терминальными (terminal), либо промежуточными (intermediate). Промежуточные операции возвращают трансформированный поток. Например, выше в примере метод filter принимал поток чисел и возвращал уже преобразованный поток, в котором только числа больше 0. К возвращенному потоку также можно применить ряд промежуточных операций.

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

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

В основе Stream API лежит интерфейс BaseStream. Его полное определение:

interface BaseStream<T , S extends BaseStream<T , S>>

Здесь параметр T означает тип данных в потоке, а S - тип потока, который наследуется от интерфейса BaseStream.

BaseStream определяет базовый функционал для работы с потоками, которые реализуется через его методы:

  • void close() закрывает поток

  • boolean isParallel() возвращает true, если поток является параллельным

  • Iterator<Т> iterator() возвращает ссылку на итератор потока

  • Spliterator<Т> spliterator() возвращает ссылку на сплитератор потока

  • S parallel() возвращает параллельный поток (параллельные потоки могут задействовать несколько ядер процессора в многоядерных архитектурах)

  • S sequential() возвращает последовательный поток

  • S unordered() возвращает неупорядоченный поток

От интерфейса BaseStream наследуется ряд интерфейсов, предназначенных для создания конкретных потоков:

  • Stream<T> используется для потоков данных, представляющих любой ссылочный тип

  • IntStream используется для потоков с типом данных int

  • DoubleStream используется для потоков с типом данных double

  • LongStream используется для потоков с типом данных long

При работе с потоками, которые представляют определенный примитивный тип - double, int, long проще использовать интерфейсы DoubleStream, IntStream, LongStream. Но в большинстве случаев, как правило, работа происходит с более сложными данными, для которых предназначен интерфейс Stream<T>. Рассмотрим некоторые его методы:

  • boolean allMatch(Predicate<? super T> predicate) возвращает true, если все элементы потока удовлетворяют условию в предикате

  • boolean anyMatch(Predicate<? super T> predicate) возвращает true, если хоть один элемент потока удовлетворяют условию в предикате

  • <R, A> R collect(Collector<? super T, A, R> collector) добавляет элементы в неизменяемый контейнер с типом R. T представляет тип данных из вызывающего потока, а A - тип данных в контейнере

  • long count() возвращает количество элементов в потоке

  • Stream<T> concat|(Stream<? extends T> a, Stream<? extends T> b) объединяет два потока

  • Stream<T> distinct() возвращает поток, в котором имеются только уникальные данные с типом T

  • Stream<T> dropWhile​(Predicate<? super T> predicate) пропускает элементы, которые соответствуют условию в predicate, пока не попадется элемент, который не соответствует условию. Выбранные элементы возвращаются в виде потока

  • Stream<T> filter(Predicate<? super T> predicate) фильтрует элементы в соответствии с условием в предикате

  • Optional<T> findFirst() возвращает первый элемент из потока

  • Optional<T> findAny() возвращает первый попавшийся элемент из потока

  • void forEach(Consumer<? super T> action) для каждого элемента выполняется действие action

  • Stream<T> limit(long maxSize) оставляет в потоке только maxSize элементов

  • Optional<T> max(Comparator<? super T> comparator) возвращает максимальный элемент из потока. Для сравнения элементов применяется компаратор comparator

  • Optional<T> min(Comparator<? super T> comparator) возвращает минимальный элемент из потока. Для сравнения элементов применяется компаратор comparator

  • <R> Stream<R> map(Function<? super T,? extends R> mapper) преобразует элементы типа T в элементы типа R и возвращает поток с элементами R

  • <R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper) позволяет преобразовать элемент типа T в несколько элементов типа R и возвращает поток с элементами R

  • boolean noneMatch(Predicate<? super T> predicate) возвращает true, если ни один из элементов в потоке не удовлетворяет условию в предикате

  • Stream<T> skip(long n) возвращает поток, в котором отсутствуют первые n элементов

  • Stream<T> sorted() возвращает отсортированный поток

  • Stream<T> sorted(Comparator<? super T> comparator) возвращает отсортированный в соответствии с компаратором поток

  • Stream<T> takeWhile​(Predicate<? super T> predicate) выбирает из потока элементы, пока они соответствуют условию в predicate. Выбранные элементы возвращаются в виде потока

  • Object[] toArray() возвращает массив из элементов потока

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

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

  • операции с потоками не изменяют источника данных. Операции с потоками лишь возвращают новый поток с результатами этих операций

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

5. Создание потока данных

Для создания потока данных можно применять различные методы. В качестве источника потока мы можем использовать коллекции. В частности, в JDK 8 в интерфейс Collection, который реализуется всеми классами коллекций, были добавлены два метода для работы с потоками:

  • default Stream<E> stream возвращается поток данных из коллекции

  • default Stream<E> parallelStream возвращается параллельный поток данных из коллекции

Так, рассмотрим пример с ArrayList:

import java.util.ArrayList;
import java.util.Collections;

public class Test {
    public static void main(String[] args) {
        ArrayList<String> cities = new ArrayList<String>();
        Collections.addAll(cities, "Париж", "Лондон", "Мадрид");
        cities.stream() // получаем поток
                .filter(s -> s.length() == 6) // применяем фильтрацию по длине строки
                .forEach(s -> System.out.println(s)); // выводим отфильтрованные строки на консоль
    }
}

Здесь с помощью вызова cities.stream() получаем поток, который использует данные из списка cities. С помощью каждой промежуточной операции, которая применяется к потоку, мы также можем получить поток с учетом модификаций. Например, мы можем изменить предыдущий пример следующим образом:

import java.util.ArrayList;
import java.util.Collections;
import java.util.stream.Stream;

public class Test {
    public static void main(String[] args) {
        ArrayList<String> cities = new ArrayList<String>();
        Collections.addAll(cities, "Париж", "Лондон", "Мадрид");
        Stream<String> citiesStream = cities.stream(); // получаем поток
        citiesStream = citiesStream.filter(s -> s.length() == 6); // применяем фильтрацию по длине строки
        citiesStream.forEach(s -> System.out.println(s)); // выводим отфильтрованные строки на консоль
    }
}

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

citiesStream.forEach(s -> System.out.println(s)); // терминальная операция употребляет поток
long number = citiesStream.count(); // здесь ошибка, так как поток уже употреблен
System.out.println(number);
citiesStream = citiesStream.filter(s -> s.length() > 5); // тоже нельзя, так как поток уже употреблен

Фактически жизненный цикл потока проходит следующие три стадии:

  • создание потока

  • применение к потоку ряда промежуточных операций

  • применение к потоку терминальной операции и получение результата

Кроме вышерассмотренных методов мы можем использовать еще ряд способов для создания потока данных. Один из таких способов представляет метод Arrays.stream(T[] array), который создает поток данных из массива:

Stream<String> citiesStream = Arrays.stream(new String[]{"Париж", "Лондон", "Мадрид"}) ;
citiesStream.forEach(s -> System.out.println(s)); // выводим все элементы массива

Для создания потоков IntStream, DoubleStream, LongStream можно использовать соответствующие перегруженные версии этого метода:

IntStream intStream = Arrays.stream(new int[]{1, 2, 4, 5, 7});
intStream.forEach(i -> System.out.println(i));

LongStream longStream = Arrays.stream(new long[]{100, 250, 400, 5843787, 237});
longStream.forEach(l -> System.out.println(l));

DoubleStream doubleStream = Arrays.stream(new double[]{3.4, 6.7, 9.5, 8.2345, 121});
doubleStream.forEach(d -> System.out.println(d));

И еще один способ создания потока представляет статический метод of(T..values) класса Stream:

Stream<String> citiesStream = Stream.of("Париж", "Лондон", "Мадрид");
citiesStream.forEach(s -> System.out.println(s));

// можно передать массив
String[] cities = {"Париж", "Лондон", "Мадрид"};
Stream<String> citiesStream2 = Stream.of(cities);

IntStream intStream = IntStream.of(1, 2, 4, 5, 7);
intStream.forEach(i -> System.out.println(i));

LongStream longStream = LongStream.of(100, 250, 400, 5843787, 237);
longStream.forEach(l -> System.out.println(l));

DoubleStream doubleStream = DoubleStream.of(3.4, 6.7, 9.5, 8.2345, 121);
doubleStream.forEach(d -> System.out.println(d));

6. Фильтрация, перебор элементов и отображение

6.1. forEach()

Для перебора элементов потока применяется метод forEach(), который представляет терминальную операцию. В качестве параметра он принимает объект Consumer<? super String>, который представляет действие, выполняемое для каждого элемента набора. Например:

Stream<String> citiesStream = Stream.of("Париж", "Лондон", "Мадрид","Берлин", "Брюссель");
citiesStream.forEach(s -> System.out.println(s));

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

Париж
Лондон
Мадрид
Берлин
Брюссель

Кстати мы можем сократить в данном случае применение метода forEach следующим образом:

Stream<String> citiesStream = Stream.of("Париж", "Лондон", "Мадрид","Берлин", "Брюссель");
citiesStream.forEach(System.out::println);

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

6.2. filter()

Для фильтрации элементов в потоке применяется метод filter(), который представляет промежуточную операцию. Он принимает в качестве параметра некоторое условие в виде объекта Predicate<T> и возвращает новый поток из элементов, которые удовлетворяют этому условию:

Stream<String> citiesStream = Stream.of("Париж", "Лондон", "Мадрид","Берлин", "Брюссель");
citiesStream.filter(s -> s.length() == 6).forEach(s -> System.out.println(s));

Здесь условие s.length() == 6 возвращает true для тех элементов, длина которых равна 6 символам. То есть в итоге программа выведет:

Лондон
Мадрид
Берлин

Рассмотрим еще один пример фильтрации с более сложными данными. Допустим, у нас есть следующий класс Phone:

class Phone {
    private String name;
    private int price;

    public Phone(String name, int price) {
        this.name = name;
        this.price = price;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getPrice() {
        return price;
    }

    public void setPrice(int price) {
        this.price = price;
    }
}

Отфильтруем набор телефонов по цене:

Stream<Phone> phoneStream = Stream.of(
    new Phone("iPhone 6 S", 54000),
    new Phone("Lumia 950", 45000),
    new Phone("Samsung Galaxy S 6", 40000)
);

phoneStream.filter(p -> p.getPrice() < 50000)
        .forEach(p -> System.out.println(p.getName()));

6.3. map()

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

<R> Stream<R> map(Function<? super T, ? extends R> mapper)

Передаваемая в метод map() функция задает преобразование от объектов типа T к типу R. И в результате возвращается новый поток с преобразованными объектами.

Возьмем вышеопределенный класс телефонов и выполним преобразование от типа Phone к типу String:

Stream<Phone> phoneStream = Stream.of(
    new Phone("iPhone 6 S", 54000),
    new Phone("Lumia 950", 45000),
    new Phone("Samsung Galaxy S 6", 40000)
);
phoneStream.map(p -> p.getName()) // помещаем в поток только названия телефонов
        .forEach(s -> System.out.println(s));

Операция map(p → p.getName()) помещает в новый поток только названия телефонов. В итоге на консоли будут только названия:

iPhone 6 S
Lumia 950
Samsung Galaxy S 6

Еще проведем преобразования:

phoneStream
        .map(p -> "название: " + p.getName() + " цена: " + p.getPrice())
        .forEach(s -> System.out.println(s));

Здесь также результирующий поток содержит только строки, только теперь названия соединяются с ценами.

Для преобразования объектов в типы Integer, Long, Double определены специальные методы mapToInt(), mapToLong() и mapToDouble() соответственно.

6.4. flatMap()

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

<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper)

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

Stream<Phone> phoneStream = Stream.of(
    new Phone("iPhone 6 S", 54000),
    new Phone("Lumia 950", 45000),
    new Phone("Samsung Galaxy S 6", 40000)
);

phoneStream
        .flatMap(p -> Stream.of(
                String.format("название: %s  цена без скидки: %d", p.getName(), p.getPrice()),
                String.format("название: %s  цена со скидкой: %d", p.getName(), p.getPrice() - (int) (p.getPrice() * 0.1))
        ))
        .forEach(s -> System.out.println(s));

Результат работы программы:

название: iPhone 6 S цена без скидки: 54000
название: iPhone 6 S цена со скидкой: 48600
название: Lumia 950 цена без скидки: 45000
название: Lumia 950 цена со скидкой: 40500
название: Samsung Galaxy S 6 цена без скидки: 40000
название: Samsung Galaxy S 6 цена со скидкой: 36000

7. sorted()

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

Для простой сортировки по возрастанию применяется метод sorted():

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class Program {
    public static void main(String[] args) {
        List<String> phones = new ArrayList<String>();
        Collections.addAll(phones, "iPhone X", "Nokia 9", "Huawei Nexus 6P",
                "Samsung Galaxy S8", "LG G6", "Xiaomi MI6",
                "ASUS Zenfone 3", "Sony Xperia Z5", "Meizu Pro 6",
                "Pixel 2");

        phones.stream()
                .filter(p -> p.length() < 12)
                .sorted() // сортировка по возрастанию
                .forEach(s -> System.out.println(s));
    }
}

Консольный вывод после сортировки объектов:

LG G6
Meizu Pro 6
Nokia 9
Pixel 2
Xiaomi MI6
iPhone X

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

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

Если же у нас классы объектов не реализуют этот интерфейс или мы хотим создать какую-то свою логику сортировки, то мы можем использовать другую версию метода sorted(), которая в качестве параметра принимает компаратор.

Например, пусть у нас есть следующий класс Phone:

class Phone {
    private String name;
    private String company;
    private int price;

    public Phone(String name, String comp, int price) {
        this.name = name;
        this.company = comp;
        this.price = price;
    }

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }

    public String getCompany() {
        return company;
    }
}

Отсортируем поток обектов Phone:

import java.util.Comparator;
import java.util.stream.Stream;

public class Program {
    public static void main(String[] args) {
        Stream<Phone> phoneStream = Stream.of(new Phone("iPhone X", "Apple", 600),
            new Phone("Pixel 2", "Google", 500),
            new Phone("iPhone 8", "Apple",450),
            new Phone("Nokia 9", "HMD Global",150),
            new Phone("Galaxy S9", "Samsung", 300));

        phoneStream.sorted(new PhoneComparator())
                .forEach(p->System.out.printf("%s (%s) - %d \n",
                        p.getName(), p.getCompany(), p.getPrice()));
    }
}

class PhoneComparator implements Comparator<Phone> {
    public int compare(Phone a, Phone b) {
        return a.getName().toUpperCase().compareTo(b.getName().toUpperCase());
    }
}

Здесь определен класс компаратора PhoneComparator, который сортирует объекты по полю name. В итоге мы получим следующий вывод:

Galaxy S9 (Samsung) - 300
iPhone 8 (Apple) - 450
iPhone X (Apple) - 600
Nokia 9 (HMD Global) - 150
Pixel 2 (Google) - 500

8. Получение подпотока и объединение потоков

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

8.1. takeWhile()

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

import java.util.stream.Stream;

public class Program {
    public static void main(String[] args) {
        Stream<Integer> numbers = Stream.of(-3, -2, -1, 0, 1, 2, 3, -4, -5);
        numbers.takeWhile(n -> n < 0)
            .forEach(n -> System.out.println(n));
    }
}

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

-3
-2
-1

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

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

Stream<Integer> numbers = Stream.of(-3, -2, -1, 0, 1, 2, 3, -4, -5);
numbers.sorted().takeWhile(n -> n < 0)
        .forEach(n -> System.out.println(n));

Консольный вывод программы:

-5
-4
-3
-2
-1

8.2. dropWhile()

Метод dropWhile() выполняет обратную задачу - он пропускает элементы потока, которые соответствуют условию до тех пор, пока не встретит элемент, который НЕ соответствует условию:

Stream<Integer> numbers = Stream.of(-3, -2, -1, 0, 1, 2, 3, -4, -5);
numbers.sorted().dropWhile(n -> n < 0)
    .forEach(n -> System.out.println(n));

Консольный вывод программы:

0
1
2
3

8.3. Stream.concat()

Статический метод concat() объединяет элементы двух потоков, возвращая объединенный поток:

import java.util.stream.Stream;

public class Program {
    public static void main(String[] args) {
        Stream<String> people1 = Stream.of("Tom", "Bob", "Sam");
        Stream<String> people2 = Stream.of("Alice", "Kate", "Sam");
        Stream.concat(people1, people2).forEach(n -> System.out.println(n));
    }
}

Консольный вывод:

Tom
Bob
Sam
Alice
Kate
Sam

8.4. distinct()

Метод distinct() возвращает только ункальные элементы в виде потока:

Stream<String> people = Stream.of("Tom", "Bob", "Sam", "Tom", "Alice", "Kate", "Sam");
people.distinct().forEach(p -> System.out.println(p));

Консольный вывод:

Tom
Bob
Sam
Alice
Kate

9. skip() и limit()

Метод skip(long n) используется для пропуска n элементов. Этот метод возвращает новый поток, в котором пропущены первые n элементов.

Метод limit(long n) применяется для выборки первых n элементов потоков. Этот метод также возвращает модифицированный поток, в котором не более n элементов.

Зачастую эта пара методов используется вместе для создания эффекта постраничной навигации. Рассмотрим, как их применять:

Stream<String> phoneStream = Stream.of("iPhone 6 S", "Lumia 950", "Samsung Galaxy S 6", "LG G 4", "Nexus 7");

phoneStream.skip(1)
    .limit(2)
    .forEach(s -> System.out.println(s));

В данном случае метод skip() пропускает один первый элемент, а метод limit() выбирает два следующих элемента. В итоге мы получим следующий консольный вывод:

Lumia 950
Samsung Galaxy S 6

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

И если в метод limit() передается число, большее, чем количество элементов, то просто выбираются все элементы потока.

Теперь рассмотрим, как создать постраничную навигацию:

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.*;
import java.util.Scanner;

public class Program {
    public static void main(String[] args) {
        List<String> phones = new ArrayList<String>();
        phones.addAll(Arrays.asList(new String[]
                {"iPhone 6 S", "Lumia 950", "Huawei Nexus 6P",
                "Samsung Galaxy S 6", "LG G 4", "Xiaomi MI 5",
                "ASUS Zenfone 2", "Sony Xperia Z5", "Meizu Pro 5",
                "Lenovo S 850"}));

        int pageSize = 3; // количество элементов на страницу
        Scanner scanner = new Scanner(System.in);
        while(true) {
            System.out.println("Введите номер страницы: ");
            int page = scanner.nextInt();

            if(page < 1) {
                break; // если число меньше 1, выходим из цикла
            }

            phones.stream().skip((page - 1) * pageSize)
                .limit(pageSize)
                .forEach(s -> System.out.println(s));
       }
    }
}

В данном случае у нас набор из 10 элементов. С помощью переменной pageSize определяем количество элементов на странице - 3. То есть у нас получится 4 страницы (на последней будет только один элемент).

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

Теперь введем какие-нибудь номера страниц, например, 4 и 2:

Введите номер страницы:
4
Lenovo S 850
Введите номер страницы:
2
Samsung Galaxy S 6
LG G 4
Xiaomi MI 5

10. Операции сведения

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

10.1. count()

Метод count() возвращает количество элементов в потоке данных:

import java.util.stream.Stream;
import java.util.Optional;
import java.util.*;
public class Program {
    public static void main(String[] args) {
        ArrayList<String> names = new ArrayList<String>();
        names.addAll(Arrays.asList(new String[]{"Tom", "Sam", "Bob", "Alice"}));
        System.out.println(names.stream().count()); // 4

        // количество элементов с длиной не больше 3 символов
        System.out.println(names.stream()
                .filter(n -> n.length() <= 3)
                .count());  // 3
    }
}

10.2. findFirst() и findAny()

Метод findFirst() извлекает из потока первый элемент, а findAny() извлекает случайный объект из потока (нередко так же первый):

ArrayList<String> names = new ArrayList<String>();
names.addAll(Arrays.asList(new String[]{"Tom", "Sam", "Bob", "Alice"}));

Optional<String> first = names.stream().findFirst();
System.out.println(first.get()); // Tom

Optional<String> any = names.stream().findAny();
System.out.println(first.get()); // Tom

10.3. allMatch(), anyMatch(), noneMatch()

Еще одна группа операций сведения возвращает логическое значение true или false:

  • boolean allMatch(Predicate<? super T> predicate) возвращает true, если все элементы потока удовлетворяют условию в предикате

  • boolean anyMatch(Predicate<? super T> predicate) возвращает true, если хоть один элемент потока удовлетворяют условию в предикате

  • boolean noneMatch(Predicate<? super T> predicate) возвращает true, если ни один из элементов в потоке не удовлетворяет условию в предикате

Пример использования функций:

import java.util.stream.Stream;
import java.util.Optional;
import java.util.ArrayList;
import java.util.Arrays;
public class Program {
    public static void main(String[] args) {
        ArrayList<String> names = new ArrayList<String>();
        names.addAll(Arrays.asList(new String[]{"Tom", "Sam", "Bob", "Alice"}));

        // есть ли в потоке строка, длина которой больше 3
        boolean any = names.stream().anyMatch(s -> s.length() > 3);
        System.out.println(any); // true

        // все ли строки имеют длину в 3 символа
        boolean all = names.stream().allMatch(s -> s.length() == 3);
        System.out.println(all); // false

        // НЕТ ЛИ в потоке строки "Bill". Если нет, то true, если есть, то false
        boolean none = names.stream().noneMatch(s -> s == "Bill");
        System.out.println(none); // true
    }
}

10.4. min() и max()

Методы min() и max() возвращают соответственно минимальное и максимальное значение. Поскольку данные в потоке могут представлять различные типы, в том числе сложные классы, то в качестве параметра в эти методы передается объект интерфейса Comparator, который указывает, как сравнивать объекты:

Optional<T> min(Comparator<? super T> comparator)
Optional<T> max(Comparator<? super T> comparator)

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

Например, найдем минимальное и максимальное число в числовом потоке:

import java.util.stream.Stream;
import java.util.Optional;
import java.util.ArrayList;
import java.util.Arrays;
public class Program {
    public static void main(String[] args) {
        ArrayList<Integer> numbers = new ArrayList<Integer>();
        numbers.addAll(Arrays.asList(new Integer[]{1, 2, 3, 4, 5, 6, 7, 8, 9}));

        Optional<Integer> min = numbers.stream().min(Integer::compare);
        Optional<Integer> max = numbers.stream().max(Integer::compare);
        System.out.println(min.get()); // 1
        System.out.println(max.get()); // 9
    }
}

Интерфейс Comparator - это функциональный интерфейс, который определяет один метод compare(), принимающий два сравниваемых объекта и возвращающий число (если первый объект больше, возвращается положительное число, иначе возвращается отрицательное число). Поэтому вместо конкретной реализации компаратора мы можем передать лямбда-вырашение или метод, который соответствует методу compare интерфейса Comparator. Поскольку сравниваются числа, то в метод передается в качестве компаратора статический метод Integer.compare().

При этом методы min() и max() возвращают именно Optional, и чтобы получить непосредственно результат операции из Optional, необходимо вызвать метод get().

Рассмотрим более сложный случай, когда нам надо сравнивать более сложные объекты:

import java.util.stream.Stream;
import java.util.Optional;
import java.util.ArrayList;
import java.util.Arrays;
public class Program {
    public static void main(String[] args) {
        ArrayList<Phone> phones = new ArrayList<Phone>();
        phones.addAll(Arrays.asList(new Phone[] {
            new Phone("iPhone 8", 52000),
            new Phone("Nokia 9", 35000),
            new Phone("Samsung Galaxy S9", 48000),
            new Phone("HTC U12", 36000)
        }));

        Phone min = phones.stream().min(Phone::compare).get();
        Phone max = phones.stream().max(Phone::compare).get();
        System.out.printf("MIN Name: %s Price: %d \n", min.getName(), min.getPrice());
        System.out.printf("MAX Name: %s Price: %d \n", max.getName(), max.getPrice());
    }
}

class Phone {
    private String name;
    private int price;

    public Phone(String name, int price) {
        this.name = name;
        this.price = price;
    }

    public static int compare (Phone p1, Phone p2) {
        if(p1.getPrice() > p2.getPrice()) {
            return 1;
        }
        return -1;
    }

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }
}

В данном случае мы находим минимальный и максимальный объект Phone: фактически объекты с максимальной и минимальной ценой. Для определения функциональности сравнения в классе Phone реализован статический метод compare(), который соответствует сигнатуре метода compare интерфейса Comparator. И в методах min() и max() применяем этот статический метод для сравнения объектов.

Консольный вывод:

MIN Name: Nokia 9 Price: 35000
MAX Name: iPhone 8 Price: 52000

11. reduce()

Метод reduce() выполняет терминальные операции сведения, возвращая некоторое значение - результат операции. Он имеет следующие формы:

Optional<T> reduce(BinaryOperator<T> accumulator)
T reduce(T identity, BinaryOperator<T> accumulator)
U reduce(U identity, BiFunction<U,? super T,U> accumulator, BinaryOperator<U> combiner)

Первая форма возвращает результат в виде объекта Optional<T>. Например, вычислим произведение набора чисел:

import java.util.stream.Stream;
import java.util.Optional;

public class Program {
    public static void main(String[] args) {
        Stream<Integer> numbersStream = Stream.of(1, 2, 3, 4, 5, 6);
        Optional<Integer> result = numbersStream.reduce((x, y) -> x * y);
        System.out.println(result.get()); // 720
    }
}

Объект BinaryOperator<T> представляет функцию, которая принимает два элемента и выполняет над ними некоторую операцию, возвращая результат. При этом метод reduce() сохраняет результат и затем опять же применяет к этому результату и следующему элементу в наборе бинарную операцию. Фактически в данном случае мы получим результат, который будет равен: n1 op n2 op n3 op n4 op n5 op n6, где op - это операция (в данном случае умножения), а n1, n2, …​ - элементы из потока.

Затем с помощью метода get() мы можем получить собственно результат вычислений: result.get()

Или еще один пример - объединение слов в предложение:

Stream<String> wordsStream = Stream.of("мама", "мыла", "раму");
Optional<String> sentence = wordsStream.reduce((x, y) -> x + " " + y);
System.out.println(sentence.get());

Если нам надо, чтобы первым элементом в наборе было какое-то определенное значение, то мы можем использовать вторую версию метода reduce(), которая в качестве первого параметра принимает T identity. Этот параметр хранит значение, с которого будет начинаться цепочка бинарных операций. Например:

Stream<String> wordsStream = Stream.of("мама", "мыла", "раму");
String sentence = wordsStream.reduce("Результат:", (x, y) -> x + " " + y);
System.out.println(sentence); // Результат: мама мыла раму

Фактически здесь выполняется следующая цепь операций: identity op n1 op n2 op n3 op n4…​

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

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

class Phone {
    private String name;
    private int price;

    public Phone(String name, int price) {
        this.name = name;
        this.price = price;
    }

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }
}

И мы хотим найти сумму цен тех телефонов, у которых цена меньше определенного значения. Для этого используем третью версию метода reduce():

Stream<Phone> phoneStream = Stream.of(
    new Phone("iPhone 6 S", 54000),
    new Phone("Lumia 950", 45000),
    new Phone("Samsung Galaxy S 6", 40000),
    new Phone("LG G 4", 32000)
);

int sum = phoneStream.reduce(0,
            (x, y) -> (y.getPrice() < 50000) ? x + y.getPrice() : x + 0,
            (x, y) -> x + y);

System.out.println(sum); // 117000

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

12. Класс Optional

Ряд операций сведения, такие как min(), max(), reduce(), возвращают объект Optional<T>. Этот объект фактически обертывает результат операции. После выполнения операции с помощью метода get() объекта Optional мы можем получить его значение:

import java.util.Optional;
import java.util.ArrayList;
import java.util.Arrays;
public class Program {
    public static void main(String[] args) {
        ArrayList<Integer> numbers = new ArrayList<Integer>();
        numbers.addAll(Arrays.asList(new Integer[] {1, 2, 3, 4, 5, 6, 7, 8, 9}));
        Optional<Integer> min = numbers.stream()
            .min(Integer::compare);
        System.out.println(min.get()); // 1
    }
}

Но что, если поток не содержит вообще никаких данных:

// список numbers пустой
ArrayList<Integer> numbers = new ArrayList<Integer>();
Optional<Integer> min = numbers.stream()
    .min(Integer::compare);
System.out.println(min.get());  // java.util.NoSuchElementException

В этом случае программа выдаст исключение java.util.NoSuchElementException. Что мы можем сделать, чтобы избежать выброса исключения? Для этого класс Optional предоставляет ряд методов.

Самой простой способ избежать подобной ситуации - это предварительная проверка наличия значения в Optional с помощью метода isPresent(). Он возврашает true, если значение присутствует в Optional, и false, если значение отсутствует:

ArrayList<Integer> numbers = new ArrayList<Integer>();
Optional<Integer> min = numbers.stream()
    .min(Integer::compare);
if (min.isPresent()) {
    System.out.println(min.get());
}

12.1. orElse()

Метод orElse() позволяет определить альтернативное значение, которое будет возвращаться, если Optional не получит из потока какого-нибудь значения:

// пустой список
ArrayList<Integer> numbers = new ArrayList<Integer>();
Optional<Integer> min = numbers.stream()
    .min(Integer::compare);
System.out.println(min.orElse(-1)); // -1

// непустой список
numbers.addAll(Arrays.asList(new Integer[] {4, 5, 6, 7, 8, 9}));
min = numbers.stream()
    .min(Integer::compare);
System.out.println(min.orElse(-1)); // 4

12.1.1. orElseGet()

Метод orElseGet() позволяет задать функцию, которая будет возвращать значение по умолчанию:

import java.util.Optional;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Random;

public class Program {
    public static void main(String[] args) {
        ArrayList<Integer> numbers = new ArrayList<Integer>();
        Optional<Integer> min = numbers.stream()
            .min(Integer::compare);
        Random rnd = new Random();
        System.out.println(min.orElseGet(() -> rnd.nextInt(100)));
    }
}

В данном случае возвращаемое значение генерируется с помощью метода nextInt() класса Random, который возвращает случайное число.

12.2. orElseThrow()

Еще один метод - orElseThrow() позволяет сгенерировать исключение, если Optional не содержит значения:

ArrayList<Integer> numbers = new ArrayList<Integer>();
Optional<Integer> min = numbers.stream()
    .min(Integer::compare);
// генеррация исключения IllegalStateException
System.out.println(min.orElseThrow(IllegalStateException::new));

12.3. ifPresent()

Метод ifPresent() определяет действия со значением в Optional, если значение имеется:

ArrayList<Integer> numbers = new ArrayList<Integer>();
numbers.addAll(Arrays.asList(new Integer[]{4,5,6,7,8,9}));
Optional<Integer> min = numbers.stream().min(Integer::compare);
min.ifPresent(v->System.out.println(v)); // 4

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

12.4. ifPresentOrElse()

Метод ifPresentOrElse() позволяет определить альтернативную логику на случай, если значение в Optional отсутствует:

ArrayList<Integer> numbers = new ArrayList<Integer>();
Optional<Integer> min = numbers.stream()
    .min(Integer::compare);
min.ifPresentOrElse(v -> System.out.println(v), () -> System.out.println("Value not found"));

В метод ifPresentOrElse() передается две функции. Первая обрабатывает значение в Optional, если оно присутствует. Вторая функция представляет действия, которые выполняются, если значение в Optional отсутствует.

13. Stream.collect()

Большинство операций класса Stream, которые модифицируют набор данных, возвращают этот набор в виде потока. Однако бывают ситуации, когда хотелось бы получить данные не в виде потока, а в виде обычной коллекции, например, ArrayList или HashSet. И для этого у класса Stream определен метод collect. Первая версия метода принимает в качестве параметра функцию преобразования к коллекции:

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

Параметр R представляет тип результата метода, параметр Т - тип элемента в потоке, а параметр А - тип промежуточных накапливаемых данных. В итоге параметр collector представляет функцию преобразования потока в коллекцию.

Эта функция представляет объект Collector, который определен в пакете java.util.stream. Мы можем написать свою реализацию функции, однако Java уже предоставляет ряд встроенных функций, определенных в классе Collectors:

  • toList() преобразование к типу List

  • toSet() преобразование к типу Set

  • toMap() преобразование к типу Map

Например, преобразуем набор в потоке в список:

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

public class Program {
    public static void main(String[] args) {
        List<String> phones = new ArrayList<String>();
        Collections.addAll(phones, "iPhone 8", "HTC U12", "Huawei Nexus 6P",
                "Samsung Galaxy S9", "LG G6", "Xiaomi MI6", "ASUS Zenfone 2",
                "Sony Xperia Z5", "Meizu Pro 6", "Lenovo S850");
        List<String> filteredPhones = phones.stream()
                .filter(s -> s.length() < 10)
                .collect(Collectors.toList());

        for(String s : filteredPhones) {
            System.out.println(s);
        }
    }
}

Использование метода toSet() аналогично.

Set<String> filteredPhones = phones.stream()
                .filter(s -> s.length() < 10)
                .collect(Collectors.toSet());

Для применения метода toMap() надо задать ключ и значение. Например, пусть у нас есть следующая модель:

class Phone {
    private String name;
    private int price;

    public Phone(String name, int price) {
        this.name = name;
        this.price = price;
    }

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }
}

Теперь применим метод toMap():

import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class Program {
    public static void main(String[] args) {
        Stream<Phone> phoneStream = Stream.of(new Phone("iPhone 8", 54000),
            new Phone("Nokia 9", 45000),
            new Phone("Samsung Galaxy S9", 40000),
            new Phone("LG G6", 32000));


        Map<String, Integer> phones = phoneStream
            .collect(Collectors.toMap(p -> p.getName(), t -> t.getPrice()));

        phones.forEach((k, v) -> System.out.println(k + " " + v));
    }
}
class Phone {
    private String name;
    private int price;

    public Phone(String name, int price) {
        this.name=name;
        this.price=price;
    }

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }
}

Лямбда-выражение p → p.getName() получает значение для ключа элемента, а t → t.getPrice() - извлекает значение элемента.

Если нам надо создать какой-то определенный тип коллекции, например, HashSet, то мы можем использовать специальные функции, которые определены в классах-коллекций. Например, получим объект HashSet:

import java.util.TreeSet;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class Program {
    public static void main(String[] args) {
        Stream<String> phones = Stream.of("iPhone 8", "HTC U12", "Huawei Nexus 6P",
                "Samsung Galaxy S9", "LG G6", "Xiaomi MI6", "ASUS Zenfone 2",
                "Sony Xperia Z5", "Meizu Pro 6", "Lenovo S850");

        TreeSet<String> filteredPhones = phones.filter(s -> s.length() < 12).
                                    collect(Collectors.toCollection(TreeSet::new));

        filteredPhones.forEach(s -> System.out.println(s));
    }
}

Выражение HashSet::new представляет функцию создания коллекции. Аналогичным образом можно получать другие коллекции, например, ArrayList:

ArrayList<String> result = phones.collect(Collectors.toCollection(ArrayList::new));

Вторая форма метода <R> R collect(Supplier<R> supplier, BiConsumer<R,? super T> accumulator, BiConsumer<R,R> combiner) имеет три параметра:

  • supplier создает объект коллекции

  • accumulator добавляет элемент в коллекцию

  • combiner бинарная функция, которая объединяет два объекта

Применим эту версию метода collect():

import java.util.ArrayList;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class Program {
    public static void main(String[] args) {
        Stream<String> phones = Stream.of("iPhone 8", "HTC U12", "Huawei Nexus 6P",
                "Samsung Galaxy S9", "LG G6", "Xiaomi MI6", "ASUS Zenfone 2",
                "Sony Xperia Z5", "Meizu Pro 6", "Lenovo S850");

        ArrayList<String> filteredPhones = phones.filter(s -> s.length() < 12)
            .collect(
                ()-> new ArrayList<String>(), // создаем ArrayList
                (list, item) -> list.add(item), // добавляем в список элемент
                (list1, list2) -> list1.addAll(list2)); // добавляем в список другой список

        filteredPhones.forEach(s -> System.out.println(s));
    }
}

14. Класс Collectors

14.1. groupingBy()

Чтобы сгруппировать данные по какому-нибудь признаку, нам надо использовать в связке метод collect() объекта Stream и метод Collectors.groupingBy(). Допустим, у нас есть следующий класс:

class Phone {
    private String name;
    private String company;
    private int price;

    public Phone(String name, String comp, int price) {
        this.name = name;
        this.company = comp;
        this.price = price;
    }

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }

    public String getCompany() {
        return company;
    }
}

И, к примеру, у нас есть набор объектов Phone, которые мы хотим сгруппировать по компании:

import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import java.util.stream.Collectors;

public class Program {
    public static void main(String[] args) {
        Stream<Phone> phoneStream = Stream.of(
            new Phone("iPhone X", "Apple", 600),
            new Phone("Pixel 2", "Google", 500),
            new Phone("iPhone 8", "Apple",450),
            new Phone("Galaxy S9", "Samsung", 440),
            new Phone("Galaxy S8", "Samsung", 340)
        );

        Map<String, List<Phone>> phonesByCompany = phoneStream.collect(
                Collectors.groupingBy(Phone::getCompany));

        for(Map.Entry<String, List<Phone>> item : phonesByCompany.entrySet()) {
            System.out.println(item.getKey());
            for(Phone phone : item.getValue()) {
                System.out.println(phone.getName());
            }
            System.out.println();
        }
    }
}

Консольный вывод:

Google
Pixel 2

Apple
iPhone X
iPhone 8

Samsung
Galaxy S9
Galaxy S8

Итак, для создания групп в метод phoneStream.collect() передается вызов функции Collectors.groupingBy(), которая с помощью выражения Phone::getCompany группирует объекты по компании. В итоге будет создан объект Map, в котором ключами являются названия компаний, а значениями - список связанных с компаниями телефонов.

14.2. partitioningBy()

Метод Collectors.partitioningBy() имеет похожее действие, только он делит элементы на группы по принципу, соответствует ли элемент определенному условию. Например:

Map<Boolean, List<Phone>> phonesByCompany = phoneStream.collect(
                Collectors.partitioningBy(p -> p.getCompany() == "Apple"));

for(Map.Entry<Boolean, List<Phone>> item : phonesByCompany.entrySet()) {
    System.out.println(item.getKey());
    for(Phone phone : item.getValue()) {
        System.out.println(phone.getName());
    }
    System.out.println();
}

В данном случае с помощью условия p → p.getCompany() == "Apple" мы смотрим, принадлежит ли телефон компании Apple. Если телефон принадлежит этой компании, то он попадает в одну группу, если нет, то в другую.

14.3. counting()

Метод Collectors.counting() применяется в Collectors.groupingBy() для вычисления количества элементов в каждой группе:

Map<String, Long> phonesByCompany = phoneStream.collect(
        Collectors.groupingBy(Phone::getCompany, Collectors.counting()));

for(Map.Entry<String, Long> item : phonesByCompany.entrySet()) {
    System.out.println(item.getKey() + " - " + item.getValue());
}

Консольный вывод:

Google -1
Apple - 2
Samsung - 2

14.4. summing()

Метод Collectors.summing() применяется для подсчета суммы. В зависимости от типа данных, к которым применяется метод, он имеет следующие формы: summingInt(), summingLong(), summingDouble(). Применим этот метод для подсчета стоимости всех смартфонов по компаниям:

Map<String, Integer> phonesByCompany = phoneStream.collect(
        Collectors.groupingBy(Phone::getCompany, Collectors.summingInt(Phone::getPrice)));

for(Map.Entry<String, Integer> item : phonesByCompany.entrySet()) {
    System.out.println(item.getKey() + " - " + item.getValue());
}

С помощью выражения Collectors.summingInt(Phone::getPrice)) мы указываем, что для каждой компании будет вычислять совокупная цена всех ее смартфонов. И поскольку вычисляется результат - сумма для значений типа int, то в качестве типа возвращаемой коллекции используется тип Map<String, Integer>

Консольный вывод:

Google - 500
Apple - 1050
Samsung - 780

14.5. maxBy() и minBy()

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

Map<String, Optional<Phone>> phonesByCompany = phoneStream.collect(
        Collectors.groupingBy(Phone::getCompany,
                Collectors.minBy(Comparator.comparing(Phone::getPrice))));

for(Map.Entry<String, Optional<Phone>> item : phonesByCompany.entrySet()) {
    System.out.println(item.getKey() + " - " + item.getValue().get().getName());
}

Консольный вывод:

Google - Pixel 2
Apple - iPhone 8
Samsung - Galaxy S8

В качестве возвращаемого значения операции группировки используется объект Map<String, Optional<Phone>>. Опять же поскольку группируем по компаниям, то ключом будет выступать строка, а значением - объект Optional<Phone>.

14.6. summarizing()

Методы summarizingInt() / summarizingLong() / summarizingDouble() позволяют объединить в набор значения соответствующих типов:

Map<String, java.util.IntSummaryStatistics> priceSummary = phoneStream.collect(
    Collectors.groupingBy(Phone::getCompany,
        Collectors.summarizingInt(Phone::getPrice)));

for(Map.Entry<String, java.util.IntSummaryStatistics> item : priceSummary.entrySet()) {
    System.out.println(item.getKey() + " - " + item.getValue().getAverage());
}

Метод Collectors.summarizingInt(Phone::getPrice)) создает набор, в который помещаются цены для всех телефонов каждой из групп. Данный набор инкапсулируется в объекте IntSummaryStatistics. Соответственно если бы мы применяли методы summarizingLong() или summarizingDouble(), то соответственно бы получали объекты LongSummaryStatistics или DoubleSummaryStatistics.

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

  • getAverage() возвращает среднее значение

  • getCount() возвращает количество элементов в наборе

  • getMax() возвращает максимальное значение

  • getMin() возвращает минимальное значение

  • getSum() возвращает сумму элементов

  • accept() добавляет в набор новый элемент

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

Консольный вывод:

Google - 500.0
Apple - 525.0
Samsung - 390.0

14.7. mapping()

Метод mapping() позволяет дополнительно обработать данные и задать функцию отображения объектов из потока на какой-нибудь другой тип данных. Например:

Map<String, List<String>> phonesByCompany = phoneStream.collect(
    Collectors.groupingBy(Phone::getCompany,
    Collectors.mapping(Phone::getName, Collectors.toList())));

for(Map.Entry<String, List<String>> item : phonesByCompany.entrySet()) {
    System.out.println(item.getKey());
    for(String name : item.getValue()) {
        System.out.println(name);
    }
}

Выражение Collectors.mapping(Phone::getName, Collectors.toList()) указывает, что в группу будут выделятся названия смартфонов, причем группа будет представлять объект List.

15. Параллельные потоки

Кроме последовательных потоков Stream API поддерживает параллельные потоки. Распараллеливание потоков позволяет задействовать несколько ядер процессора (если целевая машина многоядерная) и тем самым может повысить производительность и ускорить вычисления. В то же время говорить, что применение параллельных потоков на многоядерных машинах однозначно повысит производительность - не совсем корректно. В каждом конкретном случае надо проверять и тестировать.

Чтобы сделать обычный последовательный поток параллельным, надо вызвать у объекта Stream метод parallel(). Кроме того, можно также использовать метод parallelStream() интерфейса Collection для создания параллельного потока из коллекции.

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

Применение параллельных потоков во многих случаях будет аналогично. Например:

import java.util.Optional;
import java.util.stream.Stream;

public class Program {
    public static void main(String[] args) {
        Stream<Integer> numbersStream = Stream.of(1, 2, 3, 4, 5, 6);
        Optional<Integer> result = numbersStream.parallel().reduce((x, y) -> x * y);
        System.out.println(result.get()); // 720
    }
}

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

Stream<String> wordsStream = Stream.of("мама", "мыла", "раму");
String sentence = wordsStream.parallel().reduce("Результат:", (x, y) -> x + " " + y);
System.out.println(sentence);

Результатом этой функции будет консольный вывод:

Результат: мама Результат: мыла Результат: раму

15.1. sequential()

Данный вывод не является правильным. Если же мы не уверены, что на каком-то этапе работы с параллельным потоком он адекватно сможет выполнить какую-нибудь операцию, то мы можем преобразовать этот поток в последовательный посредством вызова метода sequential():

Stream<String> wordsStream = Stream.of("мама", "мыла", "раму", "hello world");
String sentence = wordsStream.parallel()
        .filter(s->s.length()<10) // фильтрация над параллельным потоком
        .sequential()
        .reduce("Результат:", (x, y) -> x + " " + y); // операция над последовательным потоком
System.out.println(sentence);

И возьмем другой пример:

Stream<Integer> numbersStream = Stream.of(1, 2, 3, 4, 5, 6);
Integer result = numbersStream.parallel().reduce(1, (x, y) -> x * y);
System.out.println(result);

Фактически здесь происходит перемножение чисел. При этом нет разницы между 1 * 2 * 3 * 4 * (5 * 6) или 5 * 6 * 1 * (2 * 3) * 4. Мы можем расставить скобки любым образом, разместить последовательность чисел в любом порядке, и все равно мы получим один и тот же результат. То есть данная операция является ассоциативной и поэтому может быть распараллелена.

15.2. Вопросы производительности в параллельных операциях

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

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

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

Чем проще структура данных, с которой работает поток, тем быстрее будут происходить операции. Например, данные из ArrayList легко использовать, так как структура данной коллекции предполагает последовательность несвязанных данных. А вот коллекция типа LinkedList - не лучший вариант, так как в последовательном списке все элементы связаны с предыдущими/последующими. И такие данные трудно распараллелить.

Над данными примитивных типов операции будут производиться быстрее, чем над объектами классов

15.3. Упорядоченность в параллельных потоках

Как правило, элементы передаются в поток в том же порядке, в котором они определены в источнике данных. При работе с параллельными потоками система сохраняет порядок следования элементов. Исключение составляет метод forEach(), который может выводить элементы в произвольном порядке. И чтобы сохранить порядок следования, необходимо применять метод forEachOrdered():

phones.parallelStream()
    .sorted()
    .forEachOrdered(s -> System.out.println(s));

Сохранение порядка в параллельных потоках увеличивает издержки при выполнении. Но если нам порядок не важен, то мы можем отключить его сохранение и тем самым увеличить производительность, использовав метод unordered():

phones.parallelStream()
    .sorted()
    .unordered()
    .forEach(s -> System.out.println(s));

16. Параллельные операции над массивами

В JDK 8 к классу Arrays было добавлено ряд методов, которые позволяют в параллельном режиме совершать обработку элементов массива. И хотя данные методы формально не входят в Stream API, но реализуют схожую функциональность, что и параллельные потоки:

  • parallelSetAll() устанавливает элементы массива с помощью лямбда-выражения

  • parallelSort() сортирует массив

  • parallelPrefix() вычисляет некоторое значение для элементов массива (например, сумму элементов)

16.1. parallelSetAll()

Используем метод parallelSetAll() для установки элементов массива:

import java.util.Arrays;
public class Program {
    public static void main(String[] args) {
        int[] numbers = initializeArray(6);
        for(int i : numbers) {
            System.out.println(i);
        }
    }

    public static int[] initializeArray(int size) {
        int[] values = new int[size];
        Arrays.parallelSetAll(values, i -> i * 10);
        return values;
    }
}

В метод Arrays.parallelSetAll() передается два параметра: изменяемый массив и функция, которая устанавливает элементы массива. Эта функция перебирает все элементы и в качестве параметра получает индекс текущего перебираемого элемента. Выражение i → i * 10 означает, что по каждому индексу в массиве будет хранится число, равное i * 10. В итоге мы получим следующий вывод:

0
10
20
30
40
50

Рассмотрим более сложный пример. Пусть у нас есть следующий класс Phone:

class Phone {
    private String name;
    private int price;

    public Phone(String name, int price) {
        this.name = name;
        this.price = price;
    }

    public String getName() {
        return name;
    }

    public void setName(String val) {
        this.name = val;
    }

    public int getPrice() {
        return price;
    }

    public void setPrice(int val) {
        this.price = val;
    }
}

Теперь произведем манипуляции с массивом объектов Phone:

Phone[] phones = new Phone[] {
    new Phone("iPhone 8", 54000),
    new Phone("Pixel 2", 45000),
    new Phone("Samsung Galaxy S9", 40000),
    new Phone("Nokia 9", 32000)
};

Arrays.parallelSetAll(phones, i -> {
    phones[i].setPrice(phones[i].getPrice() - 10000);
    return phones[i];
});

for(Phone p : phones) {
    System.out.printf("%s - %d \n", p.getName(), p.getPrice());
}

Теперь лямбда-выражение в методе Arrays.parallelSetAll() представляет блок кода. И так как лямбда-выражение должно возвращать объект, то нам надо явным образом использовать оператор return. В этом лямбда-выражении опять же функция получает индексы перебираемых элементов, и по этим индексам мы можем обратиться к элементам массива и их изменить. Конкретно в данном случае происходит уменьшение цены смартфонов на 10000 единиц. В итоге мы получим следующий консольный вывод:

iPhone 8 - 44000
Pixel 2 - 35000
Samsung Galaxy S9 - 30000
Nokia 9 - 22000

16.2. parallelSort()

Отсортируем массив чисел в параллельном режиме:

int[] nums = {30, -4, 5, 29, 7, -8};
Arrays.parallelSort(nums);
for(int i : nums) {
    System.out.println(i);
}

Метод Arrays.parallelSort() в качестве параметра принимает массив и сортирует его по возрастанию:

-8
-4
5
7
29
30

Если же нам надо как-то по-другому отсортировать объекты, например, по модулю числа, или у нас более сложные объекты, то мы можем создать свой компаратор и передать его в качестве второго параметра в Arrays.parallelSort(). Например, возьмем выше определенный класс Phone и создадим для него компаратор:

import java.util.Arrays;
import java.util.Comparator;
public class Program {
    public static void main(String[] args) {
        Phone[] phones = new Phone[]{new Phone("iPhone 8", 54000),
        new Phone("Pixel 2", 45000),
        new Phone("Samsung Galaxy S9", 40000),
        new Phone("Nokia 9", 32000)};

        Arrays.parallelSort(phones,new PhoneComparator());

        for(Phone p : phones) {
        System.out.println(p.getName());
        }
    }
}

class PhoneComparator implements Comparator<Phone> {
    public int compare(Phone a, Phone b) {
        return a.getName().toUpperCase().compareTo(b.getName().toUpperCase());
    }
}

16.3. parallelPrefix()

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

int[] numbers = {1, 2, 3, 4, 5, 6};
Arrays.parallelPrefix(numbers, (x, y) -> x * y);

for(int i : numbers) {
    System.out.println(i);
}

Мы получим следующий результат:

1
2
6
24
120
720

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