Когда и как выполнять преобразование "один в 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. Когда каждый из них предпочтительнее?
  • Сколько раз можно вызвать mapper?

person Nikolas Charalambidis    schedule 30.09.2020    source источник
comment
На первый взгляд, это похоже на перегрузку flatMap, но у них, должно быть, были причины не перегрузить. Тогда я рад, что я не единственный, кто плохо называет API. Добавьте это в закладки, чтобы попробовать и понять, что помешало им улучшить производительность с помощью существующей комбинации filter.map или flatMap вместо предоставления нового API. В любом случае спасибо за то, что поделились.   -  person Naman    schedule 30.09.2020
comment
@Naman Поэкспериментировав с этой функцией, я решил опубликовать этот вопрос и ответ. Я считаю, что нужно провести тест filter.map против mapMulti.   -  person Nikolas Charalambidis    schedule 30.09.2020
comment
Я думаю, что этот метод идеально подходит для сопоставления с образцом, если он будет более продвинутым, чем просто instanceof   -  person fps    schedule 30.09.2020
comment
@fps: Совершенно верно! Оба сценария представлены в JavaDoc (первый пример) и в сценарии фильтра-карты моего ответа.   -  person Nikolas Charalambidis    schedule 30.09.2020


Ответы (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();

Как видно выше, его варианты вроде _ 39_, _ 40_ и _ 41_. Это входит в mapMulti методы в примитивных потоках, таких как _ 43_. Также были введены три новых функциональных интерфейса. По сути, это примитивные варианты 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
person Nikolas Charalambidis    schedule 30.09.2020
comment
Интересно. Внезапно появился простой (r) способ превратить произвольные объекты, не являющиеся коллекциями, которые поддерживают Consumer, в поток. Например, уже существующий Iterator. Но также преобразование потока CompletionStage экземпляров в поток результатов, упрощенный до mapMulti(CompletionStage::thenAccept) - person Holger; 30.09.2020
comment
SpinnedBuffer есть только в реализации по умолчанию, ReferencePipeline переопределяет это и выставляет фактическое Sink (элементы потребляются этим), что и является этим consumer. Это довольно безумие, никогда не думал об этом так. - person Eugene; 30.09.2020
comment
@Eugene Я полагаю, что буфер все еще вступает в игру, когда происходит короткое замыкание терминальной операции, тогда как фактический приемник может быть передан напрямую другим. - person Holger; 01.10.2020
comment
Случай, заслуживающий сравнительного анализа, - это обработка потока Optional, то есть flatMap(Optional::stream) vs. mapMulti(Optional::ifPresent). Раздражает только ограниченный вывод типа. Кажется, нам почти всегда нужно .<IntendedType>mapMulti(…) - person Holger; 01.10.2020
comment
Стоит запустить такой тест с разными размерами списков, а также с другими терминальными операциями, чтобы получить лучшее представление о фактическом влиянии промежуточной операции. - person Holger; 02.10.2020
comment
IIRC, вы можете превратить переменную COUNT через аннотацию в параметр, который JMH может изменять - person Holger; 02.10.2020
comment
@ Николас, да. Также вы можете запустить это в несколько @Forks для уменьшения ошибки. У меня есть проект git, который делает это против github (поскольку для этого требуется много времени, несколько вилок) ... - person Eugene; 02.10.2020
comment
Ух ты, отличная работа @NikolasCharalambidis - person Yassin Hajaj; 22.04.2021

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

Когда проще использовать императивный подход для генерации элементов результата, чем возвращать их в форме 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 шагами он теряет некоторую возможность параллелизма, которую эталонная реализация в любом случае не использовала. Для операции без короткого замыкания, подобной описанной выше, новый метод, вероятно, выиграет от уменьшенного бокса и меньшего количества экземпляров захвата лямбда-выражений. Но, конечно, его следует использовать разумно, чтобы не переписывать каждую конструкцию в императивную версию (после того, как так много людей пытались переписать каждый императивный код в функциональную версию)…

person Holger    schedule 30.09.2020
comment
Я впечатлен тем, как быстро вы пришли к таким разнообразным сценариям. ИМХО вложенное плоское отображение - это проблема, когда будет сохранена большая часть производительности. С другой стороны, просмотр последнего фрагмента дает мне понять, что, поскольку метод основан на императивном подходе, весь поток не нужен вообще, и весь код будет иметь смысл с несколькими циклами для каждого. - person Nikolas Charalambidis; 30.09.2020
comment
примеры на высоте! Вы помогли найти два случая в нашей базе кодов, где мы можем это использовать, спасибо. - person Eugene; 30.09.2020
comment
@Nikolas, конечно, это зависит от последующих операций с потоком или от того, является ли возвращение Stream частью контракта. При простом связывании тривиальной операции обычный цикл действительно может быть каноническим решением. - person Holger; 01.10.2020