为什么 Java 流是一次性的?
Why are Java Streams once-off?
与 C# 的 IEnumerable
不同,在 C# 中,执行管道可以执行任意多次,而在 Java 中,一个流只能 'iterated' 一次。
对终端操作的任何调用都会关闭流,使其无法使用。
这个 'feature' 带走了很多力量。
我想这是不是技术原因。这个奇怪的限制背后的设计考虑是什么?
编辑:为了演示我在说什么,请考虑在 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 无法完成!
我什至不能在不使其无法使用的情况下询问流是否为空。
背景
虽然问题看起来很简单,但实际答案需要一些背景知识才能理解。如果您想跳到结论,请向下滚动...
选择您的比较点 - 基本功能
使用基本概念,C# 的 IEnumerable
概念与 Java's Iterable
, which is able to create as many Iterators as you want. IEnumerables
create IEnumerators
的关系更为密切。 Java 的 Iterable
创建 Iterators
每个概念的历史都是相似的,因为 IEnumerable
和 Iterable
都有一个基本动机,即允许 'for-each' 样式循环遍历数据集合的成员。这是过于简单化了,因为它们都允许的不仅仅是这些,而且它们也是通过不同的进程到达那个阶段的,但无论如何这是一个重要的共同特征。
让我们比较一下这个特性:在两种语言中,如果 class 实现了 IEnumerable
/Iterable
,那么 class 必须至少实现一个方法(对于 C#,它是 GetEnumerator
,对于 Java,它是 iterator()
)。在每种情况下,从 (IEnumerator
/Iterator
) 编辑的实例 return 允许您访问数据的当前和后续成员。此功能用于 for-each 语言语法。
选择您的比较点 - 增强功能
C# 中的 IEnumerable
已扩展为允许许多其他语言功能 (mostly related to Linq)。添加的功能包括选择、投影、聚合等。这些扩展在集合论中的使用具有很强的动机,类似于 SQL 和关系数据库概念。
Java 8 还添加了一些功能,可以使用 Streams 和 Lambdas 进行一定程度的函数式编程。请注意,Java 8 流的主要动机不是集合论,而是函数式编程。无论如何,有很多相似之处。
所以,这是第二点。对 C# 所做的增强是作为对 IEnumerable
概念的增强来实现的。不过,在 Java 中,所做的增强是通过创建 Lambdas 和 Streams 的新基本概念,然后还创建了一种相对简单的方法来从 Iterators
和 Iterables
转换为 Streams 来实现的,并且反之亦然。
因此,将 IEnumerable 与 Java 的 Stream 概念进行比较是不完整的。您需要将它与 Java.
中组合的流和集合 API 进行比较
在 Java 中,Streams 与 Iterables 或 Iterators 不同
流的设计目的与迭代器不同:
- 迭代器是描述数据序列的一种方式。
- 流是一种描述数据转换序列的方式。
使用Iterator
,你得到一个数据值,处理它,然后得到另一个数据值。
使用 Streams,您可以将一系列函数链接在一起,然后将输入值提供给流,并从组合序列中获取输出值。请注意,在 Java 术语中,每个函数都封装在一个 Stream
实例中。 Streams API 允许您以链接一系列转换表达式的方式 link 一系列 Stream
个实例。
为了完成 Stream
概念,您需要一个数据源来提供流,以及一个使用流的终端函数。
您将值输入流的方式实际上可能来自 Iterable
,但 Stream
序列本身不是 Iterable
,它是一个复合函数。
A Stream
也被设计成懒惰的,因为它只在您向它请求值时才起作用。
注意 Streams 的这些重要假设和特征:
- Java中的一个
Stream
是一个转换引擎,它将一个状态的数据项转换为另一种状态。
- 流没有数据顺序或位置的概念,只需简单地转换它们被要求的任何内容。
- 流可以从许多来源提供数据,包括其他流、迭代器、迭代器、集合,
- 你不能“重置”流,那就像“重新编程转换”。重置数据源可能就是你想要的。
- 任何时候流中逻辑上只有 1 个数据项 'in flight'(除非流是并行流,此时每个线程有 1 个数据项)。这与数据源无关,数据源可能有超过当前项目 'ready' 提供给流,或者流收集器可能需要聚合和减少多个值。
- 流可以是未绑定的(无限的),仅受数据源或收集器(也可以是无限的)限制。
- 流是'chainable',过滤一个流的输出是另一个流。输入到流并由流转换的值可以依次提供给进行不同转换的另一个流。处于转换状态的数据从一个流流向下一个流。您无需干预并从一个流中提取数据并将其插入到下一个流中。
C# 比较
当您认为 Java Stream 只是供应、流和收集系统的一部分,并且 Streams 和 Iterators 经常与 Collections 一起使用时,也就不足为奇了很难与几乎全部嵌入到 C# 中的单个 IEnumerable
概念中的相同概念相关。
IEnumerable 的部分内容(以及密切相关的概念)在所有 Java 迭代器、迭代器、Lambda 和流概念中都很明显。
Java 概念可以做的一些小事情在 IEnumerable 中比较难,反之亦然。
结论
- 这里没有设计问题,只是语言之间概念匹配的问题。
- Streams 以不同的方式解决问题
- Streams 向 Java 添加功能(它们添加了不同的做事方式,它们并没有去除功能)
添加 Streams 可以让您在解决问题时有更多选择,class可以公平地定义为 'enhancing power',而不是 'reducing'、'taking away' 或 'restricting'它。
为什么 Java 流是一次性的?
这个问题被误导了,因为流是函数序列,而不是数据。根据提供流的数据源,您可以重置数据源,并提供相同或不同的流。
与 C# 的 IEnumerable 不同,执行管道可以执行任意多次,在 Java 中,流可以 'iterated' 一次。
将 IEnumerable
与 Stream
进行比较是错误的。你用来说 IEnumerable
可以执行任意多次的上下文,与 Java Iterables
相比是最好的,它可以迭代任意多次。 Java Stream
表示 IEnumerable
概念的子集,而不是提供数据的子集,因此不能 'rerun'.
对终端操作的任何调用都会关闭流,使其无法使用。这'feature'带走了很多力量。
从某种意义上说,第一个陈述是正确的。 'takes away power' 语句不是。你还在比较 Streams 它的 IEnumerables。流中的终端操作就像 for 循环中的 'break' 子句。如果您愿意,并且可以重新提供所需的数据,您始终可以自由使用另一个流。同样,如果您认为 IEnumerable
更像是 Iterable
,对于此语句,Java 就可以了。
我想这不是技术原因。这个奇怪的限制背后的设计考虑是什么?
原因是技术性的,原因很简单,Stream 是它所认为的子集。流子集不控制数据供应,因此您应该重置供应,而不是流。在那种情况下,它并不奇怪。
快速排序示例
您的快速排序示例具有签名:
IEnumerable<int> QuickSort(IEnumerable<int> ints)
您正在将输入 IEnumerable
视为数据源:
IEnumerable<int> lt = ints.Where(i => i < pivot);
此外,return 的值也是IEnumerable
,这是一个数据源,并且由于这是一个排序操作,所以该源的顺序很重要。如果您认为 Java Iterable
class 是合适的匹配项,特别是 Iterable
的 List
专业化,因为 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));
}
请注意有一个错误(我已重现),因为排序不能很好地处理重复值,它是 'unique value' 排序。
还要注意 Java 代码如何使用数据源 (List
),以及不同点的流概念,在 C# 中,这两个 'personalities' 可以用 IEnumerable
。此外,虽然我使用 List
作为基本类型,但我本可以使用更通用的 Collection
,并且通过一个小的迭代器到流的转换,我本可以使用更通用的 Iterable
Stream
s 是围绕 Spliterator
s 构建的,它们是有状态的、可变的对象。他们没有“重置”动作,事实上,要求支持这种倒带动作会“带走很多力量”。 Random.ints()
应该如何处理这样的请求?
另一方面,对于具有可追溯来源的 Stream
,很容易构造一个等价的 Stream
以供再次使用。只需将构建 Stream
的步骤放入可重用的方法中即可。请记住,重复这些步骤并不是一项昂贵的操作,因为所有这些步骤都是惰性操作;实际工作从终端操作开始,根据实际终端操作,可能会执行完全不同的代码。
作为此类方法的作者,您可以指定两次调用该方法意味着什么:它是否重现完全相同的序列,就像为未修改的数组或集合创建的流一样,还是生成具有相似语义但元素不同的流,例如随机整数流或控制台输入行流等。
顺便说一下,为了避免混淆,终端操作 消耗 Stream
不同于 关闭 Stream
就像在流上调用 close()
一样(这对于具有关联资源的流是必需的,例如由 Files.lines()
生成)。
似乎很多混淆源于 IEnumerable
与 Stream
的误导性比较。 IEnumerable
表示提供实际 IEnumerator
的能力,因此它类似于 Java 中的 Iterable
。相反,Stream
是一种迭代器,可与 IEnumerator
相媲美,因此声称这种数据类型可以在 .NET 中多次使用是错误的,对 [=30= 的支持] 是可选的。此处讨论的示例使用了 IEnumerable
可用于获取 new IEnumerator
s 并且与 Java 一起使用的事实 Collection
s 也是如此;你可以获得一个新的Stream
。如果 Java 开发人员决定将 Stream
操作直接添加到 Iterable
,中间操作 return 另一个 Iterable
,它确实具有可比性并且可以工作同理
然而,开发人员决定反对它,并且在 this question. The biggest point is the confusion about eager Collection operations and lazy Stream operations. By looking at the .NET API, I (yes, personally) find it justified. While it looks reasonable looking at IEnumerable
alone, a particular Collection will have lots of methods manipulating the Collection directly and lots of methods returning a lazy IEnumerable
, while the particular nature of a method isn’t always intuitively recognizable. The worst example I found (within the few minutes I looked at it) is List.Reverse()
whose name matches exactly the name of the inherited (is this the right terminus for extension methods?) Enumerable.Reverse()
中讨论了该决定,同时具有完全矛盾的行为。
当然,这是两个截然不同的决定。第一个使 Stream
成为不同于 Iterable
/Collection
的类型,第二个使 Stream
成为一种一次性迭代器而不是另一种可迭代的。但是这些决定是一起做出的,可能从未考虑过将这两个决定分开。创建它时并未考虑与 .NET 相媲美。
实际的 API 设计决策是添加改进类型的迭代器,即 Spliterator
。 Spliterator
s 可以由旧的 Iterable
s 提供(这是对它们进行改造的方式)或全新的实现。然后,Stream
作为高级前端添加到相当低级别的 Spliterator
中。而已。您可能会讨论不同的设计是否会更好,但这不会产生效果,它不会改变,因为它们现在的设计方式。
您还必须考虑另一个实施方面。 Stream
是 而不是 不可变数据结构。每个中间操作可能 return 一个新的 Stream
封装旧实例的实例,但它也可以操纵自己的实例而不是 return 本身(这并不排除为同一操作同时执行这两个操作).众所周知的例子是像 parallel
或 unordered
这样的操作,它们不添加另一个步骤而是操纵整个管道)。拥有这样一个可变的数据结构并尝试重用(或者更糟的是,同时多次使用它)并不能很好地发挥作用……
为了完整起见,这里将您的快速排序示例翻译成 Java Stream
API。说明它并没有真正“带走多少电量”。
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());
}
当你仔细观察时,我认为两者之间的区别很小。
从表面上看,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
的进一步调用只会 return false,并且无法将其重置为开头。如果您想再次遍历数字,则需要创建另一个枚举器实例。
为了更好地说明 IEnumerable 具有(可以具有)与 Java 流相同的 'feature',请考虑一个其数字源不是静态集合的可枚举。例如,我们可以创建一个生成 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 流)。
此外,当应用于 RandomNumberStream
时,基于可枚举的快速排序意味着什么?
结论
因此,最大的区别在于 .NET 允许您在需要访问序列中的元素时通过在后台隐式创建新的 IEnumerator
来重用 IEnumerable
。
这种隐式行为通常很有用(如您所说 'powerful'),因为我们可以重复迭代一个集合。
但有时,这种隐式行为实际上会导致问题。如果您的数据源不是静态的,或者访问成本很高(例如数据库或网站),那么必须放弃关于 IEnumerable
的许多假设;重用并不是那么简单
我对 Streams 的早期设计有一些回忆 API,这可能会阐明设计原理。
早在 2012 年,我们就在语言中添加了 lambda,我们想要一个面向集合或 "bulk data" 的操作集,使用 lambda 编程,这将促进并行性。懒惰地将操作链接在一起的想法在这一点上得到了很好的确立。我们也不希望中间操作存储结果。
我们需要决定的主要问题是链中的对象在 API 中是什么样子以及它们如何连接到数据源。来源通常是集合,但我们也希望支持来自文件或网络的数据,或即时生成的数据,例如来自随机数生成器的数据。
现有工作对设计有很多影响。其中比较有影响力的是 Google 的 Guava library and the Scala collections library. (If anybody is surprised about the influence from Guava, note that Kevin Bourrillion, Guava lead developer, was on the JSR-335 Lambda expert group.) On Scala collections, we found this talk by Martin Odersky to be of particular interest: Future-Proofing Scala Collections: from Mutable to Persistent to Parallel。 (斯坦福 EE380,2011 年 6 月 1 日。)
我们当时的原型设计是基于 Iterable
。熟悉的操作 filter
、map
等是 Iterable
上的扩展(默认)方法。调用一个向链中添加一个操作并返回另一个 Iterable
。像 count
这样的终端操作会调用 iterator()
链上游到源,并且这些操作在每个阶段的迭代器中实现。
由于这些是 Iterables,您可以多次调用 iterator()
方法。那会发生什么?
如果源是一个集合,这在大多数情况下都可以正常工作。集合是可迭代的,每次调用 iterator()
都会产生一个独立于任何其他活动实例的不同 Iterator 实例,并且每个独立地遍历集合。太棒了。
现在如果源是一次性的,比如从文件中读取行怎么办?也许第一个 Iterator 应该获取所有值,但第二个和后续值应该为空。也许值应该在迭代器之间交错。或者也许每个 Iterator 都应该获得所有相同的值。那么,如果您有两个迭代器并且一个比另一个更早怎么办?有人将不得不缓冲第二个迭代器中的值,直到它们被读取。更糟糕的是,如果您获得一个 Iterator 并读取所有值,并且仅 then 获得第二个 Iterator,该怎么办?现在的价值观从何而来?是否需要将它们都缓冲起来以防万一有人想要第二个迭代器?
显然,在一次性源上允许多个迭代器会引发很多问题。我们没有很好的答案给他们。如果你调用 iterator()
两次,我们想要一致的、可预测的行为。这促使我们禁止多次遍历,使管道一次性完成。
我们还观察到其他人遇到了这些问题。在JDK中,大部分Iterable都是集合或类集合对象,允许多次遍历。它没有在任何地方指定,但似乎有一个不成文的期望 Iterables 允许多次遍历。一个值得注意的例外是 NIO DirectoryStream 接口。它的规范包括这个有趣的警告:
While DirectoryStream extends Iterable, it is not a general-purpose Iterable as it supports only a single Iterator; invoking the iterator method to obtain a second or subsequent iterator throws IllegalStateException.
[原文加粗]
这看起来不寻常且令人不快,以至于我们不想创建一大堆可能只有一次的新 Iterables。这让我们放弃了使用 Iterable。
大约在这个时候,article by Bruce Eckel 出现了,描述了他在使用 Scala 时遇到的一个问题。他写了这段代码:
// Scala
val lines = fromString(data).getLines
val registrants = lines.map(Registrant)
registrants.foreach(println)
registrants.foreach(println)
这很简单。它将文本行解析为 Registrant
个对象并打印两次。除了它实际上只打印一次。结果他认为 registrants
是一个集合,而实际上它是一个迭代器。对 foreach
的第二次调用遇到一个空迭代器,其中所有值都已用完,因此它不打印任何内容。
这种经历让我们相信,如果尝试多次遍历,获得清晰可预测的结果非常重要。它还强调了将类似惰性管道的结构与存储数据的实际集合区分开来的重要性。这反过来又推动了将惰性管道操作分离到新的 Stream 接口中,并直接在 Collections 上保留急切的、可变的操作。 Brian Goetz has explained 这样做的理由。
允许对基于集合的管道进行多次遍历但不允许对非基于集合的管道进行多次遍历呢?这是不一致的,但它是明智的。如果您正在从网络中读取值,当然您不能再次遍历它们。如果你想多次遍历它们,你必须显式地将它们拉入一个集合。
但让我们探索允许从基于集合的管道进行多次遍历。假设您这样做了:
Iterable<?> it = source.filter(...).map(...).filter(...).map(...);
it.into(dest1);
it.into(dest2);
(into
操作现在拼写为 collect(toList())
。)
如果源是一个集合,那么第一个 into()
调用将创建一个返回源的迭代器链,执行管道操作,并将结果发送到目标。第二次调用 into()
将创建另一个迭代器链,并再次执行管道操作 。这显然不是错误的,但它确实具有对每个元素第二次执行所有过滤器和映射操作的效果。我想很多程序员都会对这种行为感到惊讶。
正如我上面提到的,我们一直在与 Guava 开发人员交谈。他们拥有的一件很酷的东西是 Idea Graveyard,他们在其中描述了他们决定 不 实施的功能以及原因。惰性集合的想法听起来很酷,但这是他们不得不说的。考虑一个 List.filter()
操作 returns a List
:
The biggest concern here is that too many operations become expensive, linear-time propositions. If you want to filter a list and get a list back, and not just a Collection or an Iterable, you can use ImmutableList.copyOf(Iterables.filter(list, predicate))
, which "states up front" what it's doing and how expensive it is.
举个具体的例子,一个List上get(0)
或者size()
的成本是多少?对于像 ArrayList
这样常用的 类,它们是 O(1)。但是,如果您在惰性过滤列表中调用其中一个,它必须 运行 对后备列表进行过滤,突然间这些操作就变成了 O(n)。更糟糕的是,它必须在 每个 操作时遍历支持列表。
在我们看来,这太过懒惰了。设置一些操作并推迟实际执行是一回事 "Go"。以隐藏潜在大量重新计算的方式进行设置是另一回事。
在提议禁止非线性或 "no-reuse" 流时,Paul Sandoz described the potential consequences 允许它们产生 "unexpected or confusing results." 他还提到并行执行会使事情变得更加棘手。最后,我要补充一点,如果操作意外执行多次,或者至少执行次数与程序员预期的次数不同,则具有副作用的管道操作会导致难以理解的错误。 (但是 Java 程序员不会写有副作用的 lambda 表达式,对吗?他们会写吗??)
这就是 Java 8 Streams API 设计的基本原理,它允许一次性遍历并且需要严格的线性(无分支)管道。它在多个不同的流源之间提供一致的行为,它清楚地将惰性操作与急切操作区分开来,并且它提供了一个简单的执行模型。
关于 IEnumerable
,我远不是 C# 和 .NET 方面的专家,所以如果我得出任何不正确的结论,我将不胜感激(温和地)纠正。然而,IEnumerable
似乎确实允许多次遍历对不同的来源表现不同;并且它允许嵌套 IEnumerable
操作的分支结构,这可能会导致一些重要的重新计算。虽然我明白不同的系统会做出不同的权衡,但这是我们在设计 Java 8 Streams API.
时力求避免的两个特征
OP 给出的快速排序示例很有趣,令人费解,很抱歉,有点恐怖。调用 QuickSort
需要一个 IEnumerable
并且 returns 需要一个 IEnumerable
,所以在遍历最后的 IEnumerable
之前实际上没有进行排序。不过,该调用似乎是建立一个 IEnumerables
的树结构,它反映了快速排序将执行的分区,但实际上并没有这样做。 (毕竟这是惰性计算。)如果源有 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
可以用这种方式构建复杂的计算结构确实很酷。但是,如果它确实像我认为的那样增加了计算复杂性,那么除非非常小心,否则似乎应该避免以这种方式编程。
可以绕过 Stream API 中的某些 "run once" 保护;例如,我们可以通过引用和重用 Spliterator
(而不是直接使用 Stream
)来避免 java.lang.IllegalStateException
异常(消息 "stream has already been operated upon or closed")。
例如,这段代码将运行不抛出异常:
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
而不是重复输出两次。这是因为用作 Stream
源的 ArraySpliterator
是有状态的并存储其当前位置。当我们重放这个 Stream
时,我们会在最后重新开始。
我们有多种选择来解决这个挑战:
我们可以使用无状态 Stream
创建方法,例如 Stream#generate()
。我们将不得不在我们自己的代码中从外部管理状态,并在 Stream
"replays":
之间重置
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);
另一个(稍好但不完美)的解决方案是编写我们自己的 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);
这个问题的最佳解决方案(在我看来)是在调用新运算符时为 Stream
管道中使用的任何有状态 Spliterator
制作一个新副本在 Stream
上。这更复杂并且涉及实施,但如果您不介意使用第三方库,cyclops-react 有一个 Stream
实现可以做到这一点。 (披露:我是该项目的首席开发人员。)
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
符合预期。
原因是您可以根据定义只能使用一次的事物创建流,例如 Iterator 或 BufferedReader。您可以认为 Stream 的使用方式与使用 BufferedReader 读取文本文件直至结束的方式相同。一旦到达文件末尾,BufferedReader 不会停止存在,但它会变得毫无用处,因为您无法再从中获取任何内容。如果你想再次读取文件,你必须创建一个新的reader。流也是如此。如果要对流的源进行两次处理,则必须创建两个单独的流。
与 C# 的 IEnumerable
不同,在 C# 中,执行管道可以执行任意多次,而在 Java 中,一个流只能 'iterated' 一次。
对终端操作的任何调用都会关闭流,使其无法使用。 这个 'feature' 带走了很多力量。
我想这是不是技术原因。这个奇怪的限制背后的设计考虑是什么?
编辑:为了演示我在说什么,请考虑在 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 无法完成! 我什至不能在不使其无法使用的情况下询问流是否为空。
背景
虽然问题看起来很简单,但实际答案需要一些背景知识才能理解。如果您想跳到结论,请向下滚动...
选择您的比较点 - 基本功能
使用基本概念,C# 的 IEnumerable
概念与 Java's Iterable
, which is able to create as many Iterators as you want. IEnumerables
create IEnumerators
的关系更为密切。 Java 的 Iterable
创建 Iterators
每个概念的历史都是相似的,因为 IEnumerable
和 Iterable
都有一个基本动机,即允许 'for-each' 样式循环遍历数据集合的成员。这是过于简单化了,因为它们都允许的不仅仅是这些,而且它们也是通过不同的进程到达那个阶段的,但无论如何这是一个重要的共同特征。
让我们比较一下这个特性:在两种语言中,如果 class 实现了 IEnumerable
/Iterable
,那么 class 必须至少实现一个方法(对于 C#,它是 GetEnumerator
,对于 Java,它是 iterator()
)。在每种情况下,从 (IEnumerator
/Iterator
) 编辑的实例 return 允许您访问数据的当前和后续成员。此功能用于 for-each 语言语法。
选择您的比较点 - 增强功能
C# 中的IEnumerable
已扩展为允许许多其他语言功能 (mostly related to Linq)。添加的功能包括选择、投影、聚合等。这些扩展在集合论中的使用具有很强的动机,类似于 SQL 和关系数据库概念。
Java 8 还添加了一些功能,可以使用 Streams 和 Lambdas 进行一定程度的函数式编程。请注意,Java 8 流的主要动机不是集合论,而是函数式编程。无论如何,有很多相似之处。
所以,这是第二点。对 C# 所做的增强是作为对 IEnumerable
概念的增强来实现的。不过,在 Java 中,所做的增强是通过创建 Lambdas 和 Streams 的新基本概念,然后还创建了一种相对简单的方法来从 Iterators
和 Iterables
转换为 Streams 来实现的,并且反之亦然。
因此,将 IEnumerable 与 Java 的 Stream 概念进行比较是不完整的。您需要将它与 Java.
中组合的流和集合 API 进行比较在 Java 中,Streams 与 Iterables 或 Iterators 不同
流的设计目的与迭代器不同:
- 迭代器是描述数据序列的一种方式。
- 流是一种描述数据转换序列的方式。
使用Iterator
,你得到一个数据值,处理它,然后得到另一个数据值。
使用 Streams,您可以将一系列函数链接在一起,然后将输入值提供给流,并从组合序列中获取输出值。请注意,在 Java 术语中,每个函数都封装在一个 Stream
实例中。 Streams API 允许您以链接一系列转换表达式的方式 link 一系列 Stream
个实例。
为了完成 Stream
概念,您需要一个数据源来提供流,以及一个使用流的终端函数。
您将值输入流的方式实际上可能来自 Iterable
,但 Stream
序列本身不是 Iterable
,它是一个复合函数。
A Stream
也被设计成懒惰的,因为它只在您向它请求值时才起作用。
注意 Streams 的这些重要假设和特征:
- Java中的一个
Stream
是一个转换引擎,它将一个状态的数据项转换为另一种状态。 - 流没有数据顺序或位置的概念,只需简单地转换它们被要求的任何内容。
- 流可以从许多来源提供数据,包括其他流、迭代器、迭代器、集合,
- 你不能“重置”流,那就像“重新编程转换”。重置数据源可能就是你想要的。
- 任何时候流中逻辑上只有 1 个数据项 'in flight'(除非流是并行流,此时每个线程有 1 个数据项)。这与数据源无关,数据源可能有超过当前项目 'ready' 提供给流,或者流收集器可能需要聚合和减少多个值。
- 流可以是未绑定的(无限的),仅受数据源或收集器(也可以是无限的)限制。
- 流是'chainable',过滤一个流的输出是另一个流。输入到流并由流转换的值可以依次提供给进行不同转换的另一个流。处于转换状态的数据从一个流流向下一个流。您无需干预并从一个流中提取数据并将其插入到下一个流中。
C# 比较
当您认为 Java Stream 只是供应、流和收集系统的一部分,并且 Streams 和 Iterators 经常与 Collections 一起使用时,也就不足为奇了很难与几乎全部嵌入到 C# 中的单个 IEnumerable
概念中的相同概念相关。
IEnumerable 的部分内容(以及密切相关的概念)在所有 Java 迭代器、迭代器、Lambda 和流概念中都很明显。
Java 概念可以做的一些小事情在 IEnumerable 中比较难,反之亦然。
结论
- 这里没有设计问题,只是语言之间概念匹配的问题。
- Streams 以不同的方式解决问题
- Streams 向 Java 添加功能(它们添加了不同的做事方式,它们并没有去除功能)
添加 Streams 可以让您在解决问题时有更多选择,class可以公平地定义为 'enhancing power',而不是 'reducing'、'taking away' 或 'restricting'它。
为什么 Java 流是一次性的?
这个问题被误导了,因为流是函数序列,而不是数据。根据提供流的数据源,您可以重置数据源,并提供相同或不同的流。
与 C# 的 IEnumerable 不同,执行管道可以执行任意多次,在 Java 中,流可以 'iterated' 一次。
将 IEnumerable
与 Stream
进行比较是错误的。你用来说 IEnumerable
可以执行任意多次的上下文,与 Java Iterables
相比是最好的,它可以迭代任意多次。 Java Stream
表示 IEnumerable
概念的子集,而不是提供数据的子集,因此不能 'rerun'.
对终端操作的任何调用都会关闭流,使其无法使用。这'feature'带走了很多力量。
从某种意义上说,第一个陈述是正确的。 'takes away power' 语句不是。你还在比较 Streams 它的 IEnumerables。流中的终端操作就像 for 循环中的 'break' 子句。如果您愿意,并且可以重新提供所需的数据,您始终可以自由使用另一个流。同样,如果您认为 IEnumerable
更像是 Iterable
,对于此语句,Java 就可以了。
我想这不是技术原因。这个奇怪的限制背后的设计考虑是什么?
原因是技术性的,原因很简单,Stream 是它所认为的子集。流子集不控制数据供应,因此您应该重置供应,而不是流。在那种情况下,它并不奇怪。
快速排序示例
您的快速排序示例具有签名:
IEnumerable<int> QuickSort(IEnumerable<int> ints)
您正在将输入 IEnumerable
视为数据源:
IEnumerable<int> lt = ints.Where(i => i < pivot);
此外,return 的值也是IEnumerable
,这是一个数据源,并且由于这是一个排序操作,所以该源的顺序很重要。如果您认为 Java Iterable
class 是合适的匹配项,特别是 Iterable
的 List
专业化,因为 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));
}
请注意有一个错误(我已重现),因为排序不能很好地处理重复值,它是 'unique value' 排序。
还要注意 Java 代码如何使用数据源 (List
),以及不同点的流概念,在 C# 中,这两个 'personalities' 可以用 IEnumerable
。此外,虽然我使用 List
作为基本类型,但我本可以使用更通用的 Collection
,并且通过一个小的迭代器到流的转换,我本可以使用更通用的 Iterable
Stream
s 是围绕 Spliterator
s 构建的,它们是有状态的、可变的对象。他们没有“重置”动作,事实上,要求支持这种倒带动作会“带走很多力量”。 Random.ints()
应该如何处理这样的请求?
另一方面,对于具有可追溯来源的 Stream
,很容易构造一个等价的 Stream
以供再次使用。只需将构建 Stream
的步骤放入可重用的方法中即可。请记住,重复这些步骤并不是一项昂贵的操作,因为所有这些步骤都是惰性操作;实际工作从终端操作开始,根据实际终端操作,可能会执行完全不同的代码。
作为此类方法的作者,您可以指定两次调用该方法意味着什么:它是否重现完全相同的序列,就像为未修改的数组或集合创建的流一样,还是生成具有相似语义但元素不同的流,例如随机整数流或控制台输入行流等。
顺便说一下,为了避免混淆,终端操作 消耗 Stream
不同于 关闭 Stream
就像在流上调用 close()
一样(这对于具有关联资源的流是必需的,例如由 Files.lines()
生成)。
似乎很多混淆源于 IEnumerable
与 Stream
的误导性比较。 IEnumerable
表示提供实际 IEnumerator
的能力,因此它类似于 Java 中的 Iterable
。相反,Stream
是一种迭代器,可与 IEnumerator
相媲美,因此声称这种数据类型可以在 .NET 中多次使用是错误的,对 [=30= 的支持] 是可选的。此处讨论的示例使用了 IEnumerable
可用于获取 new IEnumerator
s 并且与 Java 一起使用的事实 Collection
s 也是如此;你可以获得一个新的Stream
。如果 Java 开发人员决定将 Stream
操作直接添加到 Iterable
,中间操作 return 另一个 Iterable
,它确实具有可比性并且可以工作同理
然而,开发人员决定反对它,并且在 this question. The biggest point is the confusion about eager Collection operations and lazy Stream operations. By looking at the .NET API, I (yes, personally) find it justified. While it looks reasonable looking at IEnumerable
alone, a particular Collection will have lots of methods manipulating the Collection directly and lots of methods returning a lazy IEnumerable
, while the particular nature of a method isn’t always intuitively recognizable. The worst example I found (within the few minutes I looked at it) is List.Reverse()
whose name matches exactly the name of the inherited (is this the right terminus for extension methods?) Enumerable.Reverse()
中讨论了该决定,同时具有完全矛盾的行为。
当然,这是两个截然不同的决定。第一个使 Stream
成为不同于 Iterable
/Collection
的类型,第二个使 Stream
成为一种一次性迭代器而不是另一种可迭代的。但是这些决定是一起做出的,可能从未考虑过将这两个决定分开。创建它时并未考虑与 .NET 相媲美。
实际的 API 设计决策是添加改进类型的迭代器,即 Spliterator
。 Spliterator
s 可以由旧的 Iterable
s 提供(这是对它们进行改造的方式)或全新的实现。然后,Stream
作为高级前端添加到相当低级别的 Spliterator
中。而已。您可能会讨论不同的设计是否会更好,但这不会产生效果,它不会改变,因为它们现在的设计方式。
您还必须考虑另一个实施方面。 Stream
是 而不是 不可变数据结构。每个中间操作可能 return 一个新的 Stream
封装旧实例的实例,但它也可以操纵自己的实例而不是 return 本身(这并不排除为同一操作同时执行这两个操作).众所周知的例子是像 parallel
或 unordered
这样的操作,它们不添加另一个步骤而是操纵整个管道)。拥有这样一个可变的数据结构并尝试重用(或者更糟的是,同时多次使用它)并不能很好地发挥作用……
为了完整起见,这里将您的快速排序示例翻译成 Java Stream
API。说明它并没有真正“带走多少电量”。
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());
}
当你仔细观察时,我认为两者之间的区别很小。
从表面上看,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
的进一步调用只会 return false,并且无法将其重置为开头。如果您想再次遍历数字,则需要创建另一个枚举器实例。
为了更好地说明 IEnumerable 具有(可以具有)与 Java 流相同的 'feature',请考虑一个其数字源不是静态集合的可枚举。例如,我们可以创建一个生成 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 流)。
此外,当应用于 RandomNumberStream
时,基于可枚举的快速排序意味着什么?
结论
因此,最大的区别在于 .NET 允许您在需要访问序列中的元素时通过在后台隐式创建新的 IEnumerator
来重用 IEnumerable
。
这种隐式行为通常很有用(如您所说 'powerful'),因为我们可以重复迭代一个集合。
但有时,这种隐式行为实际上会导致问题。如果您的数据源不是静态的,或者访问成本很高(例如数据库或网站),那么必须放弃关于 IEnumerable
的许多假设;重用并不是那么简单
我对 Streams 的早期设计有一些回忆 API,这可能会阐明设计原理。
早在 2012 年,我们就在语言中添加了 lambda,我们想要一个面向集合或 "bulk data" 的操作集,使用 lambda 编程,这将促进并行性。懒惰地将操作链接在一起的想法在这一点上得到了很好的确立。我们也不希望中间操作存储结果。
我们需要决定的主要问题是链中的对象在 API 中是什么样子以及它们如何连接到数据源。来源通常是集合,但我们也希望支持来自文件或网络的数据,或即时生成的数据,例如来自随机数生成器的数据。
现有工作对设计有很多影响。其中比较有影响力的是 Google 的 Guava library and the Scala collections library. (If anybody is surprised about the influence from Guava, note that Kevin Bourrillion, Guava lead developer, was on the JSR-335 Lambda expert group.) On Scala collections, we found this talk by Martin Odersky to be of particular interest: Future-Proofing Scala Collections: from Mutable to Persistent to Parallel。 (斯坦福 EE380,2011 年 6 月 1 日。)
我们当时的原型设计是基于 Iterable
。熟悉的操作 filter
、map
等是 Iterable
上的扩展(默认)方法。调用一个向链中添加一个操作并返回另一个 Iterable
。像 count
这样的终端操作会调用 iterator()
链上游到源,并且这些操作在每个阶段的迭代器中实现。
由于这些是 Iterables,您可以多次调用 iterator()
方法。那会发生什么?
如果源是一个集合,这在大多数情况下都可以正常工作。集合是可迭代的,每次调用 iterator()
都会产生一个独立于任何其他活动实例的不同 Iterator 实例,并且每个独立地遍历集合。太棒了。
现在如果源是一次性的,比如从文件中读取行怎么办?也许第一个 Iterator 应该获取所有值,但第二个和后续值应该为空。也许值应该在迭代器之间交错。或者也许每个 Iterator 都应该获得所有相同的值。那么,如果您有两个迭代器并且一个比另一个更早怎么办?有人将不得不缓冲第二个迭代器中的值,直到它们被读取。更糟糕的是,如果您获得一个 Iterator 并读取所有值,并且仅 then 获得第二个 Iterator,该怎么办?现在的价值观从何而来?是否需要将它们都缓冲起来以防万一有人想要第二个迭代器?
显然,在一次性源上允许多个迭代器会引发很多问题。我们没有很好的答案给他们。如果你调用 iterator()
两次,我们想要一致的、可预测的行为。这促使我们禁止多次遍历,使管道一次性完成。
我们还观察到其他人遇到了这些问题。在JDK中,大部分Iterable都是集合或类集合对象,允许多次遍历。它没有在任何地方指定,但似乎有一个不成文的期望 Iterables 允许多次遍历。一个值得注意的例外是 NIO DirectoryStream 接口。它的规范包括这个有趣的警告:
While DirectoryStream extends Iterable, it is not a general-purpose Iterable as it supports only a single Iterator; invoking the iterator method to obtain a second or subsequent iterator throws IllegalStateException.
[原文加粗]
这看起来不寻常且令人不快,以至于我们不想创建一大堆可能只有一次的新 Iterables。这让我们放弃了使用 Iterable。
大约在这个时候,article by Bruce Eckel 出现了,描述了他在使用 Scala 时遇到的一个问题。他写了这段代码:
// Scala
val lines = fromString(data).getLines
val registrants = lines.map(Registrant)
registrants.foreach(println)
registrants.foreach(println)
这很简单。它将文本行解析为 Registrant
个对象并打印两次。除了它实际上只打印一次。结果他认为 registrants
是一个集合,而实际上它是一个迭代器。对 foreach
的第二次调用遇到一个空迭代器,其中所有值都已用完,因此它不打印任何内容。
这种经历让我们相信,如果尝试多次遍历,获得清晰可预测的结果非常重要。它还强调了将类似惰性管道的结构与存储数据的实际集合区分开来的重要性。这反过来又推动了将惰性管道操作分离到新的 Stream 接口中,并直接在 Collections 上保留急切的、可变的操作。 Brian Goetz has explained 这样做的理由。
允许对基于集合的管道进行多次遍历但不允许对非基于集合的管道进行多次遍历呢?这是不一致的,但它是明智的。如果您正在从网络中读取值,当然您不能再次遍历它们。如果你想多次遍历它们,你必须显式地将它们拉入一个集合。
但让我们探索允许从基于集合的管道进行多次遍历。假设您这样做了:
Iterable<?> it = source.filter(...).map(...).filter(...).map(...);
it.into(dest1);
it.into(dest2);
(into
操作现在拼写为 collect(toList())
。)
如果源是一个集合,那么第一个 into()
调用将创建一个返回源的迭代器链,执行管道操作,并将结果发送到目标。第二次调用 into()
将创建另一个迭代器链,并再次执行管道操作 。这显然不是错误的,但它确实具有对每个元素第二次执行所有过滤器和映射操作的效果。我想很多程序员都会对这种行为感到惊讶。
正如我上面提到的,我们一直在与 Guava 开发人员交谈。他们拥有的一件很酷的东西是 Idea Graveyard,他们在其中描述了他们决定 不 实施的功能以及原因。惰性集合的想法听起来很酷,但这是他们不得不说的。考虑一个 List.filter()
操作 returns a List
:
The biggest concern here is that too many operations become expensive, linear-time propositions. If you want to filter a list and get a list back, and not just a Collection or an Iterable, you can use
ImmutableList.copyOf(Iterables.filter(list, predicate))
, which "states up front" what it's doing and how expensive it is.
举个具体的例子,一个List上get(0)
或者size()
的成本是多少?对于像 ArrayList
这样常用的 类,它们是 O(1)。但是,如果您在惰性过滤列表中调用其中一个,它必须 运行 对后备列表进行过滤,突然间这些操作就变成了 O(n)。更糟糕的是,它必须在 每个 操作时遍历支持列表。
在我们看来,这太过懒惰了。设置一些操作并推迟实际执行是一回事 "Go"。以隐藏潜在大量重新计算的方式进行设置是另一回事。
在提议禁止非线性或 "no-reuse" 流时,Paul Sandoz described the potential consequences 允许它们产生 "unexpected or confusing results." 他还提到并行执行会使事情变得更加棘手。最后,我要补充一点,如果操作意外执行多次,或者至少执行次数与程序员预期的次数不同,则具有副作用的管道操作会导致难以理解的错误。 (但是 Java 程序员不会写有副作用的 lambda 表达式,对吗?他们会写吗??)
这就是 Java 8 Streams API 设计的基本原理,它允许一次性遍历并且需要严格的线性(无分支)管道。它在多个不同的流源之间提供一致的行为,它清楚地将惰性操作与急切操作区分开来,并且它提供了一个简单的执行模型。
关于 IEnumerable
,我远不是 C# 和 .NET 方面的专家,所以如果我得出任何不正确的结论,我将不胜感激(温和地)纠正。然而,IEnumerable
似乎确实允许多次遍历对不同的来源表现不同;并且它允许嵌套 IEnumerable
操作的分支结构,这可能会导致一些重要的重新计算。虽然我明白不同的系统会做出不同的权衡,但这是我们在设计 Java 8 Streams API.
OP 给出的快速排序示例很有趣,令人费解,很抱歉,有点恐怖。调用 QuickSort
需要一个 IEnumerable
并且 returns 需要一个 IEnumerable
,所以在遍历最后的 IEnumerable
之前实际上没有进行排序。不过,该调用似乎是建立一个 IEnumerables
的树结构,它反映了快速排序将执行的分区,但实际上并没有这样做。 (毕竟这是惰性计算。)如果源有 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
可以用这种方式构建复杂的计算结构确实很酷。但是,如果它确实像我认为的那样增加了计算复杂性,那么除非非常小心,否则似乎应该避免以这种方式编程。
可以绕过 Stream API 中的某些 "run once" 保护;例如,我们可以通过引用和重用 Spliterator
(而不是直接使用 Stream
)来避免 java.lang.IllegalStateException
异常(消息 "stream has already been operated upon or closed")。
例如,这段代码将运行不抛出异常:
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
而不是重复输出两次。这是因为用作 Stream
源的 ArraySpliterator
是有状态的并存储其当前位置。当我们重放这个 Stream
时,我们会在最后重新开始。
我们有多种选择来解决这个挑战:
我们可以使用无状态
之间重置Stream
创建方法,例如Stream#generate()
。我们将不得不在我们自己的代码中从外部管理状态,并在Stream
"replays":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);
另一个(稍好但不完美)的解决方案是编写我们自己的
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);
这个问题的最佳解决方案(在我看来)是在调用新运算符时为
Stream
管道中使用的任何有状态Spliterator
制作一个新副本在Stream
上。这更复杂并且涉及实施,但如果您不介意使用第三方库,cyclops-react 有一个Stream
实现可以做到这一点。 (披露:我是该项目的首席开发人员。)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
符合预期。
原因是您可以根据定义只能使用一次的事物创建流,例如 Iterator 或 BufferedReader。您可以认为 Stream 的使用方式与使用 BufferedReader 读取文本文件直至结束的方式相同。一旦到达文件末尾,BufferedReader 不会停止存在,但它会变得毫无用处,因为您无法再从中获取任何内容。如果你想再次读取文件,你必须创建一个新的reader。流也是如此。如果要对流的源进行两次处理,则必须创建两个单独的流。