Java-потоки 24. Уменьшить

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

В этом посте мы рассмотрим терминальную операцию reduce(), которая имеет три перегруженные версии:

  • Optional‹T›reduce(BinaryOperator‹T›accumulator) — накапливает элементы потока, используя указанную функцию, и возвращает результирующее значение, если оно есть, заключенное в Optional объект.
  • T reduce(T identity, BinaryOperator‹T› аккумулятор) — накапливает элементы потока, используя указанное значение идентификатора и функцию-аккумулятор, и возвращает результирующее значение, которое может быть просто указанным значением идентификатора.
  • U reduce(U identity, BiFunction‹U, T, U› аккумулятор, BinaryOperator‹U› Combiner) — накапливает элементы потока, используя указанное значение идентификатора и функцию-аккумулятор, и возвращает результирующее значение, который может быть просто указанным значением идентификатора. В случае параллельного потока использует указанную функцию объединения для включения результатов всех подпроцессов в возвращаемое результирующее значение.

Во-первых, давайте посмотрим, что такое BinaryOperator‹T› и что такое BiFunction‹U, T, U›.

BinaryOperator‹T› и BiFunction‹U, T, U›

BinaryOperator‹T› и BiFunction‹U, T, U› — это функциональные интерфейсы. Это означает, что каждый из них имеет только один абстрактный метод. У них также есть неабстрактные методы, которые мы обсудим позже (они не существенны для нашего обсуждения метода reduce()), так что пока давайте сосредоточимся на том факте, что у них есть только один абстрактный метод каждый.

Эти два функциональных интерфейса связаны между собой: BinaryOperator‹T› расширяет интерфейс BiFunction‹U, T, U›. Это означает, что абстрактный метод интерфейса BiFunction‹U, T, U› наследуется интерфейсом BinaryOperator‹T›. Именно так BinaryOperator‹T› получает свой единственный абстрактный метод. Это означает, что эти функциональные интерфейсы используют абстрактный метод.

В интерфейсе BiFunction‹U, T, U› абстрактный метод принимает два параметра типа U и T и возвращает значение типа Н.

Например, вот две (из многих) возможных реализации интерфейса BiFunction‹U, T, U›:

BiFunction<String, Integer, String> bf1 = 
        (String s, Integer i) -> {
            String r1 = s == null ? "" : s;
            String r2 = i == null ? "0" : i.toString();
            return r1 + ", " + r2;
        };
  BiFunction<Integer, String, Integer> bf2 =
        (Integer i, String s) -> {
            Integer r1 = i == null ? 0 : i;
            Integer r2 = s == null ? 0 : s.length();
            return r1 + r2;
        };

Абстрактный метод определяется в BiFunction‹U, T, U› следующим образом:

  • U apply(T t, U u). Применяет эту функцию к заданным аргументам.

Это означает, что мы можем выполнить функции bf1 и bf2, вызвав метод apply(), как показано ниже:

System.out.println(bf1.apply("abc", 42));   //prints: abc, 42
  System.out.println(bf1.apply(null, 42));    //prints: , 42
  System.out.println(bf1.apply("abc", null)); //prints: abc, 0
  
  System.out.println(bf2.apply(42, "abc"));   //prints: 45
  System.out.println(bf2.apply(null, "abc")); //prints: 3
  System.out.println(bf2.apply(42, null));    //prints: 42

Интерфейс BinaryOperator‹T› является специализацией BiFunction‹U, T, U›. Он определен так, что типы двух входных параметров его абстрактного метода совпадают. Они также равны типу выходного параметра. Именно поэтому его подпись включает только один тип. Это означает, что абстрактный метод интерфейса BinaryOperator‹T› определяется следующим образом:

  • T применить(T t1, T t2). Применяет эту функцию к заданным аргументам.

Ниже приведены две (из многих) возможных реализации интерфейса BinaryOperator‹T›:

  BinaryOperator<String> bo1 = 
        (String s1, String s2) -> s1 + ", " + s2;
  System.out.println(bo1.apply("abc", "42")); //prints: abc, null
  System.out.println(bo1.apply(null, "42"));  //prints: null, 42
  System.out.println(bo1.apply("abc", null)); //prints: abc, null
  BinaryOperator<Integer> bo2 =
        (Integer i1, Integer i2) -> {
            Integer r1 = i1 == null ? 0 : i1;
            Integer r2 = i2 == null ? 0 : i2;
            return r1 + r2;
        };
  System.out.println(bo2.apply(42, 42));     //prints: 84
  System.out.println(bo2.apply(null, 42));   //prints: 42
  System.out.println(bo2.apply(42, null));   //prints: 42

