为什么 Java Streams 是一次性的?背景结论

2022-08-31 05:36:34

与C#不同,在执行管道可以根据需要执行任意次数的情况下,在Java中,流只能“迭代”一次。IEnumerable

对终端操作的任何调用都会关闭流,使其不可用。这个“功能”带走了很多权力。

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

编辑:为了演示我正在谈论的内容,请考虑以下C#中快速排序的实现:

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中完成!我甚至不能在不使其不可用的情况下询问流是否为空。


答案 1

我从 Streams API 的早期设计中得到了一些回忆,这些回忆可能会阐明设计原理。

早在 2012 年,我们就在该语言中添加了 lambda,我们想要一个面向集合或“批量数据”的操作集,使用 lambda 进行编程,以促进并行性。懒惰地将操作链接在一起的想法在这一点上已经确立。我们也不希望中间操作存储结果。

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

现有工作对设计有许多影响。其中比较有影响力的是谷歌的番石榴图书馆和斯卡拉馆藏库。(如果有人对番石榴的影响感到惊讶,请注意,番石榴首席开发人员Kevin BourrillionJSR-335 Lambda专家组的成员。关于 Scala 集合,我们发现 Martin Odersky 的这个演讲特别有趣:Future-ProofIng Scala Collections: From Mutable to Persistent to Parallel。(斯坦福大学EE380,2011年6月1日。

我们当时的原型设计是基于 .熟悉的操作 、等是 上的扩展(默认)方法。调用一个操作将操作添加到链中并返回另一个 。像终端操作这样的终端操作会将链调用到源,并且操作在每个阶段的迭代器中实现。IterablefiltermapIterableIterablecountiterator()

由于这些是可迭代的,因此您可以多次调用该方法。那么应该发生什么呢?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)

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

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

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

但是,让我们探讨一下允许从基于集合的管道进行多次遍历。假设您执行了以下操作:

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

(该操作现在拼写为 。)intocollect(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时试图避免的两个特征。IEnumerableIEnumerableIEnumerable

OP给出的快速排序示例很有趣,令人费解,我很抱歉地说,有些可怕。调用需要 a 并返回 一个 ,因此在遍历最终值之前,实际上不会进行排序。但是,该调用似乎所做的是构建一个树结构,该结构反映了快速排序将要执行的分区,而无需实际执行此操作。(毕竟,这是懒惰计算。如果源有 N 个元素,则树最宽处将是 N 个元素,并且它将是 lg(N) 级别的深度。QuickSortIEnumerableIEnumerableIEnumerableIEnumerables

在我看来 - 再一次,我不是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


答案 2

背景

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

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

使用基本概念,C#的概念与Java的Iterable更密切相关,后者能够创建任意数量的迭代器IEnumerables 创建 IEnumerators。Java的创造IEnumerableIterableIterators

每个概念的历史都是相似的,因为两者都有一个基本的动机,允许“for-each”风格在数据收集的成员上循环。这是一种过度简化,因为它们都允许的不仅仅是这个,而且它们也通过不同的进展到达了那个阶段,但无论如何,这是一个重要的共同特征。IEnumerableIterable

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

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

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

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

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

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

在Java中,Streams与Iterables或Iterators不同。

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

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

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

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

为了完成这个概念,你需要一个数据源来馈送流,以及一个使用流的终端函数。Stream

你向流中馈送值的方式实际上可能来自 一个,但序列本身不是一个,它是一个复合函数。IterableStreamIterable

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

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

  • Java中的A是一个转换引擎,它将处于一种状态的数据项转换为另一种状态。Stream
  • 流没有数据顺序或位置的概念,只是简单地转换它们被要求的任何东西。
  • 流可以提供来自许多源的数据,包括其他流,迭代器,可迭代对象,集合,
  • 你不能“重置”一个流,这就像“重新编程转换”。重置数据源可能是您想要的。
  • 从逻辑上讲,在任何时候,流中只有 1 个“正在运行”的数据项(除非流是并行流,此时,每个线程有 1 个项目)。这独立于数据源,数据源可能具有比当前“就绪”要提供给流的更多项目,或者流收集器可能需要聚合和减少多个值。
  • 流可以是未绑定的(无限的),仅受数据源或收集器(也可以是无限的)限制。
  • 流是“可链接的”,过滤一个流的输出是另一个流。输入到流并由流转换的值可以依次提供给执行不同转换的另一个流。处于转换状态的数据从一个流流流向下一个流。您无需干预并从一个流中提取数据并将其插入下一个流。

C# 比较

当您考虑到Java流只是供应,流和收集系统的一部分,并且流和迭代器通常与集合一起使用时,难怪很难与几乎全部嵌入到C#中的单个概念中的相同概念相关联。IEnumerable

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

Java概念可以做一些小事情,在IEnumerable中更难,反之亦然。


结论

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

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

为什么 Java Streams 是一次性的?

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

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

将 a 与 a 进行比较是错误的。您用来表示的上下文可以根据需要执行任意次数,与Java相比是最好的,Java可以根据需要迭代多次。Java表示概念的子集,而不是提供数据的子集,因此不能“重新运行”。IEnumerableStreamIEnumerableIterablesStreamIEnumerable

对终端操作的任何调用都会关闭流,使其不可用。这个“功能”带走了很多权力。

从某种意义上说,第一种说法是正确的。“夺走权力”的说法不是。你仍然在比较Streams it IEnumerables。流中的终端操作类似于 for 循环中的“break”子句。如果您愿意,您可以随时拥有另一个流,并且是否可以重新提供所需的数据。同样,如果您认为对于此语句,它更像是 一个 ,Java 可以很好地做到这一点。IEnumerableIterable

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

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

快速排序示例

您的快速排序示例具有以下签名:

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

您将输入视为数据源:IEnumerable

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

此外,返回值也是,这是数据的供应,并且由于这是一个排序操作,因此该供应的顺序很重要。如果您认为 Java 类与此匹配,特别是 的专用化,因为 List 是具有保证顺序或迭代的数据供应,那么与您的代码等效的 Java 代码将是:IEnumerableIterableListIterable

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 代码如何在不同的点使用数据源 () 和流概念,并且在 C# 中,这两个“个性”可以用 just 表示。另外,尽管我使用作为基本类型,但我可以使用更通用的,并且通过较小的迭代器到流的转换,我可以使用更通用的ListIEnumerableListCollectionIterable


推荐