何时以及如何执行一对 0..n 映射 Stream mapMulti over flatMap

When and how to perform one to 0..n mapping Stream mapMulti over flatMap

我一直在浏览新闻和最新 LTE Java 17 版本的源代码,我遇到了名为 mapMulti 的新 Stream 方法。早期访问 JavaDoc 说它类似于 flatMap.

<R> Stream<R> mapMulti(BiConsumer<? super T,? super Consumer<R>> mapper)

Stream::mapMulti 是一种新方法class化为中间操作.

需要 BiConsumer<T, Consumer<R>> mapper 个即将处理的元素 Consumer。后者使该方法乍一看很奇怪,因为它不同于我们习惯的其他中间方法,例如 mapfilterpeek,其中 none 他们使用 *Consumer.

的任何变体

API 本身在 lambda 表达式中提供的 Consumer 的目的是接受 any 数字元素,以便在后续管道。因此,将传播所有元素,无论有多少。

使用简单片段进行解释

  • 一对多 (0..1) 映射(类似于filter

    仅对少数选定项目使用 consumer.accept(R r) 可实现 filter-alike 管道。如果根据谓词 检查元素映射到不同的值,这可能会很有用,否则可以使用 filter 和 [=24= 的组合来完成] 反而。以下

    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) any 次。假设我们要复制代表字符串长度的数字本身,即 2 变成 224 变为 44440 什么都没有。

    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 的使用在内部允许 push 元素到一个与 flatMap 不同,无需为每组输出元素创建一个新的单个扁平化 Stream 实例。 JavaDoc 声明 two use-cases 使用此方法优于 flatMap:

  • When replacing each stream element with a small (possibly zero) number of elements. Using this method avoids the overhead of creating a new Stream instance for every group of result elements, as required by flatMap.
  • When it is easier to use an imperative approach for generating result elements than it is to return them in the form of a Stream.

Performance-wise,新方法 mapMulti 在这种情况下是赢家。查看此答案底部的基准。

Filter-map场景

单独使用此方法而不是 filtermap 没有意义,因为它很冗长,而且无论如何都会创建一个中间流。例外可能是替换 .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 and mapMultiToLong were introduced. This comes along the mapMulti methods within the primitive Streams such as IntStream mapMulti​(IntStream.IntMapMultiConsumer mapper)。此外,还引入了三个新的功能接口。基本上,它们是 BiConsumer<T, Consumer<R>> 的原始变体,例如:

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

结合真实use-case场景

这种方法的真正强大之处在于它的使用灵活性和一次只创建一个 Stream,这是优于 flatMap 的主要优势。以下两个片段表示 Product 及其 List<Variation>0..n 的平面映射,由 Offer class 表示并基于特定条件(产品类别和变体可用性)。

  • ProductString nameint basePriceString categoryList<Variation> variations.
  • VariationString nameint priceboolean 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());

与使用 flatMapmap 的 previous-versions 流方法组合的声明性方法相比,mapMulti 的使用更倾向于命令式和 filter。从这个角度来说,就看use-case使用命令式的方式是否更容易了。递归是 JavaDoc.

中描述的一个很好的例子

基准

如约,我根据评论中收集的想法写了一堆micro-benchmarks。只要有相当多的代码要发布,我已经创建了一个包含实现细节的 GitHub repository,我将只分享结果。

Stream::flatMap(Function) 对比 Stream::mapMulti(BiConsumer) Source

在这里我们可以看到巨大的差异,并证明新方法确实如描述的那样工作,并且它的使用避免了为每个处理过的元素创建一个新的 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) Source

使用链式管道(虽然没有嵌套)没问题。

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) Source

这个非常有趣,特别是在用法方面(参见源代码):我们现在可以使用 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

解决场景

When it is easier to use an imperative approach for generating result elements than it is to return them in the form of a Stream.

我们可以看到它现在具有 the yield statement C# 的有限变体。限制是我们总是需要来自流的初始输入,因为这是一个中间操作,此外,我们在一个函数评估中推送的元素没有 short-circuiting。

尽管如此,它还是带来了有趣的机会。

例如,实现以前需要的斐波那契数流

现在,我们可以使用类似的东西:

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 值溢出时停止,如前所述,它不会 short-circuit 当我们使用不消耗所有值的终端操作时,但是,此循环产生 then-ignored values 可能仍然比其他方法更快。

启发的另一个示例,用于从根到最具体的 class 层次结构迭代:

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);
    }
}

与旧方法不同,它不需要额外的堆存储,并且可能 运行 更快(假设合理的 class 深度不会使递归适得其反)。

还有怪物like this

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 步骤相比,它失去了一些并行机会,而参考实现并没有利用这些机会。对于像上面这样的非 short-circuiting 操作,新方法可能会受益于减少装箱和捕获 lambda 表达式的实例化。但是,当然,应该明智地使用它,而不是将每个构造都重写为命令式版本(在这么多人试图将每个命令式代码重写为函数式版本之后)……