Когда и как выполнять преобразование "один в 0..n" Stream mapMulti поверх flatMap начиная с Java 16

Я бегло просматривал новости и исходный код Java 16, и я столкнулся с новым методом Stream под названием mapMulti. В JavaDoc для раннего доступа говорится, что он похож на flatMap и уже утвержден для той же версии Java.

       <R> Stream<R> mapMulti​(BiConsumer<? super T,​? super Consumer<R>> mapper)
  • Как с помощью этого метода выполнить отображение один на 0..n?
  • Как работает новый метод и чем он отличается от flatMap. Когда каждый из них предпочтительнее?
  • Сколько раз Consumer mapper можно назвать?

2 ответа

Решение

Stream::mapMultiэто новый метод, который классифицируется как промежуточная операция.

Это требует BiConsumer<T, Consumer<R>> mapper элемента, который будет обрабатываться Consumer. Последнее делает метод странным на первый взгляд, потому что он отличается от того, к чему мы привыкли с другими промежуточными методами, такими как map, filter, или же peek где ни один из них не использует никаких вариаций *Consumer.

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

Объяснение с использованием простых фрагментов

  • Отображение один на некоторое (0..1) (аналогично filter)

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

    Stream.of("Java", "Python", "JavaScript", "C#", "Ruby")
          .mapMulti((str, consumer) -> {
              if (str.length() > 4) {
                  consumer.accept(str.length());  // lengths larger than 4
              }
          })
          .forEach(i -> System.out.print(i + " "));
    
    // 6 10
    
  • Сопоставление один в один (аналогично map)

    Работая с предыдущим примером, когда условие опущено, а каждый элемент отображается в новый и принимается с использованием consumer, метод эффективно ведет себя как map:

    Stream.of("Java", "Python", "JavaScript", "C#", "Ruby")
          .mapMulti((str, consumer) -> consumer.accept(str.length()))
          .forEach(i -> System.out.print(i + " "));
    
    // 4 6 10 2 4
    
  • Отображение "один ко многим" (аналогично flatMap)

    Здесь все становится интересно, потому что можно позвонить consumer.accept(R r) любое количество раз. Допустим, мы хотим воспроизвести число, представляющее длину строки, само по себе, т.е. 2 становится 2, 2. 4 становится 4, 4, 4, 4. и 0 ничего не становится.

    Stream.of("Java", "Python", "JavaScript", "C#", "Ruby", "")
          .mapMulti((str, consumer) -> {
              for (int i = 0; i < str.length(); i++) {
                  consumer.accept(str.length());
              }
          })
          .forEach(i -> System.out.print(i + " "));
    
    // 4 4 4 4 6 6 6 6 6 6 10 10 10 10 10 10 10 10 10 10 2 2 4 4 4 4 
    
    

Сравнение с flatMap

Сама идея этого механизма заключается в том, что он может вызываться несколько раз (включая ноль) и его использование SpinedBufferвнутренне позволяет помещать элементы в один сплющенный экземпляр Stream без создания нового для каждой группы выходных элементов, в отличие от flatMap. В JavaDoc указываются два варианта использования, когда использование этого метода предпочтительнее, чем flatMap:

  • При замене каждого элемента потока небольшим (возможно, нулевым) количеством элементов. Использование этого метода позволяет избежать накладных расходов на создание нового экземпляра Stream для каждой группы элементов результата, как того требует flatMap.
  • Когда проще использовать императивный подход для генерации элементов результата, чем возвращать их в форме Stream.

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

Сценарий фильтра-карты

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

int sum = Stream.of(1, 2.0, 3.0, 4F, 5, 6L)
                .mapMultiToInt((number, consumer) -> {
                    if (number instanceof Integer) {
                        consumer.accept((Integer) number);
                    }
                })
                .sum();
// 6
int sum = Stream.of(1, 2.0, 3.0, 4F, 5, 6L)
                .filter(number -> number instanceof Integer)
                .mapToInt(number -> (Integer) number)
                .sum();

Как видно выше, его варианты вроде mapMultiToDouble, mapMultiToInt и mapMultiToLong были представлены. Это происходит вместе mapMulti методы в примитивных потоках, таких как IntStream mapMulti​(IntStream.IntMapMultiConsumer mapper). Также были введены три новых функциональных интерфейса. По сути, это примитивные вариации BiConsumer<T, Consumer<R>>, пример:

@FunctionalInterface
interface IntMapMultiConsumer {
    void accept(int value, IntConsumer ic);
}

Комбинированный реальный сценарий использования

Настоящая сила этого метода заключается в его гибкости использования и создании только одного потока за раз, что является основным преимуществом перед flatMap. Два нижеприведенных фрагмента представляют собой плоское отображение Product и это List<Variation> в 0..n предложения, представленные Offer класса и на основе определенных условий (категория продукта и наличие вариации).

  • Product с участием String name, int basePrice, String category и List<Variation> variations.
  • Variation с участием String name, int price и boolean availability.
