为什么flatMap()之后的filter()在Java流中不是“完全”惰性的?


75

我有以下示例代码:

System.out.println(
       "Result: " +
        Stream.of(1, 2, 3)
                .filter(i -> {
                    System.out.println(i);
                    return true;
                })
                .findFirst()
                .get()
);
System.out.println("-----------");
System.out.println(
       "Result: " +
        Stream.of(1, 2, 3)
                .flatMap(i -> Stream.of(i - 1, i, i + 1))
                .flatMap(i -> Stream.of(i - 1, i, i + 1))
                .filter(i -> {
                    System.out.println(i);
                    return true;
                })
                .findFirst()
                .get()
);

输出如下:

1
Result: 1
-----------
-1
0
1
0
1
2
1
2
3
Result: -1

从这里,我看到第一种情况下的stream行为确实很懒惰-我们使用它,findFirst()因此一旦有了第一个元素,就不会调用过滤lambda。但是,在使用flatMaps的第二种情况下,我们看到尽管找到了满足过滤条件的第一个元素(因为lambda始终返回true,所以它只是任何第一个元素),流的其他内容仍通过过滤功能馈送。

我试图理解为什么它会像这样,而不是像第一种情况那样在计算第一个元素之后就放弃了。任何有用的信息将不胜感激。


11
@PhilippSander:因为如果它表现得很懒惰(就像第一种情况一样),那么它只会对过滤器进行一次评估。
乔恩·斯基特

4
请注意,您还可以使用peekStream.of(1, 2, 3).peek(System.out::println).filter(i -> true)...
Alexis C.

4
请注意,我创建了一个一般的解决方法
Holger 2015年

