如何将Java8流中的元素添加到现有列表中

214

Collector的Javadoc 显示如何将流中的元素收集到新List中。是否有一行代码将结果添加到现有的ArrayList中?


8个回答

255

注意:nosid的回答展示了如何使用forEachOrdered()来向现有集合添加内容。这是一种对现有集合进行变异的有用且有效的技术。我的回答解释了为什么你不应该使用Collector来修改现有集合。

简短的回答是不行,至少在一般情况下,你不应该使用Collector来修改现有集合。

原因是收集器(collectors)旨在支持并行处理,即使是那些不是线程安全的集合。它们实现这一点的方式是让每个线程独立地在自己的中间结果集上操作。每个线程获得自己的集合的方法是调用Collector.supplier(),该方法要求每次返回一个新的集合。

这些中间结果集然后以线程限制的方式合并,直到形成单个结果集。这是collect()操作的最终结果。

来自Balderassylias的一些回答建议使用Collectors.toCollection(),然后传递一个返回现有列表而不是新列表的供应商。这违反了对供应商的要求,即它每次必须返回一个新的空集合。

这对于简单情况可以工作,正如他们回答中的示例所展示的那样。但是,它将会失败,特别是如果流在并行下运行。(即使在顺序情况下,某些未预料到的库的未来版本可能会发生变化导致它会失败。)

让我们看一个简单的例子:

List<String> destList = new ArrayList<>(Arrays.asList("foo"));
List<String> newList = Arrays.asList("0", "1", "2", "3", "4", "5");
newList.parallelStream()
       .collect(Collectors.toCollection(() -> destList));
System.out.println(destList);

当我运行这个程序时,我经常会遇到ArrayIndexOutOfBoundsException异常。这是因为多个线程正在操作ArrayList,这是一个线程不安全的数据结构。好的,让我们将其同步:

List<String> destList =
    Collections.synchronizedList(new ArrayList<>(Arrays.asList("foo")));

这段代码将不再引发异常,但结果可能与预期不同:

[foo, 0, 1, 2, 3]

它会给出像这样奇怪的结果:

[foo, 2, 3, foo, 2, 3, 1, 0, foo, 2, 3, foo, 2, 3, 1, 0, foo, 2, 3, foo, 2, 3, 1, 0, foo, 2, 3, foo, 2, 3, 1, 0]

这是我上面描述的线程限制的累加/合并操作的结果。使用并行流,每个线程调用供应商以获取其自己的集合进行中间累积。如果传递返回相同集合的供应商,则每个线程将其结果附加到该集合中。由于线程之间没有排序,因此结果将按任意顺序附加。

然后,当这些中间集合被合并时,实际上是将列表与其自身进行合并。使用List.addAll()来合并列表,其中指定如果在操作期间修改源集合则结果未定义。在这种情况下,ArrayList.addAll()执行一个数组复制操作,因此最终会将自己重复,这可能是人们期望的某种方式。(请注意,其他List实现可能具有完全不同的行为。)总之,这解释了目标中奇怪的结果和重复元素。

也许你会说,“我只需确保以顺序方式运行我的流”,然后编写如下代码:

stream.collect(Collectors.toCollection(() -> existingList))

不管怎样,我建议不要这样做。如果你能控制流,那么当然可以确保它不会并行运行。我预计会出现一种编程风格,其中流将被传递而不是集合。如果有人把一个流交给你,而你使用这段代码,如果该流恰巧是并行的,它将失败。更糟糕的是,有人可能将一个顺序流交给你,而这段代码在一段时间内可以正常工作、通过所有测试等。然后,系统中的其他代码可能会在任意的时间改变为使用并行流,这将导致你的代码出现问题。

好的,那么只需确保在使用此代码之前在任何流上调用sequential()

stream.sequential().collect(Collectors.toCollection(() -> existingList))
当然,你会记得每次都这样做,对吧? :-) 让我们假设你真的这么做了。那么,性能团队会想知道为什么他们精心制作的并行实现没有提供任何加速。再一次,他们将追溯到你的代码,这会强制整个流按顺序运行。
不要这么做。

