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 即可。

相关推荐
青春不流名5 小时前
yarn application命令中各参数的详细解释
windows
alexhilton7 小时前
不使用Jetpack Compose的10个理由
android·kotlin·android jetpack
圆觉妙心9 小时前
解决 iOS日志在 Windows 电脑显示
windows·ios
心灵宝贝9 小时前
Visio 2021 专业版是微软推出的一款专业图表绘制工具 资源分享
windows
RPA中国9 小时前
全球首创!微软发布医疗AI助手,终结手写病历时代
人工智能·microsoft
扛枪的书生10 小时前
Windows 提权-PrintNightmare
windows·渗透·kali·提权
T0uken10 小时前
【C++】使用 CMake 在 Windows 上自动化发布 C++/Qt 应用程序
c++·windows·自动化
独隅10 小时前
Android Studio 的详细安装步骤,适用于 Windows/MacOS/Linux 系统
windows·macos·android studio
鲤籽鲲13 小时前
C# IComparer<T> 使用详解
windows·c#·基础语法·c# 知识捡漏
独隅13 小时前
VSCode详细安装步骤,适用于 Windows/macOS/Linux 系统
windows·vscode·macos