我对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
。熟悉业务filter
,map
等了对扩展名(默认)方法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
以这种方式来构建复杂的计算结构确实很酷。但是,如果确实像我认为的那样增加了计算复杂性,那么除非特别小心,否则应该避免这种方式的编程。