Kotlin标准库操作与for循环对比

15

我写了以下代码:

val src = (0 until 1000000).toList()
val dest = ArrayList<Double>(src.size / 2 + 1)    

for (i in src)
{
    if (i % 2 == 0) dest.add(Math.sqrt(i.toDouble()))
}

在我的情况下,IntellJ(或者是Android Studio)询问我是否想要使用stdlib中的操作替换for循环。这将导致以下代码:

val src = (0 until 1000000).toList()
val dest = ArrayList<Double>(src.size / 2 + 1)
src.filter { it % 2 == 0 }
   .mapTo(dest) { Math.sqrt(it.toDouble()) }

我必须说,我喜欢修改后的代码。在类似情况下,与for循环相比,我发现它更容易编写。然而,当我阅读filter函数的作用时,我意识到这段代码比for循环慢得多。 filter函数创建一个新列表,其中仅包含与谓词匹配的src元素。因此,在stdlib版本的代码中产生了一个额外的列表和一个额外的循环。当然,对于小型列表可能并不重要,但一般来说,这听起来不像是一个好的选择。特别是如果应该像这样链接更多的方法,您将会获得许多可以通过编写for循环避免的附加循环。

我的问题是Kotlin中什么被认为是良好实践。我应该坚持使用for循环,还是我错过了什么,它的工作方式与我所想象的不同?

4个回答

16
如果您关注性能,您需要的是Sequence。例如,您上述的代码将会变成:
val src = (0 until 1000000).toList()
val dest = ArrayList<Double>(src.size / 2 + 1)
src.asSequence()
    .filter { it % 2 == 0 }
    .mapTo(dest) { Math.sqrt(it.toDouble()) }
在上面的代码中,filter返回另一个Sequence,它代表了一个中间步骤。实际上没有任何对象或数组被创建(除了一个新的Sequence包装器)。只有当调用终端操作mapTo时,才会创建结果集合。
如果您学过Java 8 Stream,您可能会发现上述解释有些熟悉。实际上,Sequence大致相当于Java 8 Stream在Kotlin中的等价物。它们具有类似的目的和性能特征。唯一的区别是Sequence不设计为与ForkJoinPool一起使用,因此更容易实现。
当涉及多个步骤或可能有大量数据的情况下,建议使用Sequence而不是简单的.filter {...}.mapTo{...}。我还建议您使用Sequence形式而不是命令式形式,因为它更易于理解。当涉及到5个或更多步骤的数据处理时,命令式形式可能变得复杂,因此难以理解。如果只有一个步骤,则不需要使用Sequence,因为它只会创建垃圾并且给您没有有用的东西。

说得有道理,但当我尝试过后,使用序列化甚至更加慢了。 - rozina
1
这太神奇了。你确定你的基准测试是正确的吗?我的意思是,如果有足够的预热时间,没有JIT移除循环,重复足够多次以避免边缘情况等等...至少在我的端上,“Seqence”更快。(大约快25%) - glee8e
也许我不太了解编译器和JVM,无法正确地进行基准测试。我在循环之前和之后测量时间。没有预热-我们要做什么样的预热?我的结果: 序列:110毫秒 filter_map:57毫秒 forEach:48毫秒 for循环:35毫秒filter_map使用非序列操作。 - rozina
2
有时候,即使复制整个集合,由于良好的引用局部性,多个循环也会表现出良好的性能。请参见:https://dev59.com/T1sV5IYBdhLWcg3w6ych - hotkey
2
@rozina 这篇SO帖子解释了如何在JVM上正确进行基准测试。虽然它旨在教育Java程序员,但对于Kotlin也适用,因为Kotlin具有JVM后端。这是我的测试代码要点:https://gist.github.com/Glease/900fe08d757631e97e230d90a9b4faa2 - glee8e

3

您缺少一些东西。 :-)

在这种特定情况下,您可以使用 IntProgression

val progression = 0 until 1_000_000 step 2

然后,您可以通过各种方式创建所需的平方列表:

// may make the list larger than necessary
// its internal array is copied each time the list grows beyond its capacity
// code is very straight forward
progression.map { Math.sqrt(it.toDouble()) }

// will make the list the exact size needed
// no copies are made
// code is more complicated
progression.mapTo(ArrayList(progression.last / 2 + 1)) { Math.sqrt(it.toDouble()) }

// will make the list the exact size needed
// a single intermediate list is made
// code is minimal and makes sense
progression.toList().map { Math.sqrt(it.toDouble()) }

这段代码只是一个编造的例子。但仍是个很酷的技巧 :) 虽然,创建进度会创建一个新列表吗?可能需要用循环实现吧? - rozina
1
不,一个进度在迭代时生成其元素,并仅存储诸如第一个、最后一个和步长之类的内容。 - mfulton26

1
我的建议是选择您喜欢的编码风格。Kotlin既是面向对象的语言,也是函数式的语言,这意味着您提出的两种命题都是正确的。
通常,函数式结构更注重可读性而非性能;然而,在某些情况下,过程化代码也会更易读。您应该尽可能地坚持一种风格,但如果您觉得某些代码更适合于您的约束条件(包括可读性、性能或两者兼备),请不要害怕切换一些代码。

0

转换后的代码不需要手动创建目标列表,可以简化为:

val src = (0 until 1000000).toList()

val dest = src.filter { it % 2 == 0 }
              .map { Math.sqrt(it.toDouble()) }

正如@glee8e所述的优秀答案中提到的那样,您可以使用序列进行惰性求值。 使用序列的简化代码:

val src = (0 until 1000000).toList()

val dest = src.asSequence()                      // change to lazy
              .filter { it % 2 == 0 }
              .map { Math.sqrt(it.toDouble()) }
              .toList()                          // create the final list

请注意,在结尾添加toList()是为了将序列转换回在处理过程中所做的复制品的最终列表。您可以省略该步骤以保持为序列。

值得注意的是,@hotkey的评论指出,您不应总是假设另一个迭代或列表的副本比惰性评估更劣。@hotkey说:

有时候即使复制整个集合,多个循环也会表现良好,因为具有良好的引用局部性。请参见:Kotlin的Iterable和Sequence看起来完全相同。为什么需要两种类型?

摘自该链接:

大多数情况下,它具有良好的引用局部性,因此能够利用 CPU 缓存、预测、预取等功能,即使多次复制集合,仍然足够快且在简单的小集合情况下表现更佳。

@glee8e表示Kotlin序列和Java 8流之间存在相似之处,有关详细比较,请参见:标准Kotlin库中提供了哪些Java 8 Stream.collect等效项?


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