Kotlin 中的惰性集合(序列)

1 通过序列提高效率

首先看以下代码:

kotlin 复制代码
val list = listOf(1, 2, 3, 4, 5)
list.filter { it > 2 }.map { it * 2 }

上面的写法很简单,在处理集合时,类似于上面的操作能帮我们解决大部分的问题。但是,当 list 中的元素非常多的时候(比如超过 10 万),上面的操作在处理集合的时候就会显得比较低效。

将上面的 Kotlin 代码转换成 Java 代码查看:

java 复制代码
public final class TestKt {
   public static final void main() {
      List list = CollectionsKt.listOf(new Integer[]{1, 2, 3, 4, 5});
      Iterable $this$map$iv = (Iterable)list;
      int $i$f$map = false;
      Collection destination$iv$iv = (Collection)(new ArrayList());
      int $i$f$mapTo = false;
      Iterator var6 = $this$map$iv.iterator();

      Object item$iv$iv;
      int it;
      boolean var9;
      while(var6.hasNext()) { // 第一次 while 循环
         item$iv$iv = var6.next();
         it = ((Number)item$iv$iv).intValue();
         var9 = false;
         if (it > 2) {
            destination$iv$iv.add(item$iv$iv);
         }
      }

      $this$map$iv = (Iterable)((List)destination$iv$iv);
      $i$f$map = false;
      destination$iv$iv = (Collection)(new ArrayList(CollectionsKt.collectionSizeOrDefault($this$map$iv, 10)));
      $i$f$mapTo = false;
      var6 = $this$map$iv.iterator();

      while(var6.hasNext()) { // 第二次 while 循环
         item$iv$iv = var6.next();
         it = ((Number)item$iv$iv).intValue();
         var9 = false;
         Integer var11 = it * 2;
         destination$iv$iv.add(var11);
      }

      List var10000 = (List)destination$iv$iv;
   }

   // $FF: synthetic method
   public static void main(String[] var0) {
      main();
   }
}

从上面的代码中可以看出创建了 2 个 while 循环。也就是说 filter 方法和 map 方法都会返回都会返回一个新的集合,也就是说上面的操作会产生两个临时集合,因为 list 会先调用 filter 方法,然后产生的集合会再次调用 map 方法。如果 list 中的元素非常多,这将是一笔不小的开销。为了解决这个问题,Sequence(序列)就出现了。

Kotlin 惰性集合操作的入口就是 Sequence 接口。这个接口表示的就是一个可以逐个列举元素的元素序列。Sequence 只提供了一个方法,iterator,用来从序列中获取值。 以下是 Sequence 的源码:

kotlin 复制代码
public inline fun <T> Sequence(crossinline iterator: () -> Iterator<T>): Sequence<T> = object : Sequence<T> {
    override fun iterator(): Iterator<T> = iterator()
}

Sequence 接口的强大之处在于其操作的实现方式。序列中的元素求值是惰性的。因此,可以使用序列更高效地对集合元素进行链式操作,而不需要创建额外的结合保存过程中产生的中间结果。也就是说,序列可以避免创建临时的中间对象。

一般的操作是:通过调用扩展函数 asSequence 把任意集合转换成序列,调用 toList 来做反向的转换。 以下是序列的使用:

kotlin 复制代码
val list = listOf(1, 2, 3, 4, 5)
list.asSequence().filter { it > 2 }.map { it * 2 }.toList()

首先通过 asSequence() 方法将列表转换为一个序列,然后在这个序列上进行相应的操作,最后通过 toList() 方法将序列转换为列表。

将 list 转换为序列,在很大程度上就提高了上面操作集合的效率。这是因此在使用序列的时候,filter 方法和 map 方法的操作都没有创建额外的集合,这样当集合中的元素数量巨大的时候,就减少了大部分开销。这是因为,转换为序列之后,filter 和 map 共享一个迭代器(iterator),只需要循环一次即可。

那么,为什么要把序列转换回集合呢,使用序列代替集合不是更方便吗?有时候是的,如果我们只需要迭代序列中的元素,可以直接使用序列。但是如果要用下标访问元素,就需要把序列转换成列表。 这是因为序列的操作是惰性的,为了执行它们,需要直接迭代序列元素,或者把序列转换成一个集合。

在 Kotlin 中,序列中元素的求值是惰性的,这就意味着在利用序列进行链式求值时,不需要像操作普通集合那样,每进行一次求值操作,就产生一个新的集合保存中间数据。

