Kotlin Sequences Api:入门

Kotlin Sequences Api:入门

前言

在日常开发中,项目处理特定类型是每个软件开发人员日常工作的一部分,显然,举个例子,一个咖啡烘焙机、多个咖啡原产地、原产地农民之间的咖啡描绘,您可以通过多种方式处理此类数据。最常见的是通过API,比如说List<Roaster>``Set<Origin>``Map<Origin, Farmer>等等

一般情况下,集合可能适用所有情况,但是针对开发中的不同情况,我们需要找到更适合的方式去解决问题,正所谓术业有专攻,最合适的才是最好的;在本文中,我将带领大家入门 Kotlin的序列(Sequences Api),具体来说,从以下三方面进行讲解

  • 什么是序列以及它是如何工作的

  • 如何去使用序列

  • 什么时候应该考虑使用序列而不是集合

理解序列

​ 序列其实是一种数据容器,就像集合一样,但是和集合又有些不同,具体体现在两个方面

  • 执行序列操作是惰性的
  • 每次只处理一个元素

接下来,我们将深入了解使用序列处理的方式有什么意义

惰性处理

在处理数据的时候,序列懒惰地执行它们,而集合则急切地执行它们,例如,如果将map用于集合中:

val list = listOf(1, 2, 3)
val doubleList = list.map { number -> number * 2 }

该操作将立即执行,并且doubleList 将是第一个列表中元素的列表乘以 2。 但是,如果您使用序列执行此操作:

val originalSequence = sequenceOf(1, 2, 3)
val doubleSequence = originalSequence.map { number -> number * 2 }

虽然doubleSequence 是与 originalSequence 不同的序列,但它不会有加倍的值。相反,doubleSequence 是由初始 originalSequencemap 操作组成的序列。当您查询doubleSequence 的结果时,该操作只会在稍后执行。 但是,在了解如何从序列中获取结果之前,您需要了解创建它们的不同方法。

创建序列
  1. 您可以通过几种方式创建序列。 您已经在上文中看到了其中的一种方式
val sequence = sequenceOf(1, 2, 3)

​ 这个sequenceOf() 函数的工作方式与 listOf() 函数或任何其他同类集合函数一样。 您将元素作为参数传入,它会输出一个序列。

  1. 创建序列的另一种方法是从集合中这样做,将集合转为序列

    val coffeeOriginsSequence = listOf(
      "Ethiopia", 
      "Colombia", 
      "El Salvador"
    ).asSequence()
    

asSequence() 函数可以在每个 Collection 实现的任何Iterable 迭代器上调用。 它输出与所述 Iterable 中存在的相同元素的序列

  1. 最后一个创建序列的方法就是使用生成器函数,下面有一个例子
val naturalNumbersSequence = generateSequence(seed = 1) { previousNumber -> previousNumber + 1 }

可以看到generateSequence函数将seed参数作为序列的第一个元素,并从该元素开始生成剩余的元素

Collection接口不同的,Sequence接口不会将其任何实现绑定到size属性上面。换句话说,我们可以创建一个无限序列,这就是上面代码所作的事情,从1开始,然后到无穷大,并且在每个生成的值上累加1,那这样您就会有疑问了,我如果尝试在此序列上进行操作,获取其所有元素,那么该怎么停下来呢,因为它是无限的

一种方法是在生成器函数本身中使用某种停止机制,实际上,generateSequence函数在返回null的时候就会停止生成,下面是创建有限序列的方法

    val naturalNumbersUpToTwoHundredMillion = 
      generateSequence(seed = 1) { previousNumber ->
        if (previousNumber < 200_000_000) { // 1
            previousNumber + 1
        } else {
            null // 2
        }
      }

​ 在上面代码中

  • 先检查先前生成的值是否小于200,000,000,若是符合则添加下一个元素
  • 如果达到等于或大于 200,000,000 的值,则返回 null,从而有效地停止序列生成

停止序列生成的另一种方法是使用它的一些运算符,接下来会开始讲解它们

使用序列运算符

序列有两种运算符

  • 中间运算符(intermediate):用于构建序列的运算符

  • 终端运算符(terminal):用于执行构建序列的操作的操作符

接下来我们首先了解下中间运算符

中间运算符(Intermediate Operators)

要开始了解运算符的工作原理,我们先用上文提供的最后的一个序列的例子

val naturalNumbersUpToTwoHundredMillion = 
  generateSequence(seed = 1) { previousNumber ->
    if (previousNumber < 200_000_000) {
      previousNumber + 1
    } else {
      null
    }
  }

现在,通过添加两个中间运算符从中构建一个新序列。你可能会认出这些,因为序列和集合有很多类似的运算符,比如说filtertake

