我从 Streams API 的早期设计中得到了一些回忆,这些回忆可能会阐明设计原理。
早在 2012 年,我们就在该语言中添加了 lambda,我们想要一个面向集合或“批量数据”的操作集,使用 lambda 进行编程,以促进并行性。懒惰地将操作链接在一起的想法在这一点上已经确立。我们也不希望中间操作存储结果。
我们需要决定的主要问题是链中的对象在API中的样子以及它们如何连接到数据源。源通常是集合,但我们也希望支持来自文件或网络的数据,或者动态生成的数据,例如,来自随机数生成器的数据。
现有工作对设计有许多影响。其中比较有影响力的是谷歌的番石榴图书馆和斯卡拉馆藏库。(如果有人对番石榴的影响感到惊讶,请注意,番石榴首席开发人员Kevin Bourrillion是JSR-335 Lambda专家组的成员。关于 Scala 集合,我们发现 Martin Odersky 的这个演讲特别有趣:Future-ProofIng Scala Collections: From Mutable to Persistent to Parallel。(斯坦福大学EE380,2011年6月1日。
我们当时的原型设计是基于 .熟悉的操作 、等是 上的扩展(默认)方法。调用一个操作将操作添加到链中并返回另一个 。像终端操作这样的终端操作会将链调用到源,并且操作在每个阶段的迭代器中实现。Iterable
filter
map
Iterable
Iterable
count
iterator()
由于这些是可迭代的,因此您可以多次调用该方法。那么应该发生什么呢?iterator()
如果源是集合,则这通常工作正常。集合是可迭代的,每次调用都会生成一个独立于任何其他活动实例的不同迭代器实例,并且每个实例都独立遍历集合。伟大。iterator()
现在,如果源是一次性的,比如从文件中读取行,该怎么办?也许第一个迭代器应该获取所有值,但第二个和后续迭代器应该是空的。也许这些值应该在迭代器之间交错。或者,也许每个迭代器应该获得所有相同的值。那么,如果你有两个迭代器,其中一个比另一个更远,该怎么办?有人将不得不缓冲第二个迭代器中的值,直到它们被读取。更糟糕的是,如果你得到一个迭代器并读取所有值,然后才得到第二个迭代器,该怎么办?这些值从何而来?是否要求将它们全部缓冲起来,以防有人想要第二个迭代器?
显然,允许对一次性源进行多个迭代器会引发很多问题。我们没有给他们很好的答案。我们希望您拨打两次电话时发生一致、可预测的行为。这促使我们禁止多次遍历,使管道成为一次性的。iterator()
我们还观察到其他人遇到了这些问题。在JDK中,大多数迭代对象都是集合或类似集合的对象,它们允许多次遍历。它没有在任何地方指定,但似乎有一个不成文的期望,即迭代允许多次遍历。一个值得注意的例外是NIO DirectoryStream接口。它的规范包括这个有趣的警告:
虽然 DirectoryStream 扩展了 Iterable,但它不是通用的 Iterable,因为它只支持一个 Iterator;调用迭代器方法以获取第二个或后续迭代器将引发 IllegalStateException。
[原文粗体]
这似乎很不寻常,而且非常不愉快,以至于我们不想创建一大堆可能只有一次的新迭代。这促使我们放弃了使用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 接口中,并仅将急切的突变操作直接保留在集合上。布莱恩·戈茨(Brian Goetz)解释了这样做的理由。
允许对基于集合的管道进行多次遍历,但不允许对非基于集合的管道进行多次遍历,该怎么办?这是不一致的,但它是明智的。如果要从网络读取值,当然无法再次遍历它们。如果要多次遍历它们,则必须将它们显式拉入集合中。
但是,让我们探讨一下允许从基于集合的管道进行多次遍历。假设您执行了以下操作:
Iterable<?> it = source.filter(...).map(...).filter(...).map(...);
it.into(dest1);
it.into(dest2);
(该操作现在拼写为 。)into
collect(toList())
如果 source 是一个集合,则第一个调用将创建一个返回到源的迭代器链,执行管道操作,并将结果发送到目标。第二次调用 将创建另一个迭代器链,并再次执行管道操作。这并没有明显的错误,但它确实具有为每个元素第二次执行所有过滤器和映射操作的效果。我想很多程序员都会对这种行为感到惊讶。into()
into()
正如我上面提到的,我们一直在与番石榴的开发人员交谈。他们拥有的一件很酷的事情是Idea Graveyard,他们描述了他们决定不实现的功能以及原因。懒惰集合的想法听起来很酷,但这是他们对此的看法。考虑一个返回 :List.filter()
List
这里最大的担忧是,太多的操作会成为昂贵的线性时间命题。如果要过滤列表并获取列表,而不仅仅是集合或可迭代列表,则可以使用 ,它“预先声明”它正在做什么以及它有多昂贵。ImmutableList.copyOf(Iterables.filter(list, predicate))
举一个具体的例子,列表的成本是多少?对于像 这样常用的类,它们是 O(1)。但是,如果您在懒惰过滤的列表中调用其中一个,它必须对支持列表运行过滤器,突然之间,这些操作是O(n)。更糟糕的是,它必须遍历每个操作的后备列表。get(0)
size()
ArrayList
在我们看来,这太懒惰了。设置一些操作并推迟实际执行是一回事,直到您如此“开始”。以隐藏可能大量重新计算的方式进行设置是另一回事。
在提议禁止非线性或“不重用”流时,Paul Sandoz将允许它们的潜在后果描述为产生“意外或令人困惑的结果”。他还提到,并行执行将使事情变得更加棘手。最后,我要补充一点,如果具有副作用的管道操作意外执行多次,或者至少与程序员预期的次数不同,则会导致困难和模糊的错误。(但是Java程序员不会编写带有副作用的lambda表达式,不是吗?他们吗??)
因此,这就是Java 8 Streams API设计的基本原理,它允许一次性遍历,并且需要一个严格的线性(无分支)管道。它跨多个不同的流源提供一致的行为,它清楚地将懒惰操作与急切操作分开,并提供一个简单的执行模型。
关于 ,我远非 C# 和 .NET 方面的专家,因此,如果我得出任何不正确的结论,我将不胜感激(温和地)得到纠正。然而,它似乎确实允许多个遍历在不同的来源下具有不同的行为;并且它允许嵌套操作的分支结构,这可能会导致一些重大的重新计算。虽然我很欣赏不同的系统做出不同的权衡,但这是我们在设计Java 8 Streams API时试图避免的两个特征。IEnumerable
IEnumerable
IEnumerable
OP给出的快速排序示例很有趣,令人费解,我很抱歉地说,有些可怕。调用需要 a 并返回 一个 ,因此在遍历最终值之前,实际上不会进行排序。但是,该调用似乎所做的是构建一个树结构,该结构反映了快速排序将要执行的分区,而无需实际执行此操作。(毕竟,这是懒惰计算。如果源有 N 个元素,则树最宽处将是 N 个元素,并且它将是 lg(N) 级别的深度。QuickSort
IEnumerable
IEnumerable
IEnumerable
IEnumerables
在我看来 - 再一次,我不是C#或.NET专家 - 这将导致某些看起来无害的调用,例如通过枢轴选择,比它们看起来更昂贵。当然,在第一个层次上,它是O(1)。但考虑一下树深的一个分区,在右手边。要计算此分区的第一个元素,必须遍历整个源,即 O(N) 操作。但是由于上面的分区是懒惰的,因此必须重新计算它们,需要O(lg N)比较。因此,选择枢轴将是一个O(N lg N)操作,它与整个排序一样昂贵。ints.First()
但是,在我们遍历返回的 .在标准快速排序算法中,每个分区级别都会使分区数加倍。每个分区的大小只有一半,因此每个级别都保持在 O(N) 复杂度。分区树是 O(lg N) 高,所以总功是 O(N lg N)。IEnumerable
对于懒惰的 IEnumerables 树,在树的底部有 N 个分区。计算每个分区需要遍历 N 个元素,每个元素都需要在树上进行 lg(N) 比较。要计算树底部的所有分区,则需要 O(N^2 lg N) 比较。
(这是对的吗?我简直不敢相信。有人请帮我检查一下。
无论如何,以这种方式用于构建复杂的计算结构确实很酷。但是,如果它确实像我认为的那样增加了计算复杂性,那么除非人们非常小心,否则似乎应该避免以这种方式进行编程。IEnumerable