为什么Java Streams一次性出现?


239

与C#不同IEnumerable,在C#中,执行管道可以执行任意多次,而在Java中,流只能“迭代”一次。

对终端操作的任何调用都会关闭流,使其无法使用。这种“功能”带走了很多力量。

我想这不是技术原因。这种奇怪的限制背后的设计考虑是什么?

编辑:为了演示我在说什么,请考虑以下C#中的Quick-Sort实现:

IEnumerable<int> QuickSort(IEnumerable<int> ints)
{
  if (!ints.Any()) {
    return Enumerable.Empty<int>();
  }

  int pivot = ints.First();

  IEnumerable<int> lt = ints.Where(i => i < pivot);
  IEnumerable<int> gt = ints.Where(i => i > pivot);

  return QuickSort(lt).Concat(new int[] { pivot }).Concat(QuickSort(gt));
}

现在确定,我不主张这是快速排序的良好实现!但是,这是将lambda表达与流操作相结合的表达能力的一个很好的例子。

而且用Java无法做到!我什至不能问一个流是否为空而不使它无法使用。


4
您能否举一个具体的例子,关闭流“夺走力量”?
罗杰里奥

23
如果要多次使用流中的数据,则必须将其转储到集合中。这是非常如何工作:要么你得重新计算产生的数据流,或者您有存储中间结果。
Louis Wasserman'2

5
好的,但是在相同的流上重做相同的计算听起来是错误的。在执行计算之前,从给定源创建流,就像为每次迭代创建迭代器一样。我仍然希望看到一个实际的具体例子。最后,我敢打赌,假设C#的枚举数存在相应的使用方式,那么有一种干净的方法可以解决一次性使用流的每个问题。
罗杰里奥2015年

2
起初这让我感到困惑,因为我认为这个问题会将C#IEnumerable与“ java.io.*
SpaceTrucker”

9
请注意,在C#中多次使用IEnumerable是一种脆弱的模式,因此问题的前提可能存在一些缺陷。IEnumerable的许多实现都允许它,但有些不允许!代码分析工具往往会警告您不要这样做。
桑德

Answers:


368

我对Streams API的早期设计有一些回忆,这可能有助于我们了解设计原理。

早在2012年,我们就在语言中添加了lambda,并且我们希望使用lambdas对面向集合或“大量数据”的一组操作进行编程,以促进并行性。到现在为止,已经将惰性链接操作的想法很好地确立了。我们也不希望中间操作存储结果。

我们需要决定的主要问题是该链中的对象在API中的外观以及它们如何连接到数据源。来源通常是集合,但我们也想支持来自文件或网络的数据,或动态生成的数据(例如,随机数生成器)。

现有工作对设计有很多影响。其中最具影响力的是Google的Guava库和Scala收藏库。(如果有人对Guava的影响感到惊讶,请注意,Guava的主要开发人员Kevin Bourrillion正在JSR-335 Lambda专家组中。)在Scala系列中,我们发现Martin Odersky的演讲特别有趣:Future-验证Scala集合:从可变到持久到并行。(斯坦福大学EE380,2011年6月1日。)

我们当时的原型设计基于Iterable。熟悉业务filtermap等了对扩展名(默认)方法Iterable。调用一个将操作添加到链中,并返回另一个Iterable。像count这样的终端操作会调用iterator()到源的链,并且这些操作是在每个阶段的Iterator中实现的。

由于这些是Iterable,因此可以iterator()多次调用该方法。那应该怎么办?

如果源是集合,则通常可以正常工作。集合是可iterator()迭代的,并且每次调用都会产生一个独立的Iterator实例,该实例独立于任何其他活动实例,并且每个实例都独立遍历该集合。大。

现在,如果源是一次性的,例如从文件中读取行,该怎么办?也许第一个Iterator应该获取所有值,但第二个和后续的应该为空。也许值应该在迭代器之间交织。或者,也许每个Iterator都应该获得所有相同的值。然后,如果您有两个迭代器而一个迭代器比另一个迭代器更远呢?有人将不得不在第二个Iterator中缓冲这些值,直到它们被读取为止。更糟糕的是,如果您获得一个Iterator并读取所有值,然后又获得另一个Iterator 怎么办?这些价值从何而来?是否有必要将它们全部缓冲起来,以防万一有人想要第二个Iterator?