那么究竟惰性是什么意思呢?

在编程语言理论中,惰性求值(Lazy Evaluation)表示一种在需要时才进行求值的计算方式。在使用惰性求值的时候,表达式不在它被绑定到变量之后就立即求值,而是在该值被取用时才去求值。通过这种方式,不仅能得到性能上的提升,还有一个重要的好处就是它可以构造出一个无限的数据类型。

通过上面的定义我们可以知道惰性求值的两个好处,一个是优化性能,另一个就是能构造出无限的数据类型。

2 序列的操作方式:中间操作和末端操作

我们知道序列中元素的求值方式是采用惰性求值的。那么,惰性求值在序列中是如何体现的呢?

序列的操作分为两类:中间操作和末端操作。中间操作返回的是另一个序列,这个新序列知道如何变换原始序列中的元素。末端操作返回的是一个结果,这个结果可能是集合、元素、数字,或者其他从初识结合的变换序列中获取的任意对象。

以下面的代码为例:

kotlin 复制代码
list.asSequence().filter { it > 2 }.map { it * 2 }.toList()

在这个例子中,我们对序列总共执行了两类操作,第一类:

kotlin 复制代码
filter { it > 2 }.map { it * 2 }

filter 和 map 的操作返回的都是序列,我们将这类操作称为中间操作。还有一类:

kotlin 复制代码
toList()

这一类操作序列转换为 List,我们将这类操作称为末端操作。末端操作触发了所有的延期计算。

2.1 中间操作

在对普通集合进行链式操作的时候,有些操作会产生中间集合,当用这类操作来对序列进行求值的时候,它们就被称为中间操作,比如上面的 filter 和 map。

每一次中间操作返回的都是一个序列,产生的新序列内部知道如何去变换原来序列中的元素。中间操作都是采用惰性求值的, 比如:

kotlin 复制代码
list.asSequence().filter {
    println("filter $it")
    it > 2
}.map {
    println("map $it")
    it * 2
}

执行,没有打印,说明上面的操作中的 println 方法根本就没有执行,这说明 filter 方法和 map 方法的执行被延迟了,这就是惰性求值的体现。

惰性求值仅仅在该值被需要的时候才会真正去求值。那么这个"被需要"的状态该怎么触发呢?这就是另外一个操作了------末端操作。

2.2 末端操作

在对集合进行操作的时候,大部分情况下,我们在意的只是结果,而不是中间过程。

末端操作就是一个返回结果的操作,它的返回值不能是序列,必须是一个明确的结果,比如列表、数字、对象等表意明确的结果。末端操作一般都是放在链式操作的末尾,在执行末端操作的时候,会出发中间操作的延迟计算,也就是将被需要这个状态打开了。

下面给上面的例子加上末端操作:

kotlin 复制代码
list.asSequence().filter {
    println("filter $it")
    it > 2
}.map {
    println("map $it")
    it * 2
}.toList()

//filter 1
//filter 2
//filter 3
//map 3
//filter 4
//map 4
//filter 5
//map 5

可以看到,所有的中间操作都被执行了。

如果不用序列而是用列表来实现会有什么不同之处:

kotlin 复制代码
list.filter {
    println("filter $it")
    it > 2
}.map {
    println("map $it")
    it * 2
}.toList()

//filter 1
//filter 2
//filter 3
//filter 4
//filter 5
//map 3
//map 4
//map 5

通过对比上面的结果,可以发现,普通集合在进行链式操作的时候会现在 list 上调用 filter,然后产生一个结果列表,接下来 map 就在这个结果列表上进行操作。而序列不一样,序列在执行链式操作时,会将所有的操作都引用在一个元素上,也就是说,第 1 个元素执行完所有的操作之后,第 2 个元素在去执行所有的操作,以此类推。

反映扫上面的这个例子,就是第 1 个元素执行了 filter 之后再去执行 map,然后,第 2 个元素也是这样。通过上面序列的返回结果可以知道,由于列表中的元素 1、2 没有满足 filter 操作中大于 2 的条件,所以接下来的 map 操作就不会去执行了。所以,当我们使用序列的时候,如果 filter 和 map 的位置是可以相互调换的话,应该优先使用 filter,这样会减少一部分开销。

3 序列可以是无限的

惰性求值最大的好处就是可以构造出一个无限的数据类型。

