流中间操作顺序

6

当使用流时,是否有保证中间操作将按程序顺序执行?我怀疑是这样的,否则会导致非常微妙的错误,但我找不到明确的答案。

示例:

List<String> list = Arrays.asList("a", "b", "c");
List<String> modified = list.parallelStream()
        .map(s -> s + "-" + s)                 //"a-a", "b-b", "c-c"
        .filter(s -> !s.equals("b-b"))         //"a-a", "c-c"
        .map(s -> s.substring(2))              //"a", "c"
        .collect(toList());

这是否保证始终返回["a", "c"]["c", "a"]?(如果最后一个map操作在第一个map操作之前执行,可能会引发异常 - 同样,如果过滤器在第二个map操作之后执行,“b”将保留在最终列表中)

是的,这是有保证的。 - Louis Wasserman
2
@LouisWasserman 能否引用一份官方来源,如果有的话?我找不到。 - Rohit Jain
1
@assylias 从这个Oracle文档中我得到的印象是,虽然中间操作是惰性的,但它们只会对前一个中间操作提供的输入进行操作。我猜,如果这是真的,那么它保证返回其中一个输出。 - Rohit Jain
@RohitJain 难道 Stream 实现成管道的事实不保证了这一点吗? - Sotirios Delimanolis
@SotiriosDelimanolis 嗯。是的,这个观念让我想到,在对特定数据进行操作之前,所有在它之前的中间操作都没有完成对该数据的操作。但我还是感到困惑。 - Rohit Jain
2个回答

10

实际上,在原问题中有几个关于排序的问题。

Holger 的回答 涵盖了管道内流操作的排序。对于特定的流元素,程序必须按照编写的顺序执行管道操作,因为通常类型必须匹配,而且因为用其他方式执行操作似乎没有意义。从最初的示例开始,流库不能像它们已经被编写一样重新排序操作。

List<String> modified = list.parallelStream()
    .filter(s -> !s.equals("b-b")) // these two operations are swapped
    .map(s -> s + "-" + s)         // compared to the original example
    .map(s -> s.substring(2))
    .collect(toList());
因为结果将会是[a, b, c],而这不会发生。
原问题是关于答案是否能够是[c, a]而不是[a, c]。这实际上是一个关于另一种排序方式的问题,我们称之为“遭遇顺序”(encounter order)。这个概念在java.util.stream包文档中有提到。不幸的是,在我所知道的任何地方都没有清晰地定义它。简单来说,它涉及流中元素的相对位置(与执行顺序相对)以及这个位置是否具有任何语义。
例如,考虑从HashSet和ArrayList获取的流。基于HashSet的流没有定义的遭遇顺序,换句话说,它是无序的。如果你把一堆元素放进HashSet中,然后迭代输出它们,它们会按照某种可能与你放入它们的顺序无关的顺序输出。
然而,基于List的流确实有一个定义的遭遇顺序。在原始示例中,列表是[a,b,c],显然“a”在“b”之前,“b”在“c”之前。这个位置通常会通过从源头到输出的流操作保留。
让我修改原始示例以展示遭遇顺序的重要性。我所做的只是改变了原始列表中字符串的顺序:
List<String> list = Arrays.asList("c", "b", "a");
List<String> modified = list.parallelStream()
    .map(s -> s + "-" + s)                 //"c-c", "b-b", "a-a"
    .filter(s -> !s.equals("b-b"))         //"c-c", "a-a"
    .map(s -> s.substring(2))              //"c", "a"
    .collect(toList());

正如我们预期的那样,输出为[c,a]。现在让我们将流程应用到一个集合而不是一个列表中:

List<String> list = Arrays.asList("c", "b", "a");
Set<String> set = new HashSet<>(list);
List<String> modified = set.parallelStream()
    .map(s -> s + "-" + s)
    .filter(s -> !s.equals("b-b"))
    .map(s -> s.substring(2))
    .collect(toList());

这次结果是[a,c]。管道操作(map,filter,map)的顺序未更改,但由于集合中元素的遇到顺序是未定义的,结果以某种顺序进入目标列表,这个顺序与之前的结果不同。

(我不得不更改原始列表中的值的顺序,因为HashSet的迭代顺序与元素的哈希码有关,这里给出的简单字符串示例具有连续的哈希码。)

还有另一种“排序”可以考虑,即在不同的元素之间管道操作的相对执行顺序。对于并行流,这是完全不确定的。观察这一点的一种方法是从管道操作中突变对象。(为了安全地执行此操作,被突变的对象当然必须是线程安全的,并且依赖于任何此类副作用的顺序是不明智的。)以下是一个示例:

List<Integer> list1 = Collections.synchronizedList(new ArrayList<>());
List<Integer> list2 =
    IntStream.range(0, 10)
        .parallel()
        .boxed()
        .peek(i -> list1.add(i))
        .collect(toList());
System.out.println(list1);
System.out.println(list2);

在我的系统上,输出结果为:

[5, 6, 2, 3, 4, 8, 9, 7, 0, 1]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

在list2中,源的顺序被保留到输出中,但是通常情况下list1的顺序是不同的。事实上,list1中元素的顺序会因为每次运行而有所变化,而list2中元素的顺序总是相同的。

总之,这里展示了三种不同的排序方式:

  • 某个特定元素上流水线操作的顺序;
  • 流的遇到顺序;以及
  • 不同元素上流水线操作的执行顺序。

它们都是不同的。


7
你的问题是因为你正在从一种类型映射到相同的类型。如果你考虑你正在执行的形式操作,就会清楚地看到没有改变指定操作顺序的方法:
- 你将一个 Stream<A> 的项目映射到一个任意类型 B 中,从而创建一个 Stream<B> - 你在第一次映射的结果上应用一个 Filter<B> - 你将被过滤的 Stream<B> 映射到任意类型 C 中,从而创建一个 Stream<C> - 你将类型为 C 的项目收集到一个 List<C>
看这些形式步骤,应该清楚地看到由于类型兼容性要求,没有办法改变这些步骤的顺序。
你的特殊情况中, 三种类型恰好都是String, 但这并不改变Stream的工作逻辑。请记住,你用于类型参数的实际类型在运行时被擦除并不存在。 Stream 实现可以强制执行一些操作,例如一次执行sorteddistinct,但这要求两个操作都在同一项和Comparator上请求。或者简单地说,内部优化不能改变所请求操作的语义。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接