Метод по умолчанию andThen() является единственным неабстрактным методом интерфейса BiFunction‹U, T, U›:

  • БиФункция по умолчанию‹U, T, V› и Затем (Функция‹U, V› после). Возвращает составную функцию, которая сначала применяет BiFunction‹U, T, U› к своим входным данным, а затем применяет последующую функцию Function‹U, V› к результату. Этот метод используется для создания новой BiFunction из существующих функций.

Интерфейс BinaryOperator‹T› расширяет интерфейс BiFunction‹U, T, U› (как и все его методы) и, кроме того, имеет еще два неабстрактных метода. maxBy() и minBy():

  • static BinaryOperator‹T› maxBy(Comparator‹T› comparator). Возвращает BinaryOperator, который возвращает больший из двух элементов в соответствии с указанным Comparator;
  • static BinaryOperator‹T› minBy(Comparator‹T› comparator). Возвращает BinaryOperator, который возвращает меньший из двух элементов в соответствии с указанным Comparator.

Отбросив функции, давайте рассмотрим первую из версий операции reduce().

Необязательное сокращение T› (аккумулятор BinaryOperator‹T›)

Чтобы продемонстрировать операцию reduce(), мы собираемся использовать класс Box:

class Box {
    int weight;
    String color;
    public Box(int weight, String color) {
        this.weight = weight;
        this.color = color;
    }
    public int getWeight() { return weight; }
    public String getColor() { return color; }
    @Override
    public String toString() {
        return "Box{weight=" + weight +
                    ", color='" + color + "'}";
    }
  }

Найдем самый «тяжелый» объект Box, используя первую версию reduce():

Box theHeaviest = Stream.of(new Box(5, "red"), 
                              new Box(8, "green"), 
                              new Box(3, "blue"))
     .reduce((b1, b2) -> 
               b1.getWeight() > b2.getWeight() ? b1 : b2)
     .orElse(null);
  System.out.print(theHeaviest);
                   //prints: Box{weight=8, color='green'}

Реализация выглядит не интуитивно понятно, не так ли? Вроде как аккумулятор ничего не накапливает. Его метод apply() принимает первые два элемента потока, сравнивает их и возвращает более «тяжелый». Затем он принимает результат (в качестве первого параметра) и третий элемент потока (в качестве второго параметра) и возвращает более «тяжелый».

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

Мы могли бы реализовать ту же функциональность, создав сначала аккумулятор BinaryOperator‹Box› следующим образом:

BinaryOperator<Box> maxByWeight = 
      (b1, b2) -> b1.getWeight() > b2.getWeight() ? b1 : b2;
  Box theHeaviest = Stream.of(new Box(5, "red"), 
                              new Box(8, "green"), 
                              new Box(3, "blue"))
           .reduce(maxByWeight)
          .orElse(null);
  System.out.print(theHeaviest);  
                     //prints: Box{weight=8, color='green'}

Мы также можем использовать метод maxBy() оператора BinaryOperator, чтобы создать нужную нам функцию:

BinaryOperator<Box> maxByWeight = 
       BinaryOperator.maxBy(Comparator
                                .comparing(Box::getWeight));

Результат был бы таким же.

Но это не единственный способ использования функции аккумулятора. В следующем примере мы фактически накапливаем (добавляем к сумме) веса всех объектов Box:

int totalWeight = Stream.of(new Box(5, "red"), 
                              new Box(8, "green"), 
                              new Box(3, "blue"))
        .map(b -> b.getWeight())
        .reduce((w1, w2) -> w1 + w2)
        .orElse(null);
  System.out.print(totalWeight);     //prints: 16

Или, еще один пример фактического накопления, мы можем объединить все цвета ящиков в одно значение String, разделенное пробелами:

String colors = Stream.of(new Box(5, "red"), 
                            new Box(8, "green"), 
                            new Box(3, "blue"))
        .map(p -> p.getColor())
        .reduce((c1, c2) -> c1 + " " + c2)
        .orElse(null);
  System.out.print(colors);    //prints: red green blue

Ниже приведен тот же пример, но с результатом String, разделенным запятыми:

String colors = Stream.of(new Box(5, "red"), 
                            new Box(8, "green"), 
                            new Box(3, "blue"))
        .map(p -> p.getColor())
        .reduce((c1, c2) -> c1 + ", " + c2)
        .orElse(null);
  System.out.print(colors);    //prints: red, green, blue

