Java Stream API - 最佳的列表转换方式:map 还是 forEach?

254
我有一个列表,名为myListToParse,我想要筛选其中的元素,并对每个元素应用一个方法,然后将结果添加到另一个列表myFinalList中。
通过Java 8中新增的Stream API,我发现可以有两种不同的方式来实现。我想知道哪一种方式更高效,并且了解为什么其中一种方式比另一种更好。
方法1:
myFinalList = new ArrayList<>();
myListToParse.stream()
        .filter(elt -> elt != null)
        .forEach(elt -> myFinalList.add(doSomething(elt)));

方法二:
myFinalList = myListToParse.stream()
        .filter(elt -> elt != null)
        .map(elt -> doSomething(elt))
        .collect(Collectors.toList()); 

我对任何关于第三种方式的建议都持开放态度。

71
第二个。一个正确的函数应该没有副作用,在你的第一种实现中,你正在修改外部世界。 - ThanksForAllTheFish
47
风格问题,但 elt -> elt != null 可以替换为 Objects::nonNull - the8472
2
@the8472 更好的做法是在集合中首先确保没有空值,并结合使用 Optional<T>flatMap - herman
2
@SzymonRoziewski,不完全正确。对于像这样微不足道的事情,设置并行流所需的工作将使使用此结构变得无效。 - user3248346
2
请注意,假设doSomething是非静态方法,则可以编写.map(this::doSomething)。如果它是静态的,则可以将this替换为类名。 - herman
显示剩余5条评论
8个回答

220

不要担心性能差异,通常在这种情况下它们将是微不足道的。

方法2更可取,因为

  1. 它不需要改变lambda表达式外存在的集合。

  2. 它更易读,因为在集合管道中执行的不同步骤是按顺序编写的:首先是过滤操作,然后是映射操作,最后是收集结果(有关集合管道的更多信息,请参见马丁·福勒的优秀文章)。

  3. 您可以通过替换所使用的Collector轻松更改收集值的方式。在某些情况下,您可能需要编写自己的Collector,但好处是可以轻松重用它。


58

我同意现有答案,第二种形式更好,因为它没有任何副作用并且更容易并行化(只需使用并行流)。

就性能而言,除非您开始使用并行流,否则它们似乎是等效的。在这种情况下,map将表现得更好。请参见下面的微基准测试结果:

Benchmark                         Mode  Samples    Score   Error  Units
SO28319064.forEach                avgt      100  187.310 ± 1.768  ms/op
SO28319064.map                    avgt      100  189.180 ± 1.692  ms/op
SO28319064.mapWithParallelStream  avgt      100   55,577 ± 0,782  ms/op

由于forEach是一个终端方法,它返回void,所以您被迫使用有状态的lambda表达式,因此您不能以同样的方式增强第一个示例。但是如果您正在使用并行流,则这确实是一个不好的主意

最后请注意,您的第二个片段可以使用方法引用和静态导入略微更简洁地编写:

myFinalList = myListToParse.stream()
    .filter(Objects::nonNull)
    .map(this::doSomething)
    .collect(toList()); 

1
关于性能,如果您使用parallelStreams,那么在您的情况下,“map”确实胜过“forEach”。我的基准测试结果以毫秒为单位:SO28319064.forEach: 187,310 ± 1,768 ms/op -- SO28319064.map: 189,180 ± 1,692 ms/op -- SO28319064.mapParallelStream: 55,577 ± 0,782 ms/op - Giuseppe Bertone
2
@GiuseppeBertone,这取决于assylias,但我认为您的编辑与原始作者的意图相矛盾。如果您想添加自己的答案,最好是添加而不是对现有答案进行如此多的编辑。现在,微基准测试的链接也与结果无关。 - Tagir Valeev

6
如果您使用Eclipse Collections,您可以使用collectIf()方法。
MutableList<Integer> source =
    Lists.mutable.with(1, null, 2, null, 3, null, 4, null, 5);

MutableList<String> result = source.collectIf(Objects::nonNull, String::valueOf);

Assert.assertEquals(Lists.immutable.with("1", "2", "3", "4", "5"), result);

它是急切地进行评估,应该比使用Stream要快一些。
注意:我是Eclipse Collections的提交者。

虽然这个观点似乎正确并且可能是一个好建议,但它并没有回答所提出的问题,即关于转换/映射的问题。 - Philippe Cloutier
1
@PhilippeCloutier 我编辑了我的答案,使用了原始问题中出现的doSomething()方法,以澄清collectIf()除了过滤外还进行了转换/映射。 - Craig P. Motlin
啊,我明白了。我提交了一个编辑来澄清这个回答针对楼主的具体问题。 - Philippe Cloutier