val firstHundredEvenNaturalNumbers = naturalNumbersUpToTwoHundredMillion
  .filter { number -> number % 2 == 0 } // 1
  .take(100) // 2

在上面代码中,主要做了如下工作

  • 按奇偶性过滤元素,只需要偶数
  • 只取前100个元素,丢弃其余元素

如前所述,序列一次处理一个元素。 换句话说,过滤器首先对第一个数字 1 进行操作,然后将其丢弃,因为它不能被 2 整除。 然后,它对 2 进行运算,让它继续取,因为 2 是偶数。 操作一直持续到操作的元素为 200,因为在 [1, 200_000_000] 区间内,200 是第100 个偶数。 那时,takefilter 都不再处理任何元素。

是不是有些难以理解,为了直观体现,下图我们将上述操作进行可视化

intermediate-operators.gif

多亏了 take(100),从 200 开始,200,000,000 和它之前的所有数字永远不会被操作。您会在临时文件中注意到,firstHundredEvenNaturalNumbers 实际上还没有输出任何值。 实际上,暂存文件只是显示类型:

Screenshot-2022-02-15-at-22.09.26-650x54.png

可以看到我们已经知道它是int类型的序列,这个时候我们就需要一个终端操作符terminal operators来处理输出序列的结果

终端运算符(Terminal Operators)

终端运算符可以采取多种方式,例如,toListtoSet就可以将序列结果作为集合输出,其他的,类似first()sum()可以输出单个值

实际上,有特别多终端操作符,但有一种简单的方法可以识别它们,而无需深入研究实现或查阅文档。

回到刚刚所写的代码中,就在 take(100) 下方,开始输入map运算符。 在您键入时,Android Studio 会弹出代码完成。 如果您查看提示,您会看到 map 的返回类型为 Sequence,而 R 是 map 的返回类型。

Screenshot-2022-02-16-at-00.24.08.png 然后我们换成开始输入forEach 终端操作符。 当代码完成弹出提示的时候,请注意 forEach 的返回类型。

Screenshot-2022-02-16-at-00.59.25.png

map不同,forEach不返回序列。 这是有道理的,对吧? 毕竟是终端运算符。 所以,长话短说,这就是你如何一眼就能区分它们的方法:

  • 中间运算符总是会返回一个序列的
  • 终端运算符是绝对不会返回序列的

现在知道了如何去构建序列并且输出结果了把,通过forEach循环打印每个元素来完成刚刚编写的终端操作符。 最后,代码如下

val firstHundredEvenNaturalNumbers = naturalNumbersUpToTwoHundredMillion
  .filter { number -> number % 2 == 0 }
  .take(100)
  .forEach { number -> println(number) }

然后运行看下实际的输出结果

Screenshot-2022-02-16-at-01.35.05-650x212.png

可以看到,它打印了每个偶数,最多 200 个。

接着我们来看,就像集合一样,运算符顺序在序列中很重要。 例如,我们将 take filter 交换,如下所示:

val firstHundredEvenNaturalNumbers = naturalNumbersUpToTwoHundredMillion
  .take(100)
  .filter { number -> number % 2 == 0 } 
  .forEach { number -> println(number) }

那么它输出的结果会和上面一样么?答案是不会的,您会看到它打印了最多 100 个偶数。由于 take 先运行,filter 只对前 100 个自然数进行操作,从一个开始。

all right,到这里为止,我们已经知道如何使用序列了,接下来就要回到我们最初提到的什么时候应该考虑使用序列而不是集合这个问题上来了

序列与集合

你现在知道如何构建和使用序列了。 但是什么时候应该使用它们而不是集合呢? 你应该使用它们吗? 这可以用软件开发中最著名的一句话来快速回答:视情况而定。 :]

​ 长答案有点复杂。 它始终取决于您的用例。 事实上,确实,您应该始终测量这两种实现,以检查哪一种更快。 但是,了解序列的一些怪癖也将帮助你做出更明智的决定。

元素操作顺序

​ 好了,如果我们有金鱼的记忆,请记住序列一次是对每个元素进行操作的, 另一方面,集合是对整个集合执行每个操作,在进行下一个操作之前构建一个中间结果。 因此,每个集合操作都会使用其结果创建一个中间集合,下一个操作将在其中进行操作

val list = naturalNumbersUpToTwoHundredMillion
  .toList()
  .filter { number -> number % 2 == 0 }
  .take(100)
  .forEach { number -> println(number) }

​ 在上面的代码中,filter 将创建一个新列表,然后 take 将对该列表进行操作,创建一个自己的新列表,依此类推。 这浪费了很多的工作!特别是因为你最终只取了 100 个元素。 绝对没有必要为百分之一之后的元素而烦恼。而序列有效地避免了计算中间结果,在这种情况下能够胜过集合。

