我有一些关于Streams API早期设计的回忆,这可能会阐明设计理念。在2012年,我们正在为语言添加lambda表达式,并且我们想要使用lambda编程的面向集合或“批量数据”操作,以便促进并行性。到这个时候,惰性地将操作链接在一起的想法已经得到了很好的发展。我们也不希望中间操作存储结果。我们需要决定的主要问题是API中链中的对象长什么样子,以及它们如何与数据源连接。这些源通常是集合,但我们还希望支持来自文件或网络的数据,或者实时生成的数据,例如从随机数生成器中生成的数据。
设计受到了现有工作的许多影响,其中最具影响力的是Google的Guava库和Scala集合库。(如果有人对Guava的影响感到惊讶,请注意Kevin Bourrillion,Guava主要开发者,曾参与JSR-335 Lambda专家组。) 关于Scala集合库,我们发现Martin Odersky的这个演讲特别有意义: Future-Proofing Scala Collections: from Mutable to Persistent to Parallel。(Stanford EE380,2011年6月1日。)
我们当时的原型设计基于
Iterable
。熟悉的操作,例如
filter
、
map
等等,默认都是扩展方法,并且在
Iterable
上实现。调用其中一个操作会将该操作添加到链中,并返回另一个
Iterable
。像
count
这样的终端操作会向上遍历整个链,直到源头调用
iterator()
,并在每个阶段的迭代器中实现这些操作。
由于这些对象是 Iterable,因此您可以多次调用
iterator()
方法。那么会发生什么呢?
如果源对象是一个集合,这通常运行良好。集合是可迭代的,每次调用
iterator()
都会生成一个不同的独立迭代器实例,每个迭代器都可以独立地遍历集合,非常棒。
现在,如果源是一次性的,比如从文件中读取行,该怎么办?也许第一个迭代器应该获取所有值,但第二个及以后的迭代器应该为空。也许这些值应该在迭代器之间交替。或者每个迭代器都应该获取相同的值。那么,如果你有两个迭代器,其中一个迭代器超前于另一个迭代器会发生什么?某人将不得不缓冲第二个迭代器中的值,直到它们被读取。更糟糕的是,如果你先获得一个迭代器并读取了所有的值,然后才获得第二个迭代器,那么现在的值来自哪里?是否需要将它们全部缓冲起来,以防万一有人想要第二个迭代器?
显然,允许对一次性源进行多次迭代引发了很多问题。我们没有好的答案。我们希望在调用
iterator()
两次时发生什么具有一致、可预测的行为。这促使我们禁止多次遍历,使管道成为一次性的。
我们还观察到其他人遇到了这些问题。在JDK中,大多数可迭代对象都是集合或类似于集合的对象,允许多次遍历。虽然没有任何地方指定,但似乎有一种未写明的期望,即可迭代对象允许多次遍历。一个值得注意的例外是NIO
DirectoryStream接口。其规范包括以下有趣的警告:
尽管DirectoryStream扩展了Iterable,但它不是通用的Iterable,因为它仅支持单个Iterator;调用iterator方法以获取第二个或后续的iterator会抛出IllegalStateException。
[原文中加粗]
这似乎很不寻常和不愉快,我们不想创建一堆可能只能使用一次的新可迭代对象。这使我们远离使用Iterable。
大约在此时,Bruce Eckel的一篇文章描述了他在Scala中遇到的麻烦。他编写了这段代码:
val lines = fromString(data).getLines
val registrants = lines.map(Registrant)
registrants.foreach(println)
registrants.foreach(println)
这很简单。它将文本行解析为Registrant
对象并打印两次。但实际上它只打印了一次。原来他认为registrants
是一个集合,而实际上它是一个迭代器。第二个对foreach
的调用遇到了一个空的迭代器,其中所有值都已经被耗尽,因此没有打印任何内容。
这种经历让我们确信,如果尝试多次遍历,则具有清晰可预测的结果非常重要。它还突显了区分类似于流水线的惰性结构和存储数据的实际集合之间的重要性。这反过来推动了将惰性流操作分离到新的Stream接口中,并仅在Collections上直接进行急切的、可变的操作。Brian Goetz已经解释了这样做的原因。
那么允许基于集合的管道进行多次遍历,但不允许非基于集合的管道进行多次遍历呢?这是不一致的,但是是明智的。如果你从网络中读取值,当然你无法再次遍历它们。如果您想多次遍历它们,必须显式地将它们拉入集合中。
但是让我们探索允许从基于集合的管道进行多次遍历。假设你这样做:
Iterable<?> it = source.filter(...).map(...).filter(...).map(...);
it.into(dest1);
it.into(dest2);
如果源对象是一个集合,那么第一次使用into()
方法会创建一个从源对象到目标对象的迭代器链,执行流水线操作,并将结果发送到目标对象。第二次使用into()
方法将再次创建一个迭代器链,并再次执行流水线操作。这个过程看似没有问题,但实际上会导致过滤和映射操作对每个元素都进行两次。我认为很多程序员都会对这种行为感到惊讶。
正如我之前提到的,我们一直在与Guava开发人员交流。他们拥有一个很酷的Idea Graveyard,其中描述了他们决定不实现的功能以及原因。懒加载集合的想法听起来很酷,但以下是他们对它的评价。考虑一个返回List
的List.filter()
操作:
这里最大的问题是太多操作变成了昂贵的线性时间命题。如果您想要过滤列表并返回列表,而不仅仅是 Collection 或 Iterable,可以使用
ImmutableList.copyOf(Iterables.filter(list, predicate))
,它“提前声明”了它正在做什么以及它的代价。
以一个具体的例子来说,List 上的
get(0)
或
size()
的成本是多少?对于像 ArrayList 这样常用的类,它们的复杂度是 O(1)。但是,如果您在一个懒惰过滤的列表上调用其中之一,它必须在后台列表上运行过滤器,所有这些操作都会变成 O(n)。更糟糕的是,它必须在每次操作时遍历后台列表。
这对我们来说似乎是过于懒惰了。设置一些操作并推迟实际执行直到您点击“Go”是一回事,以一种隐藏了潜在的大量重新计算的方式设置东西则是另一回事。
在建议禁止非线性或“不可重用”流时,
Paul Sandoz描述了允许它们的
潜在后果会导致“意外或混乱的结果”。他还提到并行执行会使事情变得更加棘手。最后,我想补充一点,如果具有副作用的管道操作意外多次执行,或者至少与程序员预期的次数不同,将导致困难和晦涩的错误。 (但是Java程序员不会编写带有副作用的lambda表达式,对吧?他们会吗?)
这就是Java 8 Streams API设计的基本原理,它允许一次性遍历,并要求严格线性(无分支)管道。它在多个不同的流源之间提供一致的行为,清楚地区分惰性和急切操作,并提供一个简单直观的执行模型。
关于IEnumerable,我对C#和.NET远非专家,因此如果我得出任何不正确的结论,我将感激得到(温柔地)纠正。然而,它似乎允许多次遍历在不同的源上表现不同; 它允许嵌套IEnumerable操作的分支结构,这可能导致一些重要的重新计算。虽然我欣赏不同的系统做出不同的权衡,但这是我们在设计Java 8 Streams API时试图避免的两个特征。
OP给出的快速排序示例很有趣,令人困惑,并且我很抱歉说,有些可怕。调用QuickSort需要一个IEnumerable并返回一个IEnumerable,因此直到最终的IEnumerable被遍历之前,实际上并没有进行任何排序。然而,调用似乎建立了一个反映快速排序所做分区的IEnumerable的树形结构,而不实际执行它。 (毕竟这是惰性计算)。如果源具有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
可以用这种方式构建复杂的计算结构。但是,如果它真的增加了计算复杂度,就像我认为的那样,似乎应该避免以这种方式编程,除非极其小心。
IEnumerable
与java.io.*
的流相关联。 - SpaceTrucker