5
使用流的主要优点之一是它能够以声明式的方式处理数据,即使用函数式编程风格。它还提供了免费的多线程能力,这意味着不需要编写任何额外的多线程代码来使您的流并发。
假设您探索这种编程风格的原因是想利用这些好处,那么您的第一个代码示例可能不是函数式的,因为foreach方法被归类为终端(表示它可以产生副作用)。
从函数式编程的角度来看,第二种方法更受欢迎,因为map函数可以接受无状态lambda函数。更明确地说,传递给map函数的lambda应该是:
1. 非干扰性的,这意味着如果源是非并发的(例如ArrayList),则该函数不应更改流的来源。 2. 无状态的,以避免在进行并行处理时出现意外结果(由线程调度差异引起)。
第二种方法的另一个好处是,如果流是并行的,并且收集器是并发的和无序的,则这些特征可以为减少操作提供有用的提示,以便并发地进行收集。

1
我更喜欢第二种方式。
当您使用第一种方式时,如果决定使用并行流来提高性能,则无法控制 forEach 添加元素到输出列表的顺序。
当您使用 toList 时,即使使用并行流,Streams API也会保留顺序。

我不确定这是正确的建议:如果他想使用并行流但仍然保留顺序,他可以使用forEachOrdered而不是forEach。但正如forEach的文档所述,保留遇到的顺序会牺牲并行性的好处。我怀疑toList也是如此。 - herman
根据https://docs.oracle.com/javase/tutorial/collections/streams/parallelism.html,collect方法旨在以并行安全的方式执行具有副作用的最常见流操作。像forEach和peek这样的操作...如果您使用其中一个操作与并行流,则Java运行时可能会从多个线程同时调用您指定为其参数的lambda表达式。因此,它小心地将一些事情变成原子状态,而另一些则不是。奇怪。假设是这种情况,那么选项1如果并行化可能会有争用问题... - rogerdpack

0

还有第三种选择-使用stream().toArray()-请参见为什么流没有toList方法下的注释。结果比forEach()或collect()慢,表达力也较差。它可能会在以后的JDK版本中进行优化,因此在这里添加只是以防万一。

假设List<String>

    myFinalList = Arrays.asList(
            myListToParse.stream()
                    .filter(Objects::nonNull)
                    .map(this::doSomething)
                    .toArray(String[]::new)
    );

使用微型基准测试,1M条目,20%为空值,并在doSomething()中进行简单转换

private LongSummaryStatistics benchmark(final String testName, final Runnable methodToTest, int samples) {
    long[] timing = new long[samples];
    for (int i = 0; i < samples; i++) {
        long start = System.currentTimeMillis();
        methodToTest.run();
        timing[i] = System.currentTimeMillis() - start;
    }
    final LongSummaryStatistics stats = Arrays.stream(timing).summaryStatistics();
    System.out.println(testName + ": " + stats);
    return stats;
}

结果为

并行:

toArray: LongSummaryStatistics{count=10, sum=3721, min=321, average=372,100000, max=535}
forEach: LongSummaryStatistics{count=10, sum=3502, min=249, average=350,200000, max=389}
collect: LongSummaryStatistics{count=10, sum=3325, min=265, average=332,500000, max=368}

连续的:

toArray: LongSummaryStatistics{count=10, sum=5493, min=517, average=549,300000, max=569}
forEach: LongSummaryStatistics{count=10, sum=5316, min=427, average=531,600000, max=571}
collect: LongSummaryStatistics{count=10, sum=5380, min=444, average=538,000000, max=557}

并行操作时无需考虑空值和过滤(以便流是SIZED):在这种情况下,toArrays具有最佳性能,而.forEach()会在接收方ArrayList上失败并出现“indexOutOfBounds”错误,必须替换为.forEachOrdered()

toArray: LongSummaryStatistics{count=100, sum=75566, min=707, average=755,660000, max=1107}
forEach: LongSummaryStatistics{count=100, sum=115802, min=992, average=1158,020000, max=1254}
collect: LongSummaryStatistics{count=100, sum=88415, min=732, average=884,150000, max=1014}

0

可能是第三种方法。

我总是喜欢保持逻辑独立。

Predicate<Long> greaterThan100 = new Predicate<Long>() {
    @Override
    public boolean test(Long currentParameter) {
        return currentParameter > 100;
    }
};
        
List<Long> sourceLongList = Arrays.asList(1L, 10L, 50L, 80L, 100L, 120L, 133L, 333L);
List<Long> resultList = sourceLongList.parallelStream().filter(greaterThan100).collect(Collectors.toList());

0
如果使用第三方库是可以的,cyclops-react定义了带有此功能的惰性扩展集合。例如,我们可以简单地编写:
ListX myListToParse;
ListX myFinalList = myListToParse.filter(elt -> elt != null) .map(elt -> doSomething(elt));
只有在第一次访问时才评估myFinalList(之后材料化列表将被缓存和重用)。
[披露我是cyclops-react的首席开发人员]

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