3
很棒的解释!感谢澄清这一点。我会编辑我的回答,建议永远不要使用可能的并行流来做这件事。 - Balder
3
如果问题是是否有一行代码可以将流中的元素添加到现有列表中,那么简短的答案是“是”。请查看我的回答。然而,我同意您的观点,使用_Collectors.toCollection()_与现有列表结合使用是错误的方式。 - nosid
没错。我猜我们其他人都在想着收集器。 - Stuart Marks
我明白什么是不该做的,这很好,但在我的情况下,我想要的是一个“flatMap”。因此,在某些情况下,要做的事情可能是使用flatMap:请参见https://dev59.com/BF8e5IYBdhLWcg3w_-lN。 - pdem
1
如果您想让流具有副作用,那么您几乎肯定想使用 forEachOrdered。副作用包括将元素添加到现有集合中,无论该集合是否已经包含元素。如果您希望将流的元素放入一个 集合中,请使用 collect(Collectors.toList())toSet()toCollection() - Stuart Marks
显示剩余2条评论

216

就我所知,到目前为止所有其他的答案都使用了收集器来向现有流添加元素。然而,有一个更短的解决方案,它适用于顺序和并行流。你可以简单地使用方法forEachOrdered与方法引用结合使用。

List<String> source = ...;
List<Integer> target = ...;

source.stream()
      .map(String::length)
      .forEachOrdered(target::add);

唯一的限制是,sourcetarget必须是不同的列表,因为在流被处理时,不允许对源进行更改。

请注意,此解决方案适用于顺序流和并行流。但是,它不会从并发性中受益。传递给forEachOrdered的方法引用将始终按顺序执行。