那么我们是否可以使用序列来构造一个无限的数据类型呢?答案是肯定的。常见的无限数据类型是什么呢?数列,比如自然数数列就是一个无限的数列。

那么如何去实现一个自然数列呢?采用一般的列表肯定是不行的,因为构建一个列表必须列举出列表中元素,而我们是么有办法将自然数全部列举出来。

自然数是有一定规律的,就是后一个数永远是前一个数加 1 的结果,我们只需要实现一个列表,让这个列表描述这种规律,那么也就是相当于实现了一个无限的自然数数列。Kotlin 为我们提供了这样一个方法,去创建无限的数列:

kotlin 复制代码
val naturalNumList = generateSequence(0) { it + 1 }

通过上面着一行代码,通过调用 generateSequence 就非常简单地实现了自然数数列。

我们知道序列是惰性求值的,所以上面创建的序列是不会把所有的自然数都列举出来的,只有在我们调用一个末端操作的时候,才去列举我们所需要的列表。

比如我们要从这个自然数列表中取出前 10 个自然数:

kotlin 复制代码
val list = naturalNumList.takeWhile { it <= 9 }.toList()
// [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

关于无限序列这一点,我们不能将一个无限的数据结构通过穷举的方式呈现出来,而只是实现了一种表示无限的状态,让我们在使用的时候感觉它是无限的。

4 序列与 Java 8 Stream 对比

序列看上去就和 Java 8 中的流(Stream)比较类似。下面就列举一些 Java 8 Stream 中比较常见的特性,并与 Kotlin 中的序列进行比较。

4.1 Java 也能使用函数风格 API

在 Java 8 出来之后,在 Java 中也能像在 Kotlin 中那样操作集合了:

java 复制代码
students.stream().filter(it -> it.sex == "m").collect(toList());

在上面的 Java 代码中,我们通过使用 stream 就能够使用类似于 filter 这种简洁的函数式 API 了。

但是相比于 Kotlin,Java 的这种操作方式还是有些繁琐,因为如果要对集合使用这种 API,就必须先将集合转换为 stream,操作完成之后,还要将 stream 转换为 List,这种操作有点类似于 Kotlin 的序列,也是高效的。

这是因为 Java 8 的流和 Kotlin 中的序列一样,也是惰性求值的,这就意味着 Java 8 的流也是存在中间操作和末端操作的,所以必须通过上面的一系列转换才行。

4.2 Stream 是一次性的

与 Kotlin 的序列不同,Java 8 中的流是一次性的。意思就是说,如果我们创建了一个 Stream,我们只能在这个 Stream 上遍历一次。

这就和迭代器很相似,当我们遍历万之后,这个流就相当于被消费掉了,我们必须再创建一个新的 Stream 才能再遍历一次。

java 复制代码
Stream<Student> studentsStream = students.stream();
studentsStream.filter(it -> it.sex == "m").collect(toList());
studentsStream.filter(it -> it.sex == "f").collect(toList());
4.3 Stream 能够并行处理数据

Java 8 中的流非常强大,其中有一个非常重要的特性就是 Java 8 Stream 能够在多核架构上并行的进行流处理。比如将前面的例子转换为并行处理的方式如下:

java 复制代码
students.paralleStream().filter(it -> it.sex == "m").collect(toList());

只需要将 stream 转换成 paralleStream 即可。

相关推荐
waicsdn_haha1 小时前
Java/JDK下载、安装及环境配置超详细教程【Windows10、macOS和Linux图文详解】
java·运维·服务器·开发语言·windows·后端·jdk
亽仒凣凣2 小时前
Windows安装Redis图文教程
数据库·windows·redis
炫彩@之星3 小时前
Windows和Linux安全配置和加固
linux·windows·安全·系统安全配置和加固
小奥超人3 小时前
RAR压缩算法的文件修复功能详解
windows·经验分享·winrar·办公技巧
Clockwiseee13 小时前
php伪协议
windows·安全·web安全·网络安全
唐宋元明清218815 小时前
.NET 阻止系统睡眠/息屏
windows·电源
yylの博客17 小时前
Windows通过git-bash安装zsh
windows·git·bash·zsh
进击的code17 小时前
windows 下使用WLS2 编译aosp Android14并刷机到pixle 5a
windows
居居飒17 小时前
Android学习(四)-Kotlin编程语言-for循环
android·学习·kotlin
禁默17 小时前
2024年图像处理、多媒体技术与机器学习
图像处理·人工智能·microsoft