List<Product> products = ...
List<Offer> offers = products.stream()
        .mapMulti((product, consumer) -> {
            if ("PRODUCT_CATEGORY".equals(product.getCategory())) {
                for (Variation v : product.getVariations()) {
                    if (v.isAvailable()) {
                        Offer offer = new Offer(
                            product.getName() + "_" + v.getName(),
                            product.getBasePrice() + v.getPrice());
                        consumer.accept(offer);
                    }
                }
            }
        })
        .collect(Collectors.toList());
List<Product> products = ...
List<Offer> offers = products.stream()
        .filter(product -> "PRODUCT_CATEGORY".equals(product.getCategory()))
        .flatMap(product -> product.getVariations().stream()
            .filter(Variation::isAvailable)
            .map(v -> new Offer(
                product.getName() + "_" + v.getName(),
                product.getBasePrice() + v.getPrice()
            ))
        )
        .collect(Collectors.toList());

Использование mapMulti является более императивным по сравнению с декларативным подходом комбинации методов Stream предыдущих версий, показанной в последнем фрагменте с использованием flatMap, map, и filter. С этой точки зрения от варианта использования зависит, проще ли использовать императивный подход. Рекурсия - хороший пример, описанный в JavaDoc.

Контрольный показатель

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

Stream::flatMap(Function) против Stream::mapMulti(BiConsumer) Источник

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

Benchmark                                   Mode  Cnt   Score   Error  Units
MapMulti_FlatMap.flatMap                    avgt   25  73.852 ± 3.433  ns/op
MapMulti_FlatMap.mapMulti                   avgt   25  17.495 ± 0.476  ns/op

Stream::filter(Predicate).map(Function) против Stream::mapMulti(BiConsumer) Источник

Использование цепочечных конвейеров (хотя и не вложенных) - это нормально.

Benchmark                                   Mode  Cnt    Score  Error  Units
MapMulti_FilterMap.filterMap                avgt   25   7.973 ± 0.378  ns/op
MapMulti_FilterMap.mapMulti                 avgt   25   7.765 ± 0.633  ns/op 

Stream::flatMap(Function) с участием Optional::stream() против Stream::mapMulti(BiConsumer) Источник

Это очень интересно, особенно с точки зрения использования (см. Исходный код): теперь мы можем сгладить, используя mapMulti(Optional::ifPresent) и, как и ожидалось, в этом случае новый метод работает немного быстрее.

Benchmark                                   Mode  Cnt   Score   Error  Units
MapMulti_FlatMap_Optional.flatMap           avgt   25  20.186 ± 1.305  ns/op
MapMulti_FlatMap_Optional.mapMulti          avgt   25  10.498 ± 0.403  ns/op

Чтобы обратиться к сценарию

Когда проще использовать императивный подход для генерации элементов результата, чем возвращать их в форме Stream.

Мы видим, что теперь у него есть ограниченный вариант оператора yield C#. Ограничения заключаются в том, что нам всегда нужен начальный ввод из потока, поскольку это промежуточная операция, кроме того, нет короткого замыкания для элементов, которые мы нажимаем в одной оценке функции.

Тем не менее, это открывает интересные возможности.

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

Теперь мы можем использовать что-то вроде:

       IntStream.of(0)
    .mapMulti((a,c) -> {
        for(int b = 1; a >=0; b = a + (a = b))
            c.accept(a);
    })
    /* additional stream operations here */
    .forEach(System.out::println);

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

Другой пример, вдохновленный этим ответом, для перебора иерархии классов от корня до наиболее конкретного:

       Stream.of(LinkedHashMap.class).mapMulti(MapMultiExamples::hierarchy)
    /* additional stream operations here */
    .forEach(System.out::println);
}
       static void hierarchy(Class<?> cl, Consumer<? super Class<?>> co) {
    if(cl != null) {
        hierarchy(cl.getSuperclass(), co);
        co.accept(cl);
    }
}

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

Также такие монстры

        List<A> list = IntStream.range(0, r_i).boxed()
    .flatMap(i -> IntStream.range(0, r_j).boxed()
        .flatMap(j -> IntStream.range(0, r_k)
            .mapToObj(k -> new A(i, j, k))))
    .collect(Collectors.toList());

теперь можно записать как

       List<A> list = IntStream.range(0, r_i).boxed()
    .<A>mapMulti((i,c) -> {
        for(int j = 0; j < r_j; j++) {
            for(int k = 0; k < r_k; k++) {
                c.accept(new A(i, j, k));
            }
        }
    })
    .collect(Collectors.toList());

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

Другие вопросы по тегам