Каждый программист, который пытался сгенерировать String со значениями, разделенными запятыми, может оценить, насколько проще это можно сделать с помощью Stream и reduce(), чем в традиционном цикле FOR:

List<Box> list = List.of(new Box(5, "red"), 
                           new Box(8, "green"), 
                           new Box(3, "blue"));
  StringBuffer sb = new StringBuffer();
  int count = 1;
  for(Box b: list){
    sb.append(b.getColor());
    if(count < list.size()){
        sb.append(", ");
    }
    count++;
  }
  System.out.print(sb.toString());  //prints: red, green, blue

В следующем посте мы представим операцию collect() и продемонстрируем еще более простой способ получения того же результата.

T reduce(T identity, BinaryOperator‹T› аккумулятор

Вторая версия операции reduce() позволяет добавить identity в качестве начального значения конечного результата. Это гарантирует, что результат не будет null, поэтому возвращаемый результат не будет заключен в Необязательный объект. Например:

int totalWeight5 = Stream.of(new Box(5, "red"), 
                               new Box(8, "green"), 
                               new Box(3, "blue"))
        .map(b -> b.getWeight())
        .reduce(10, (w1, w2) -> w1 + w2);
  System.out.print(totalWeight5);      //prints: 26

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

String colors = Stream.of(new Box(5, "red"), 
                            new Box(8, "green"), 
                            new Box(3, "blue"))
        .map(p -> p.getColor())
        .reduce("Colors: ", (c1, c2) -> c1 + " " + c2);
  System.out.print(colors); 
                 //prints: Colors: red green blue

Обратите внимание на пробел после «Цвета:» в результирующей строке. Это потому, что результатом обработки первого элемента является «Цвета:» + » » + «a». И опять же, операция collect() (которую мы представим в следующем посте) обеспечивает гораздо более простой способ выполнить все это.

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

U reduce(идентификация U, накопитель BiFunction‹U,T,U›, объединитель BinaryOperator‹U›)

В случае параллельного потока значение идентификатора может привести к неожиданному результату:

String colors = Stream.of(new Box(5, "red"), 
                            new Box(8, "green"), 
                            new Box(3, "blue"))
        .parallel()
        .map(p -> p.getColor())
        .reduce("Colors:", (c1, c2) -> c1 + " " + c2, 
                           (r1, r2) -> r1 + " " + r2);
  System.out.print(colors); 
        //prints: Colors: red Colors: green Colors: blue

Как видите, префикс «Цвета:» добавляется к каждому обрабатываемому значению, поэтому нам нужно удалить его в объединителе (если мы хотим видеть только один префикс в результирующей строке):

String colors = Stream.of(new Box(5, "red"), 
                           new Box(8, "green"), 
                           new Box(3, "blue"))
        .parallel()
        .map(p -> p.getColor())
        .reduce("Colors:", (c1, c2) -> c1 + " " + c2, 
             (r1, r2) -> r1 + " " + r2.replace("Colors: ", ""));
  System.out.print(colors);     //prints: Colors: red green blue

Обратите внимание, что мы включили пробел в удаленную подстроку «Цвета:».

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

int sum = Stream.of(1, 2, 3)
        .parallel()
        .reduce(0, (i1, i2) -> i1 + i2, 
                   (r1, r2) -> r1 + r2);
   System.out.print(sum);     //prints: 6

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

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

int sum = Stream.of(1, 2, 3)
        .parallel()
        .reduce(0, Integer::sum, Integer::sum);
  System.out.print(sum);    //prints: 6

Естественно, возникает вопрос: Использование значения identity весьма ограничено? Если нет, то какие случаи оправдывают его присутствие?

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

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

BiFunction<List<String>, String, List<String>> accumulator =
        (l, s) -> {
            l.add(s);
            return l;
        };
  BinaryOperator<List<String>> combiner =
        (l1, l2) -> {
            //Does not do anything except printing:
            System.out.println("In combiner!");
            return l1;
        };
  List<String> lst = Stream.of("a", "b", "c", "d", "e")
        .reduce(new ArrayList<>(), accumulator, combiner);
  System.out.print(lst);            //prints: [a, b, c, d, e]

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

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

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

В следующей позиции t мы начнем представлять последнюю из операций терминала, называемую операцией collect(). Это удобная специализация reduce(). Мы опишем и продемонстрируем две перегруженные версии collect():

--R collect(Коллектор‹T, A, R› коллектор);

--R collect(Поставщик‹R› поставщик, BiConsumer‹R, T› аккумулятор, BiConsumer‹R, R› объединитель);

Смотрите другие сообщения о потоках Java 8 и сообщения на другие темы.