显然,在一个触发源上允许多个Iterators引发了很多问题。我们没有给他们好的答案。如果您拨打iterator()两次电话,我们希望获得一致的,可预测的行为。这迫使我们朝着不允许多次遍历的方向前进,使流水线一发不可收拾。

我们还观察到其他人陷入了这些问题。在JDK中,大多数Iterables是集合或类似集合的对象,它们允许多次遍历。它没有在任何地方指定,但是似乎有一个不成文的期望,即Iterables允许多次遍历。NIO DirectoryStream接口是一个明显的例外。它的规范包括以下有趣的警告:

虽然DirectoryStream扩展了Iterable,但它不是通用的Iterable,因为它仅支持单个Iterator;调用迭代器方法以获得第二个或后续迭代器,则抛出IllegalStateException。

[粗体显示]

这看起来异常且令人不愉快,以至于我们不想创建一大堆可能只是一次的新Iterable。这使我们不再使用Iterable。

大约在这个时候,Bruce Eckel一篇文章出现了,描述了他在Scala遇到的麻烦。他写了这段代码:

// Scala
val lines = fromString(data).getLines
val registrants = lines.map(Registrant)
registrants.foreach(println)
registrants.foreach(println)

这很简单。它将文本行解析为Registrant对象并将其打印两次。除了它实际上只打印一次。事实证明,他认为这registrants是一个集合,而实际上它是一个迭代器。第二个调用foreach遇到一个空的迭代器,从该迭代器中耗尽所有值,因此不打印任何内容。

这种经历使我们相信,如果尝试多次遍历,获得清晰可预测的结果非常重要。它还强调了区分类似惰性管道的结构和存储数据的实际集合的重要性。反过来,这将惰性管道操作分离到新的Stream接口中,并且仅将急切的,可变的操作直接保留在Collection上。布莱恩·格茨(Brian Goetz)对此做了解释

允许对基于集合的管道进行多次遍历,而对非基于集合的管道却不允许进行遍历怎么办?这是不一致的,但是很明智。如果您正在从网络中读取值,则当然无法再次遍历它们。如果要遍历它们多次,则必须将它们显式地拉到一个集合中。

但是,让我们探索允许从基于集合的管道进行多次遍历。假设您这样做:

Iterable<?> it = source.filter(...).map(...).filter(...).map(...);
it.into(dest1);
it.into(dest2);

(该into操作现在是拼写的collect(toList())。)

如果source是一个集合,则第一个into()调用将创建一个Iterators链返回到Source,执行管道操作,并将结果发送到Destination。第二次调用into()将创建另一个Iterators链,并再次执行管道操作。这显然不是错的,但确实可以对每个元素第二次执行所有过滤和映射操作。我认为许多程序员会对这种行为感到惊讶。

如上所述,我们一直在与Guava开发人员交谈。他们拥有的很酷的东西之一是一个想法墓地,在那里他们描述他们决定实施的功能以及原因。惰性集合的想法听起来很酷,但是这是他们不得不说的。考虑一个List.filter()返回a 的操作List

这里最大的问题是太多的操作变成了昂贵的线性时间命题。如果您要过滤列表并获取列表,而不仅仅是一个Collection或Iterable,则可以使用ImmutableList.copyOf(Iterables.filter(list, predicate)),它“ 预先声明”它在做什么以及它有多昂贵。

举一个具体的例子,什么是成本get(0)size()上的列表?对于像这样的常用类ArrayList,它们是O(1)。但是,如果您在延迟过滤的列表中调用其中之一,则它必须在后备列表上运行过滤器,突然这些操作都是O(n)。更糟糕的是,它必须遍历每个操作的后备列表。

在我们看来,太懒了。设置一些操作并推迟实际执行,直到您“ Go”为止是一回事。以隐藏潜在大量重新计算的方式进行设置是另一种方法。