9
很有趣,很多人声称没有可能性,但实际上是存在的。顺便说一下,我在两个月前的答案中提到了forEach(existing::add)作为一种可能性。(https://dev59.com/bWEi5IYBdhLWcg3wMZ7a#21526973)。我也应该加上`forEachOrdered`...... - Holger
6
你使用 forEachOrdered 而不是 forEach,有什么原因吗? - membersound
10
forEachOrdered适用于顺序流和并行流,而forEach则可能会在并行流中同时执行传递的函数对象。在这种情况下,必须正确同步函数对象,例如使用Vector<Integer> - nosid
@BrianGoetz:我必须承认,Stream.forEachOrdered的文档有点不够精确。然而,在这个“规范”中,我看不到任何合理的解释,其中没有任何两个target::add调用之间的_happens-before_关系。无论从哪个线程调用该方法,都不存在数据竞争。我本来以为你知道这一点。 - nosid
2
就我而言,这是最有用的答案。它实际上展示了一种将流中的项目插入到现有列表中的实用方法,这正是问题所要求的(尽管“collect”这个误导性词语)。 - Wheezil
显示剩余2条评论

15
简短回答:不行(或者应该不能)。编辑:是的,它是可能的(请参见下面assylias的答案),但请继续阅读。编辑2:但是请看Stuart Marks的答案,了解另一个原因为什么你仍然不应该这样做! 较长回答:在Java 8中引入这些构造的目的是向语言介绍一些函数式编程的概念;在函数式编程中,数据结构通常不会被修改,而是通过转换(例如map、filter、fold/reduce和许多其他操作)从旧的数据结构创建新的数据结构。
如果您必须修改旧列表,请将映射的项收集到新列表中。
final List<Integer> newList = list.stream()
                                  .filter(n -> n % 2 == 0)
                                  .collect(Collectors.toList());

然后执行list.addAll(newList) — 如果你真的必须这样做。

(或者构建一个新的列表,将旧列表和新列表连接起来,并将其赋值回list变量—这比使用addAll更符合FP的精神。)

关于API:即使API允许这样做(请参见assylias的答案),通常情况下也应该尽量避免这样做。最好不要与范式(FP)对抗,而是学习它,只有在绝对必要的情况下才采用“不太正派”的策略。

详细解释:(即如果包括实际查找和阅读FP介绍/书籍所需的努力)

要找出为什么一般情况下修改现有列表是一个坏主意,并导致代码难以维护——除非你修改的是局部变量且你的算法很短和/或很简单,这超出了代码可维护性的范围——找一本好的函数式编程入门书籍(数百种选择)并开始阅读。一个“预览”说明是:大多数情况下,不修改数据(在程序的大多数部分)更具数学上的合理性,更容易推理,并导致更高级别、更少技术性(以及一旦你的大脑过渡到旧式命令式思维之外更加人性化)的程序逻辑定义。


@assylias:从逻辑上讲,它并没有错,因为有“或”这个部分;无论如何,我添加了一条注释。 - Erik Kaplun
1
简短的回答是正确的。提出的一行代码在简单情况下可能会成功,但在一般情况下会失败。 - Stuart Marks
1
更详细的答案大体上是正确的,但 API 的设计主要关注并行性,而不是函数式编程。当然,函数式编程有很多方面是适合并行处理的,因此这两个概念是相互契合的。 - Stuart Marks
@StuartMarks:有趣的是,在哪些情况下assylias提供的解决方案会失效?(并且关于并行性的好观点——我想我太热衷于倡导FP了) - Erik Kaplun
2
无论长短,整个答案都是错误的。nosid 给出了正确的一行代码。因此,所有解释为什么不存在这样的一行代码都是无意义的。 - Holger
显示剩余5条评论

13

Erik Kaplun已经给出了非常好的理由,解释为什么你最好不要将流中的元素收集到现有的List中。

无论如何,如果你确实需要这种功能,你可以使用下面的一行代码。

但是正如其他答案中指出的那样,你永远不应该这样做,绝不会这样做,特别是如果流可能是并行流——使用时需自负...

list.stream().collect(Collectors.toCollection(() -> myExistingList));

3
如果流在并行运行时,这种技术将会失败得很惨。 - Stuart Marks
1
集合提供者有责任确保它不会失败,例如通过提供并发集合。 - Balder
2
不,这段代码违反了toCollection()的要求,即供应商返回一个新的、空的适当类型的集合。即使目标是线程安全的,为并行情况执行的合并也会导致不正确的结果。 - Stuart Marks
我明白,这违反了toCollection()的合约,但我认为处理这种违规行为仍然是调用者的责任。无论如何,我真的很想了解为什么不返回一个新的空集合会导致错误的结果 - 你能给出一个简短的例子来说明这种失败吗? - Balder
1
@Balder,我已经添加了一个答案,应该可以澄清这个问题。 - Stuart Marks
显示剩余3条评论

3
你只需要将原始列表设置为Collectors.toList()返回的列表即可。
以下是演示内容:
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class Reference {

  public static void main(String[] args) {
    List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
    System.out.println(list);

    // Just collect even numbers and start referring the new list as the original one.
    list = list.stream()
               .filter(n -> n % 2 == 0)
               .collect(Collectors.toList());
    System.out.println(list);
  }
}

以下是您如何将新创建的元素添加到原始列表中的一行代码。

List<Integer> list = ...;
// add even numbers from the list to the list again.
list.addAll(list.stream()
                .filter(n -> n % 2 == 0)
                .collect(Collectors.toList())
);

这就是函数式编程范式提供的好处。

1
我想表达的是如何将元素添加/收集到现有列表中,而不仅仅是重新分配。 - codefx
1
从技术上讲,你不能在函数式编程范式中做那种与流相关的事情。在函数式编程中,状态不会被修改,而是在持久数据结构中创建新状态,这使得它更适合并发目的,并且更加功能化。我提到的方法是你可以采用的,或者你可以诉诸于旧式面向对象的方法,在其中迭代每个元素,并根据需要保留或删除元素。 - Aman Agnihotri

0

我会将旧列表和新列表作为流连接起来,并将结果保存到目标列表中。并行处理也很有效。

我将使用Stuart Marks提供的被接受答案的示例:

List<String> destList = Arrays.asList("foo");
List<String> newList = Arrays.asList("0", "1", "2", "3", "4", "5");

destList = Stream.concat(destList.stream(), newList.stream()).parallel()
            .collect(Collectors.toList());
System.out.println(destList);

//output: [foo, 0, 1, 2, 3, 4, 5]

希望能有所帮助。


0
假设我们有一个现有的列表,并将使用Java 8进行此操作。
import java.util.*;
import java.util.stream.Collectors;

public class AddingArray {

    public void addArrayInList(){
        List<Integer> list = Arrays.asList(3, 7, 9);

   // And we have an array of Integer type 

        int nums[] = {4, 6, 7};

   //Now lets add them all in list
   // converting array to a list through stream and adding that list to previous list
        list.addAll(Arrays.stream(nums).map(num -> 
                                       num).boxed().collect(Collectors.toList()));
     }
}

`


-3

targetList = sourceList.stream().flatmap(List::stream).collect(Collectors.toList());

targetList = sourceList.stream().flatMap(List::stream).collect(Collectors.toList());


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