但是也会有问题,我们在添加的每个中间操作都会引入一些开销。这种开销来自这样一个事实,即每个操作都涉及创建一个新的函数对象来存储稍后要执行的转换。 事实上,这种开销对于不够大的数据集或在您不需要那么多操作的情况下可能是有问题的。 因为这种开销甚至可能超过避免中间结果所带来的收益。

为了更好地理解这种开销来自哪里,我们来查看filter的具体实现:

public fun Sequence.filter(predicate: (T) -> Boolean): Sequence {
  return FilteringSequence(this, true, predicate)
}

FilteringSequence 是它自己的序列。 它包装了您调用过滤器的序列。 换句话说,每个中间操作符都会创建一个新的序列对象来装饰前一个序列。 最后,您留下的对象至少与中间运算符一样多。更复杂的是,并非所有中间运算符都将自己限制为仅装饰前一个序列。其中一些还需要了解序列的状态

无状态和有状态运算符(Stateless and Stateful Operators)

中间运算符大致可以归为

  • 无状态:它们独立处理每个元素,无需了解任何其他元素

  • 有状态的:他们需要有关其他元素的信息来处理当前元素。

    到目前为止,上述提到的中间运算符基本都是无状态的,那么有状态的运算符是怎么样的呢,我们在刚才的代码中,就在forEach之前添加一个sortedDescending调用

val firstHundredEvenNaturalNumbers = naturalNumbersUpToTwoHundredMillion
  .take(100)
  .filter { number -> number % 2 == 0 }
  .sortedDescending() // add this call
  .forEach { number -> println(number) }

然后我们在输出中看到,得到的数字列表与以前相同,但打印的是相反的。为了使sortedDescending能够反转它,它必须处理每个元素,同时与序列中的每个其他元素进行比较。 但是它怎么能做到这一点,因为序列一次只处理一个元素呢?

答案实际上很简单,我们来看下sortedDescending 是如何实现的,您会看到它将排序委托给一个名为 sortedWith 的函数。 反过来,如果您检查 sortedWith 的实现,你会看到如下内容

public fun Sequence.sortedWith(comparator: Comparator): Sequence {
  return object : Sequence { // 1
    override fun iterator(): Iterator { // 2
      val sortedList = this@sortedWith.toMutableList() // 3
      sortedList.sortWith(comparator) // 4
      return sortedList.iterator() // 5
    }
  }
}

上面代码主要实现了以下作用

  1. 它创建并返回一个实现了Sequence接口的匿名对象。
  2. 该对象实现了 Sequence 接口的iterator()方法。
  3. 该方法将序列转换为 MutableList
  4. 然后它根据比较器对列表进行排序。
  5. 最后,它返回列表的迭代器。

等等,什么?大为震惊,它将序列转换为集合。那个toMutableList 是一个终端操作符,这个中间算子有效地调用序列上的终端算子,然后在最后输出一个新的算子。因此,想想如果您在任何其他运算符之前调用 naturalNumbersUpToTwoHundredMillion 上的 sortedDescending 会发生什么:您将拥有一个MutableList,内存中有两亿个元素! 所以需要一段时间才能获得任何结果。

stateful.gif

可以看到它需要等待一段时间才有结果

虽然并非所有有状态的操作符都像sortedDescending 这样的,但它们都使用类似的技巧来获得执行任务所需的状态。 也就是说,这些运算符可能会对序列的性能产生巨大的负面影响,请始终注意何时使用它们,因为它们的影响可能足够强大,这样以来,还不如使用集合会更加适合

何时使用序列

毕竟,您应该对序列可能派上用场的情况有一个初步的了解。 以下简要概括了下使用序列比集合更适合的原因

  • 在处理大型数据集的时候,应用大量操作。
  • 使用避免不必要工作的中间运算符——例如 take。
  • 应当避免有状态的操作符,类似sortedDesending
  • 避免将序列转换为集合的终端运算符,例如 toList

这些仅仅是提供给大家作为参考,在遇到实际情况下,需要结合当时环境自己判断是否适合使用序列

小结

在本文中,我们了解了很多关于何时使用序列与集合的知识,但关于该主题仍有很多需要学习的地方。

如果您想深入了解 Sequence 和运算符,Kotlin 的文档始终是一个不错的起点。 查看Sequence 的文档和运算符列表

要了解更多关于它们如何与集合进行比较的信息,可以阅读Kotlin中的集合和序列

本文来自翻译☞[www.raywenderlich.com/31290959-ko…]

猜你喜欢

转载自juejin.im/post/7113183808551125028