在提议禁止非线性流或“不可重用”流时,Paul Sandoz描述了允许它们流带来的潜在后果是“意外或令人困惑的结果”。他还提到并行执行会使事情变得更加棘手。最后,我还要补充一下,如果意外地多次执行该管道操作,或者产生的副作用与程序员预期的次数不同,则具有副作用的管道操作将导致困难且难以理解的错误。(但是Java程序员不会编写带有副作用的lambda表达式,对吗?

因此,这是Java 8 Streams API设计的基本原理,该设计允许一次性遍历,并且需要严格的线性(无分支)流水线。它在多个不同的流源之间提供一致的行为,它清楚地将懒惰操作与急切操作分开,并且提供了直接的执行模型。


关于IEnumerable,我距离C#和.NET专家还很远,因此,如果我得出任何错误的结论,请(认真地)更正我将不胜感激。但是,它确实IEnumerable允许多次遍历在不同的源上表现不同。并且它允许嵌套IEnumerable操作的分支结构,这可能会导致一些重大的重新计算。尽管我理解不同的系统会做出不同的取舍,但这是我们在Java 8 Streams API设计中要避免的两个特征。

OP给出的快速排序示例很有趣,令人困惑,我很遗憾地说,这有些令人恐惧。调用QuickSort采用IEnumerable并返回IEnumerable,因此在IEnumerable遍历末尾之前实际上不会进行任何排序。但是,该调用似乎要做的是建立一个树结构,IEnumerables该树结构反映了quicksort会实际执行的分区。(毕竟,这是惰性计算。)如果源包含N个元素,则树的最大宽度将为N个元素宽,并且深度为lg(N)级。

在我看来-再一次,我不是C#或.NET专家-这将导致某些看上去无害的调用(例如通过ints.First()进行枢轴选择)比看起来昂贵。在第一层,当然是O(1)。但是请考虑在树的深处,在右侧边缘的分区。要计算此分区的第一个元素,必须遍历整个源,执行O(N)操作。但是由于上述分区是惰性的,因此必须重新计算它们,需要进行O(lg N)比较。因此,选择枢轴将是O(N lg N)操作,这与整个操作一样昂贵。

但是我们直到遍历返回的元素时才进行排序IEnumerable。在标准的快速排序算法中,每个分区级别使分区数量加倍。每个分区的大小只有一半,因此每个级别的复杂度保持为O(N)。分区树的高度为O(lg N),因此总功为O(N lg N)。

对于惰性IEnumerables树,在树的底部有N个分区。计算每个分区需要遍历N个元素,每个元素都需要对树进行lg(N)比较。为了计算树底部的所有分区,需要进行O(N ^ 2 lg N)个比较。

(这是对的吗?我简直难以相信。有人请帮我检查一下。)

无论如何,IEnumerable以这种方式来构建复杂的计算结构确实很酷。但是,如果确实像我认为的那样增加了计算复杂性,那么除非特别小心,否则应该避免这种方式的编程。


35
首先,谢谢您的好评!到目前为止,这是我得到的最准确的解释。就QuickSort示例而言,似乎对ints是正确的。随着递归级别的提高,首先会膨胀。我相信可以通过急切地计算“ gt”和“ lt”(通过使用ToArray收集结果)来轻松解决此问题。话虽如此,它肯定支持您的观点,即这种编程风格可能会带来意想不到的性能价格。(继续发表评论)
Vitaliy 2015年

18
另一方面,根据我在C#方面的经验(超过5年),我可以告诉您,一旦遇到性能问题(或被禁止,如果有人做出了不可想象的事情并提出了建议,副作用)。在我看来,为了确保API的纯正性而进行了太多折衷,以牺牲C#之类的代价为代价。您无疑帮助我调整了观点。
Vitaliy 2015年

7
@Vitaliy感谢您的思想交流。通过研究和编写此答案,我对C#和.NET有所了解。
Stuart Marks 2015年

10
小注释:ReSharper是Visual Studio扩展,可帮助C#。通过上面的QuickSort代码,ReSharper会为每次使用ints添加一个警告:“ IEnumerable可能的多重枚举”。IEenumerable多次使用同一工具是可疑的,应避免使用。我还要指出这个问题(我已经回答了),它显示了.Net方法的一些警告(除性能欠佳之外):List <T>和IEnumerable差异
Kobi

4
@Kobi非常有趣的是,在ReSharper中有这样的警告。感谢您指向答案的指针。我不了解C#/。NET,因此我必须仔细选择它,但是它似乎确实表现出与我上面提到的设计问题类似的问题。
斯图尔特(Stuart Marks)

122

背景

虽然问题看起来很简单,但实际答案需要一些背景才能理解。如果您想跳到结论,请向下滚动...

选择您的比较点-基本功能

使用基本概念,C#的IEnumerable概念与JavaIterable更紧密相关,Java能够创建所需的任意数量的IteratorsIEnumerables创建IEnumerators。Java的Iterable创造Iterators

每个概念的历史是相似的,在这两个IEnumerableIterable有一个基本的动机,让“换每个”风格遍历数据收集的成员。这太过简单了,因为它们不仅允许这样做,而且还通过不同的进度到达了那个阶段,但这是一个重要的共同特征。

让我们比较一下该功能:在两种语言中,如果一个类实现了IEnumerable/ Iterable,那么该类必须至少实现一个方法(对于C#GetEnumerator和Java iterator())。在每种情况下,从该(IEnumerator/ Iterator)返回的实例都允许您访问数据的当前成员和后续成员。for-each语言语法中使用了此功能。

选择您的比较点-增强功能

IEnumerableC#中的C语言已扩展为允许许多其他语言功能(主要与Linq相关)。添加的功能包括选择,预测,聚合等。这些扩展具有在集理论中使用的强烈动机,类似于SQL和关系数据库的概念。

Java 8还添加了功能,以支持使用Streams和Lambdas进行一定程度的功能编程。请注意,Java 8流并不是主要由集合论驱动,而是由功能编程驱动。无论如何,有很多相似之处。

所以,这是第二点。C#的增强功能是对IEnumerable概念的增强。但是,在Java中,增强功能是通过创建Lambda和Streams的新基本概念来实现的,然后还创建了一种相对简单的方法来IteratorsIterablesStreams 相互转换,反之亦然。

因此,将IEnumerable与Java的Stream概念进行比较是不完整的。您需要将其与Java中合并的Streams and Collections API进行比较。

在Java中,流与Iterables或Iterators不同

流的设计方式与迭代器的解决方式不同:

  • 迭代器是描述数据序列的一种方式。
  • 流是描述数据转换序列的一种方式。

使用Iterator,您将获得一个数据值,对其进行处理,然后获得另一个数据值。

使用Streams,您可以将一系列函数链接在一起,然后将输入值提供给流,并从组合的序列中获取输出值。注意,用Java术语来说,每个函数都封装在一个Stream实例中。Streams API允许您以链接Stream一系列转换表达式的方式链接实例序列。

为了完成该Stream概念,您需要一个数据源来提供流,并需要一个使用该流的终端函数。

实际上,将值馈入流的方式可能来自Iterable,但Stream序列本身不是Iterable,而是复合函数。

Stream从某种意义上说,A 也是懒惰的,因为它仅在您从中请求值时才起作用。

请注意Streams的以下重要假设和功能:

  • StreamJava中的A 是转换引擎,它将一个状态下的数据项转换为另一状态。
  • 流没有数据顺序或位置的概念,只需转换它们的要求即可。
  • 可以向流提供来自许多来源的数据,包括其他流,迭代器,可迭代对象,集合,
  • 您无法“重置”流,就像“对转换进行重新编程”一样。重置数据源可能是您想要的。
  • 从逻辑上讲,流中随时只有1个数据项在运行(除非该流是并行流,此时每个线程只有1个数据项)。这与数据源(可能要比当前“准备好”提供给流的项目更多)或流收集器(可能需要聚合并减少多个值)无关。
  • 流可以是不受限制的(无限的),仅受数据源或收集器(也可以是无限的)的限制。
  • 流是“可链接的”,过滤一个流的输出是另一流。输入到流并由其转换的值可以依次提供给另一个进行不同转换的流。处于转换状态的数据从一个流流向下一个流。您无需干预并从一个流中提取数据并将其插入下一个流。

C#比较

当您认为Java Stream只是供应,流和收集系统的一部分,并且Streams和Iterators经常与Collections一起使用时,难怪很难将相同的概念与几乎所有内容都嵌入到IEnumerableC#中的单个概念中。

IEnumerable的某些部分(以及紧密相关的概念)在所有Java Iterator,Iterable,Lambda和Stream概念中都很明显。

Java概念可以做的小事情在IEnumerable中很难,反之亦然。


结论

  • 这里没有设计问题,只是语言之间的概念匹配问题。
  • 流以不同的方式解决问题
  • 流向Java添加功能(它们添加了不同的做事方式,它们并没有剥夺功能)

在解决问题时,添加流可为您提供更多选择,将其归类为“增强能力”,而不是“减少”,“夺走”或“限制”,这是公平的。

为什么Java Streams一次性出现?

这个问题被误导了,因为流是函数序列,而不是数据。根据提供流的数据源,您可以重置数据源,并提供相同或不同的流。

与C#的IEnumerable不同,在IEnumerable中,执行管道可以执行任意多次,而在Java中,流只能被“迭代”一次。

将a IEnumerable与a 比较Stream是错误的。IEnumerable与Java相比,您要说的上下文可以根据需要多次执行,与Java相比,Java Iterables可以任意多次执行。Java Stream代表IEnumerable概念的子集,而不是提供数据的子集,因此不能被“重新运行”。

对终端操作的任何调用都会关闭流,使其无法使用。这种“功能”带走了很多力量。

从某种意义上说,第一个陈述是正确的。不是“夺走权力”的说法。您仍在比较Streams IEnumerables。流中的终端操作类似于for循环中的“ break”子句。如果需要,并且可以重新提供所需的数据,您总是可以自由地拥有另一个流。同样,如果您认为语句IEnumerable更像是Iterable,Java会很好地完成此语句。

我想这不是技术原因。这种奇怪的限制背后的设计考虑是什么?

原因是技术性的,并且出于简单的原因,Stream是其认为是的子集。流子集不控制数据供应,因此您应该重置供应,而不是流。在这种情况下,这并不奇怪。

QuickSort示例

您的quicksort示例具有签名:

IEnumerable<int> QuickSort(IEnumerable<int> ints)

您正在将输入IEnumerable视为数据源:

IEnumerable<int> lt = ints.Where(i => i < pivot);

另外,返回值也是IEnumerable数据的供应,并且由于这是排序操作,因此供应的顺序很重要。如果您认为Java Iterable类是对此的适当匹配,特别是的List特殊化Iterable,因为List是具有保证顺序或迭代的数据源,则与您的代码等效的Java代码将是:

Stream<Integer> quickSort(List<Integer> ints) {
    // Using a stream to access the data, instead of the simpler ints.isEmpty()
    if (!ints.stream().findAny().isPresent()) {
        return Stream.of();
    }

    // treating the ints as a data collection, just like the C#
    final Integer pivot = ints.get(0);

    // Using streams to get the two partitions
    List<Integer> lt = ints.stream().filter(i -> i < pivot).collect(Collectors.toList());
    List<Integer> gt = ints.stream().filter(i -> i > pivot).collect(Collectors.toList());

    return Stream.concat(Stream.concat(quickSort(lt), Stream.of(pivot)),quickSort(gt));
}    

请注意,存在一个错误(我已复制了此错误),因为该排序不能优雅地处理重复值,它是一个“唯一值”排序。

另请注意,Java代码如何使用数据源(List),并在不同点使用流概念,并且在C#中这两个“个性”可以用just表示IEnumerable。另外,尽管我已经使用List了基本类型,但是我可以使用更通用的类型Collection,并且如果迭代器到流转换很小,我可以使用更通用的类型。Iterable


9
如果您想“迭代”流,那么您做错了。流表示转换链中特定时间点的数据状态。数据以流源的形式进入系统,然后从一个流流向下一个流,并随着流的状态不断变化,直到最后被收集,减少或转储为止。A Stream是时间点概念,而不是“循环操作”。...(续)
rolfl 2015年

7
使用Stream,您可以使数据像X一样进入流,而像Y一样离开流。流执行f(x)的功能执行该转换。该流封装了该功能,但不封装流经的数据
rolfl 2015年

4
IEnumerable还可以提供随机值,解除绑定并在数据存在之前变为活动状态。
Arturo TorresSánchez2015年

6
@Vitaliy:收到期望值的许多方法都IEnumerable<T>希望它表示一个有限的集合,该集合可能会被多次迭代。某些可迭代但不满足这些条件的事情会实现,IEnumerable<T>因为没有其他标准接口符合要求,但是如果给出了不遵守这些条件的可迭代事物,则期望可以多次迭代的有限集合的方法很容易崩溃。 。
2015年

5
quickSort如果返回一个Stream; 您的示例可能会更简单。这样可以节省两个.stream()电话和一个.collect(Collectors.toList())电话。如果您随后将其替换Collections.singleton(pivot).stream()Stream.of(pivot)几乎可读取的代码……
Holger 2015年

22

Streams是围绕Spliterators 建立的,s是有状态的可变对象。他们没有“重置”动作,实际上,要求支持此类倒带动作将“夺走很多力量”。怎么会Random.ints()被认为来处理这样的要求?

另一方面,对于Stream具有可追溯原点的,很容易构造等效项Stream以再次使用。只需将构成的步骤Stream放入可重用的方法即可。请记住,重复这些步骤并不是昂贵的操作,因为所有这些步骤都是惰性操作。实际的工作从终端操作开始,并且取决于实际的终端操作,可能会执行完全不同的代码。

这种方法的作者将由您自己决定,两次调用该方法意味着什么:它会重现与为未修改数组或集合创建的流完全相同的序列,还是会产生带有语义相似,但元素不同,例如随机整数流或控制台输入行流等。


顺便说一下,为了避免混淆,终端操作消耗Stream是从不同的闭合Stream作为调用close()流上不(这是需要的流具有相关联的喜欢的产生,例如资源Files.lines())。


这似乎很混乱,从误导的比较茎IEnumerableStream。An IEnumerable表示提供实际值的能力IEnumerator,因此类似于IterableJava中的。相反,a Stream是一种迭代器,可与a媲美,IEnumerator因此断言这种数据类型可以在.NET中多次使用是错误的,对此的支持IEnumerator.Reset是可选的。这里讨论的示例使用了一个事实,IEnumerable可以使用an 来获取new, IEnumerator并且该Java也可以与Java一起使用Collection。你可以得到一个新的Stream。如果Java开发人员决定直接将Stream操作添加到Iterable,中间操作将返回另一个操作Iterable,它确实具有可比性,并且可以以相同的方式工作。

但是,开发人员对此表示反对,并且在此问题中讨论了决定。最大的问题是对急切的Collection操作和惰性Stream操作的困惑。通过查看.NET API,我(是的,个人而言)发现它是合理的。虽然IEnumerable单独看看上去很合理,但是特定的Collection将有很多直接操作Collection的方法,并且有许多方法返回lazy IEnumerable,而方法的特殊性质并不总是可以直观地识别出来的。我发现(在我看了几分钟后)最糟糕的例子是,List.Reverse()它的名称与继承的名称完全匹配(这是扩展方法的正确终点吗?),Enumerable.Reverse()却具有完全矛盾的行为。


当然,这是两个不同的决定。第一个使Stream类型不同于Iterable/ 的类型Collection,第二个使Stream一种一次性迭代器而不是另一种迭代器。但是这些决定是一起做出的,可能是从未考虑过将这两个决定分开考虑的情况。创建它的初衷并不是与.NET相提并论。

API的实际设计决定是添加一种改进的迭代器类型SpliteratorSpliterator可以由旧的Iterables(这是对它们进行改装的方式)或全新的实现来提供。然后,Stream作为较高级别的前端被添加到较低级别的Spliterators中。而已。您可能会讨论不同的设计是否会更好,但是鉴于他们现在的设计方式,这不会提高生产力,并且不会改变。

您还需要考虑另一个实现方面。Streams 不是不变的数据结构。每个中间操作都可以返回一个Stream封装了旧实例的新实例,但是它也可以替代地操纵自己的实例并返回自身(这并不排除对同一操作都执行)。众所周知的示例是类似parallel或的操作unordered,它们不会添加其他步骤,而是会操纵整个管道。具有如此可变的数据结构并尝试重用(甚至更糟的是,同时使用多次)效果不佳……


为了完整起见,这是您的快速排序示例,已转换为Java StreamAPI。它表明它并没有真正“夺走很多能量”。

static Stream<Integer> quickSort(Supplier<Stream<Integer>> ints) {

  final Optional<Integer> optPivot = ints.get().findAny();
  if(!optPivot.isPresent()) return Stream.empty();

  final int pivot = optPivot.get();

  Supplier<Stream<Integer>> lt = ()->ints.get().filter(i -> i < pivot);
  Supplier<Stream<Integer>> gt = ()->ints.get().filter(i -> i > pivot);

  return Stream.of(quickSort(lt), Stream.of(pivot), quickSort(gt)).flatMap(s->s);
}

它可以像

List<Integer> l=new Random().ints(100, 0, 1000).boxed().collect(Collectors.toList());
System.out.println(l);
System.out.println(quickSort(l::stream)
    .map(Object::toString).collect(Collectors.joining(", ")));

您可以将其编写得更加紧凑

static Stream<Integer> quickSort(Supplier<Stream<Integer>> ints) {
    return ints.get().findAny().map(pivot ->
         Stream.of(
                   quickSort(()->ints.get().filter(i -> i < pivot)),
                   Stream.of(pivot),
                   quickSort(()->ints.get().filter(i -> i > pivot)))
        .flatMap(s->s)).orElse(Stream.empty());
}

1
好吧,无论是否使用,尝试再次使用它都会引发流已关闭而不是未使用的异常。正如您所说,关于重置随机整数流的问题,取决于库的编写者来定义重置操作的确切约定。
Vitaliy 2015年

2
不,消息是“流已被操作或关闭”,我们不是在谈论“重置”操作,而是在调用两个或多个终端操作,Stream而这Spliterator暗示了源的重置。而且我非常确定这是否可能,例如“为什么count()两次在a 上调用两次Stream都会给出不同的结果”之类的问题,等等……
Holger 2015年

1
count()给出不同的结果是绝对有效的。count()是对流的查询,如果该流是可变的(或更确切地说,该流表示对可变集合的查询结果),则可以预期。看看C#的API。他们优雅地处理了所有这些问题。
Vitaliy 2015年

4
您所谓的“绝对有效”是违反直觉的行为。毕竟,这是询问多次使用流以不同方式处理结果(预期是相同的)的主要动机。Stream到目前为止,关于SO的不可重用性的每个问题都源于试图通过多次调用终端操作(显然,否则您不会注意到)来解决问题的尝试,如果StreamAPI允许的话,这将导致一个默默无闻的解决方案每次评估都有不同的结果。这是一个很好的例子
Holger 2015年

3
实际上,您的示例完美地演示了如果程序员不了解应用多个终端操作的含义会发生什么。只需考虑将这些操作中的每一个应用于一组完全不同的元素时会发生什么。仅当流的源在每个查询中返回相同的元素时,此方法才有效,但这完全是我们所讨论的错误假设。
Holger 2015年

8

当您仔细观察时,我认为两者之间几乎没有区别。

从表面上看,IEnumerable确实确实是可重用的构造:

IEnumerable<int> numbers = new int[] { 1, 2, 3, 4, 5 };

foreach (var n in numbers) {
    Console.WriteLine(n);
}

但是,编译器实际上正在做一些工作来帮助我们。它生成以下代码:

IEnumerable<int> numbers = new int[] { 1, 2, 3, 4, 5 };

IEnumerator<int> enumerator = numbers.GetEnumerator();
while (enumerator.MoveNext()) {
    Console.WriteLine(enumerator.Current);
}

每次实际迭代可枚举时,编译器都会创建一个枚举器。枚举器不可重用;进一步的调用MoveNext只会返回false,并且无法将其重置为开始。如果要再次遍历数字,则需要创建另一个枚举器实例。


为了更好地说明IEnumerable具有(可以具有)与Java Stream相同的“功能”,请考虑一个其数字来源不是静态集合的枚举。例如,我们可以创建一个可枚举的对象,该对象生成5个随机数的序列:

class Generator : IEnumerator<int> {
    Random _r;
    int _current;
    int _count = 0;

    public Generator(Random r) {
        _r = r;
    }

    public bool MoveNext() {
        _current= _r.Next();
        _count++;
        return _count <= 5;
    }

    public int Current {
        get { return _current; }
    }
 }

class RandomNumberStream : IEnumerable<int> {
    Random _r = new Random();
    public IEnumerator<int> GetEnumerator() {
        return new Generator(_r);
    }
    public IEnumerator IEnumerable.GetEnumerator() {
        return this.GetEnumerator();
    }
}

现在,我们具有与以前的基于数组的可枚举非常相似的代码,但是在第二个迭代之上numbers

IEnumerable<int> numbers = new RandomNumberStream();

foreach (var n in numbers) {
    Console.WriteLine(n);
}
foreach (var n in numbers) {
    Console.WriteLine(n);
}

第二次迭代时,numbers我们将获得不同的数字序列,这在相同意义上是不可重用的。或者,RandomNumberStream如果您尝试对其进行多次遍历,则我们可能已经编写了引发异常,从而使可枚举实际上无法使用(例如Java Stream)。

此外,将基于枚举的快速排序应用于RandomNumberStream什么意味着什么?


结论

因此,最大的不同是.NET允许您在需要访问序列中的元素时IEnumerable通过IEnumerator在后台隐式创建一个新变量来重用an 。

这种隐式行为通常很有用(如您所说的那样“强大”),因为我们可以反复遍历一个集合。

但是有时,这种隐式行为实际上可能会引起问题。如果您的数据源不是静态的,或者访问成本很高(例如数据库或网站),则IEnumerable必须放弃许多假设。重用不是那么简单


2

可以绕过Stream API中的某些“一次性运行”保护;例如,我们可以java.lang.IllegalStateException通过引用和重用Spliterator(而不是Stream直接)避免异常(出现消息“流已被操作或关闭” )。

例如,此代码将运行而不会引发异常:

    Spliterator<String> split = Stream.of("hello","world")
                                      .map(s->"prefix-"+s)
                                      .spliterator();

    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);


    replayable1.forEach(System.out::println);
    replayable2.forEach(System.out::println);

但是输出将限于

prefix-hello
prefix-world

而不是重复输出两次。这是因为ArraySpliterator用作Stream源是有状态的,并存储其当前位置。当我们重播此内容时,Stream我们将从结尾重新开始。

我们有多种解决方案来解决这一挑战:

  1. 我们可以使用无状态Stream创建方法,例如Stream#generate()。我们将不得不在自己的代码中外部管理状态,并在Stream“重播” 之间重置:

    Spliterator<String> split = Stream.generate(this::nextValue)
                                      .map(s->"prefix-"+s)
                                      .spliterator();
    
    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);
    
    
    replayable1.forEach(System.out::println);
    this.resetCounter();
    replayable2.forEach(System.out::println);
  2. 另一个解决方案(稍好一点,但不是完美的)是编写我们自己的ArraySpliterator(或类似的Stream源代码),其中包括一些重置当前计数器的能力。如果我们使用它来生成,Stream我们可能会成功地重播它们。

    MyArraySpliterator<String> arraySplit = new MyArraySpliterator("hello","world");
    Spliterator<String> split = StreamSupport.stream(arraySplit,false)
                                            .map(s->"prefix-"+s)
                                            .spliterator();
    
    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);
    
    
    replayable1.forEach(System.out::println);
    arraySplit.reset();
    replayable2.forEach(System.out::println);
  3. 解决此问题的最佳方法(我认为)是SpliteratorStream上调用新的运算符时,对管道中使用的所有有状态s 进行新的复制Stream。这更加复杂并且实现起来很麻烦,但是如果您不介意使用第三方库,cyclops-reactStream实现可以做到这一点。(公开:我是该项目的首席开发人员。)

    Stream<String> replayableStream = ReactiveSeq.of("hello","world")
                                                 .map(s->"prefix-"+s);
    
    
    
    
    replayableStream.forEach(System.out::println);
    replayableStream.forEach(System.out::println);

这将打印

prefix-hello
prefix-world
prefix-hello
prefix-world

如预期的那样。

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.