9
在提出此问题的那天,就引发了一个OpenJDK错误:bugs.openjdk.java.net/browse/JDK-8075939。差不多一年后,它已经被分配了,但仍然没有固定:(
MikeFHay

5
@MikeFHay JDK-8075939适用于Java 10。mail.openjdk.java.net/pipermail/core-libs-dev/2017-December/…用于core-libs-dev审阅线程,以及指向第一个webrev的链接。
Stefan Zobel

Answers:


65

TL; DR,已在JDK-8075939中解决,并在Java 10中修复(并在JDK-8225328中反向移植到Java 8)。

在研究实现(ReferencePipeline.java)时,我们看到了方法[ link ]

@Override
final void forEachWithCancel(Spliterator<P_OUT> spliterator, Sink<P_OUT> sink) {
    do { } while (!sink.cancellationRequested() && spliterator.tryAdvance(sink));
}

它将被调用以进行findFirst操作。需要特别注意的是,sink.cancellationRequested()它可以在第一个比赛结束时结束循环。比较[链接]

@Override
public final <R> Stream<R> flatMap(Function<? super P_OUT, ? extends Stream<? extends R>> mapper) {
    Objects.requireNonNull(mapper);
    // We can do better than this, by polling cancellationRequested when stream is infinite
    return new StatelessOp<P_OUT, R>(this, StreamShape.REFERENCE,
                                 StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT | StreamOpFlag.NOT_SIZED) {
        @Override
        Sink<P_OUT> opWrapSink(int flags, Sink<R> sink) {
            return new Sink.ChainedReference<P_OUT, R>(sink) {
                @Override
                public void begin(long size) {
                    downstream.begin(-1);
                }

                @Override
                public void accept(P_OUT u) {
                    try (Stream<? extends R> result = mapper.apply(u)) {
                        // We can do better that this too; optimize for depth=0 case and just grab spliterator and forEach it
                        if (result != null)
                            result.sequential().forEach(downstream);
                    }
                }
            };
        }
    };
}

用于推进一项的方法最终以forEach没有可能提前终止的方式在子流上进行调用,并且在flatMap方法开始时的注释甚至说明了该缺席功能。

由于这不仅仅是优化,还意味着当子流无限时代码只会中断,因此我希望开发人员尽快证明他们“可以做得更好”……


为了说明其含义,尽管Stream.iterate(0, i->i+1).findFirst()按预期工作,但Stream.of("").flatMap(x->Stream.iterate(0, i->i+1)).findFirst()最终将陷入无限循环。

关于规范,大部分内容可以在

软件包规范的“流操作和管道”一章

中间操作返回一个新的流。他们总是懒惰;

……懒惰还可以避免在不必要时检查所有数据;对于诸如“查找长度超过1000个字符的第一个字符串”之类的操作,只需检查足够多的字符串以查找具有所需特征的字符串,而无需检查可从源中获得的所有字符串。(当输入流无限且不仅仅是大时,此行为就变得更加重要。)

此外,某些操作被认为是短路操作。如果出现无限输入时,中间操作可能会短路,这可能会导致产生有限流。如果出现无限输入时,端子操作可能会在有限时间内终止,则该端子操作会发生短路。在管道中进行短路操作是使无限流的处理在有限时间内正常终止的必要条件,但还不够。

显然,短路操作不能保证有限的时间终止,例如,当过滤器与任何项目都不匹配时,处理就无法完成,但是一种实现在有限时间内不支持任何终止的实现方式只是忽略了操作的短路特性远远超出规范。


27
这是一个错误。尽管规范确实支持这种行为,但是没有人期望获得无限流的第一个元素会引发StackOverflowError或最终陷入无限循环,无论它是直接来自管道的源头还是通过映射功能从嵌套流中获取。这应该报告为错误。
fps

5
@Vadym S. Khondar:提交错误报告是一个好主意。关于为什么以前没有人发现这个错误,以前我已经看到很多“无法相信我是第一个注意到这种错误”的错误。除非涉及到无限的流,否则此错误仅会对性能产生影响,在许多用例中可能不会引起注意。
Holger

7
@Marko Topolnik:属性“直到执行管道的终端操作才开始”不会否定惰性操作的其他属性。我知道所讨论的属性没有单句声明,否则我引用了它。在StreamAPI文档据说是“流是懒惰; 仅在启动终端操作时对源数据进行计算,并且仅在需要时才使用源元素。”
Holger

6
您可能会再次提出疑问,这意味着关于短路的懒惰执行保证,但是,我倾向于相反地看待它:从来没有说过实现可以按照我们在这里看到的方式自由地实现非懒惰行为。对于允许的内容和不允许的内容,该规范非常详尽。
Holger

5
JDK-8075939现在取得了进展。请参阅mail.openjdk.java.net/pipermail/core-libs-dev/2017-December/…以获取core-libs-dev审查主题以及第一个webrev的链接。它apppears我们会看到它在Java中10
斯特凡佐贝尔

17

输入流的元素被一一延迟地消耗。第一个元素,1被两个flatMap转换为stream -1, 0, 1, 0, 1, 2, 1, 2, 3,因此整个流仅对应于第一个输入元素。嵌套的流通过管道急切地实现,然后被展平,然后被馈送到filter平台。这说明了您的输出。

以上内容并非源于基本限制,但是对于嵌套流来说,完全懒惰可能会使事情变得更加复杂。我怀疑要使其表现出色将面临更大的挑战。

为了进行比较,Clojure的惰性序列对于每个这样的嵌套级别都获得了另一层包装。由于这种设计,StackOverflowError当执行极端嵌套时,操作甚至可能失败。


2
@MarkoTopolnik,感谢您的回复。实际上,霍尔格(Holger)带来的担忧实际上是令我惊讶的原因。第二种情况是否意味着我不能将flatMap用于无限流?
Vadym S. Khondar

是的,我敢打赌嵌套流不能是无限的。
Marko Topolnik

8

关于无限子流的破坏,当人们抛出中间(而不是终端)短路操作时,flatMap的行为变得更加令人惊讶。

尽管下面的代码可以正常工作,但打印出无限的整数序列

Stream.of("x").flatMap(_x -> Stream.iterate(1, i -> i + 1)).forEach(System.out::println);

以下代码仅打印出“ 1”,但不会终止:

Stream.of("x").flatMap(_x -> Stream.iterate(1, i -> i + 1)).limit(1).forEach(System.out::println);

我无法想象阅读其中不是错误的规范。


6

在免费的StreamEx库中,我介绍了短路收集器。当使用短路收集器(如MoreCollectors.first())收集顺序流时,恰好从源消耗了一种元素。在内部,它是以非常肮脏的方式实现的:使用自定义异常来破坏控制流。使用我的库,您的示例可以通过以下方式重写:

System.out.println(
        "Result: " +
                StreamEx.of(1, 2, 3)
                .flatMap(i -> Stream.of(i - 1, i, i + 1))
                .flatMap(i -> Stream.of(i - 1, i, i + 1))
                .filter(i -> {
                    System.out.println(i);
                    return true;
                })
                .collect(MoreCollectors.first())
                .get()
        );

结果如下:

-1
Result: -1


0

我同意其他人的观点,这是在JDK-8075939中打开的错误。而且由于一年后仍未解决。我想向您推荐:AbacusUtil

N.println("Result: " + Stream.of(1, 2, 3).peek(N::println).first().get());

N.println("-----------");

N.println("Result: " + Stream.of(1, 2, 3)
                        .flatMap(i -> Stream.of(i - 1, i, i + 1))
                        .flatMap(i -> Stream.of(i - 1, i, i + 1))
                        .peek(N::println).first().get());

// output:
// 1
// Result: 1
// -----------
// -1
// Result: -1

披露:我是AbacusUtil的开发人员。


0

今天我也偶然发现了这个错误。行为并没有那么困难,导致简单的情况(如下所示)可以正常工作,但是类似的生产代码不起作用。

 stream(spliterator).map(o -> o).flatMap(Stream::of).flatMap(Stream::of).findAny()

对于不能再等几年才能迁移到JDK-10的家伙,还有另一种真正的惰性流。它不支持并行。它专用于JavaScript翻译,但是对我来说可行,因为界面相同。

StreamHelper基于集合,但是很容易使用Spliterator。

https://github.com/yaitskov/j4ts/blob/stream/src/main/java/javaemul/internal/stream/StreamHelper.java

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.