Kotlin 高阶函数详解

高阶函数

在 Kotlin 中,函数是一等公民,高阶函数是 Kotlin 的一大难点,如果高阶函数不懂的话,那么要学习 Kotlin 中的协程、阅读 Kotlin 的源码是非常难的,因为源码中有太多高阶函数了。

高阶函数的定义

高阶函数的定义非常简单:一个函数如果参数类型是函数或者返回值类型是函数,那么这就是一个高阶函数

函数类型

kotlin 中,有整型 Int、字符串类型 String,同样函数也有类型,举个例子:

fun add(num1: Int, num2: Int): Int {
    return num1 + num2
}

这个 add 函数的函数类型就是 (Int, Int) -> Int函数类型其实就是将函数的 "参数类型" 和 "返回值类型" 抽象出来,既然 (Int, Int) -> Int 是函数类型,那么它就可以跟整型,字符串类型一样,将一个变量定义成函数类型,如下所示,变量 c 的类型就是函数类型,这时编译器没有报错,所以是可以将变量的类型设置为函数类型的。

那么怎么给 c 这个变量赋值呢?类比整型、字符串变量的赋值,要给一个函数类型的变量赋值,我们需要将一个具有相同函数类型的函数引用赋值给变量就可以了,具体写法如下所示:

val c: (Int, Int) -> Int = ::add

fun add(num1: Int, num2: Int): Int = num1 + num2

::add 这种写法是一种函数引用方式的写法。

除了函数引用这种方式外,Kotlin 还支持用 Lambda 表达式对一个函数类型的变量进行赋值。如下所示:

val c: (Int, Int) -> Int = {num1: Int, num2: Int -> num1 + num2}

实际项目中,绝大多数情况下我们都是用 Lambda 表达式来调用高阶函数的。

Lambda 表达式语法结构:{参数名1: 参数类型, 参数名2: 参数类型 -> 函数体} 函数体中可以编写任意行代码,最后一行代码会自动作为 Lambda 表达式的返回值

了解了函数类型高阶函数的定义,我们很简单的就可以定义高阶函数了,如下所示:

// 参数是函数类型的高阶函数
fun higherFunction(func: (Int, Int) -> Int) {
    
}
// 返回值是函数类型的高阶函数
fun higherFunction(): (Int, Int) -> Int {

}

高阶函数的调用

我们以 Kotlin 中数组的遍历为例子来讲高阶函数的调用。

首先我们定义一个 Int 类型的数组,如下所示:

val intArray = intArrayOf(1, 2, 3, 4, 5)

我们不用 for in 的方式来遍历,而是用 forEach 方法来遍历,forEach 函数就是一个高阶函数,源码如下所示:

public inline fun IntArray.forEach(action: (Int) -> Unit): Unit {
    for (element in this) action(element)

首先高阶函数肯定是一个函数,那么方法的调用如下这样写肯定是没有问题的:

intArray.forEach(?)

只是这个 是个函数类型的参数,函数类型是 (Int) -> Unit,那么我就定义一个相同的函数类型的变量传给 forEach 不就好了嘛,如下所示:

val action: (Int) -> Unit = ??

fun main() {
    intArray.forEach(action)
}

通过上述的学习,我们知道这里的 ?? 可以是函数引用或者是 Lambda 表达式,如果我们用函数引用那代码就是这样的:

val action: (Int) -> Unit = ::printValue

fun main() {
    intArray.forEach(action)
}

fun printValue(value: Int): Unit {
    println(value)
}

前面我们已经讲过,实际项目中,绝大多数情况下我们都是用 Lambda 表达式来调用高阶函数的,因为函数引用比较麻烦,为了调用高阶函数,我们还得特意写一个函数。并且 Lambda 表达式还有很多简便的写法。

我们利用 Lambda 表达式来改写上述代码,如下所示:

val action: (Int) -> Unit = {value: Int -> println(value)}

fun main() {
    intArray.forEach(action)
}

Lambda 表达式有很多简便的写法,现在我们就对 {value: Int -> println(value)} 进行简化:

  1. Kotlin 有类型推到机制,所以 Int 可以去掉

    val action: (Int) -> Unit = {value -> println(value)}

  2. Lambda 表达式如果只有一个参数,可以直接用 it 来代替,并且不需要声明参数名

    val action: (Int) -> Unit = {println(it)}

将简化后的代码代入,现在上述的代码就变成如下这样:

fun main() {
    intArray.forEach({println(it)})
}

这个代码还可以进行简化:

  1. 当 Lambda 参数是函数的最后一个参数时,可以将 Lambda 表达式移到函数括号的外面

    fun main() {
    intArray.forEach(){
    println(it)
    }
    }

  2. 如果 Lambda 表达式是函数的唯一一个参数的话,还可以将函数的括号省略

    fun main() {
    intArray.forEach{
    println(it)
    }
    }

到此为止就无法继续简化了,这就是最终版本,相比较于最开始的样子,这个代码已经非常简洁了。

带有接收者的函数类型

前面我们举了 forEach 高阶函数,我们再来看一个高阶函数 apply,看看这两者有什么区别,apply 函数源码如下:

public inline fun <T> T.apply(block: T.() -> Unit): T {
    block()
    return this
}

apply 函数接收的函数类型是 T.() -> Unit,相比较于前面我们所见的函数类型,多了一个 T.,那么这个 T. 有什么作用呢?

再说作用之前,我们再来看一个高阶函数 also,这几个高阶函数都是定义在 Kotlin 标准库中的,目的是在对象上下文内执行代码块,also 函数的源码如下所示:

public inline fun <T> T.also(block: (T) -> Unit): T {
    block(this)
    return this
}

also 函数接收的函数类型是 (T) -> Unit

我们来看一下这两个函数实际运用中有哪些不同,如下所示:

假设这里我们把泛型 T 当中 User,User.() -> Unit 表示这个函数类型是定义在 User 类当中的,那么这里将函数类型定义到 User 类当中有什么好处呢?好处就是当我们调用 apply 函数时传入的 Lambda 表达式将会自动拥有 User 的上下文,以便访问接收者对象的成员而无需任何额外的限定符。

这个说起来确实有点抽象,但是结合上面的图片我觉得还是比较容易懂的。

到这里为止,高阶函数的理论知识我们已经算是讲完了。

高阶函数的应用

案例一:统计文件中各个字符(不包括空白字符)的个数

fun main() {
    File("build.gradle").readText() // 读文件,直接以 String 的格式返回
        .toCharArray()  // 将字符串转换成字符数组
        .filter { !it.isWhitespace() }  // 过滤空白字符
        .groupBy { it } // 按照集合中每个字符分组
        .map {it.key to it.value.size } // 映射,重新生成新的集合
        .let {
            println(it)
        }
}

运行结果如下所示:

这个案例中我们用到了 filter、groupBy、map 和 let 这几个高阶函数。如果对这个写法不是很懂的话,可以将每一步的结果打印出来看一下。

inline 优化

在讲什么是 inline 优化之前我们先来看一下高阶函数的实现原理。我们知道 Kotlin 和 Java 是完全兼容的,最后都会被编译成 .class 文件,但是 Java 里面没有高阶函数的概念,那么 Kotlin 高阶函数如果被反编译成 Java 代码会是什么样子的呢?

例:我们来看下面这个高阶函数 foo():

fun main() {
    var i = 0
    foo {
        i++
        println(i)
    }
}

fun foo(block: () -> Unit) {
    block()
}

反编译之后的 Java 代码:

// 主要代码,省略了一些没用的代码
public final class HigherFunctionKt {
   public static final void main() {
      foo((Function0)(new Function0() {
         public Object invoke() {
            this.invoke();
            return Unit.INSTANCE;
         }

         public final void invoke() {
            int var10001 = i.element++;
            int var1 = i.element;
            System.out.println(var1);
         }
      }));
   }

   public static final void foo(@NotNull Function0 block) {
      Intrinsics.checkNotNullParameter(block, "block");
      block.invoke();
   }
}

这里的 Function0 是一个接口,可以看到高阶函数 foo 的函数类型参数,变成了 Function0,而 main() 函数当中的高阶函数调用,也变成了"匿名内部类"的调用方式。所以高阶函数最终还是以匿名内部类的形式在运行,难道 Kotlin 高阶函数只是为了简化"匿名内部类"的写法吗?

当然不是,Kotlin 高阶函数的性能是远远高于匿名内部类,某些极端情况下,甚至有几百倍的性能提升。当然我们上面的实现是无法提高性能的,不过写法也很简单,只需要在函数的前面加上一个 inline 关键字就可以了。

我们来测试一下,看看 inline 关键字是不是真的能提高高阶函数的性能,这里我们利用 JMH 来进行测试,代码如下:

@BenchmarkMode(Mode.Throughput) // 基准测试的模式,采用整体吞吐量的模式
@Warmup(iterations = 3) // 预热次数
@Measurement(iterations = 10, time = 5, timeUnit = TimeUnit.SECONDS) // 测试参数,iterations = 10 表示进行10轮测试
@Threads(8) // 每个进程中的测试线程数
@Fork(2)  // 进行 fork 的次数,表示 JMH 会 fork 出两个进程来进行测试
@OutputTimeUnit(TimeUnit.MILLISECONDS) // 基准测试结果的时间类型
open class SequenceBenchmark {

    // 不用inline的高阶函数
    fun foo(block: () -> Unit) {
        block()
    }

    // 使用inline的高阶函数
    inline fun fooInline(block: () -> Unit) {
        block()
    }

    // 测试无inline的代码
    @Benchmark
    fun testNonInlined() {
        var i = 0
        foo {
            i++
        }

    }

    // 测试inline的代码
    @Benchmark
    fun testInlined() {
        var i = 0
        fooInline {
            i++
        }
    }
}

fun main() {

    val options = OptionsBuilder()
        .include(SequenceBenchmark::class.java.simpleName)
        .output("benchmark_sequence.log")
        .build()
    Runner(options).run()
}

测试结果如下,分数越高性能越好:

从上面的测试结果我们能看出来,是否使用 inline,它们之间的效率几乎相差 30 倍。而这还仅仅只是最简单的情况,如果在一些复杂的代码场景下,多个高阶函数嵌套执行,它们之间的执行效率会相差上百倍。

如果我们将函数嵌套十层,再来测试,会发现性能差距更大,代码如下所示:

@BenchmarkMode(Mode.Throughput) // 基准测试的模式,采用整体吞吐量的模式
@Warmup(iterations = 3) // 预热次数
@Measurement(iterations = 10, time = 5, timeUnit = TimeUnit.SECONDS) // 测试参数,iterations = 10 表示进行10轮测试
@Threads(8) // 每个进程中的测试线程数
@Fork(2)  // 进行 fork 的次数,表示 JMH 会 fork 出两个进程来进行测试
@OutputTimeUnit(TimeUnit.MILLISECONDS) // 基准测试结果的时间类型
open class SequenceBenchmark {

    // 不用inline的高阶函数
    fun foo(block: () -> Unit) {
        block()
    }

    // 使用inline的高阶函数
    inline fun fooInline(block: () -> Unit) {
        block()
    }

   @Benchmarkfun testNonInlined() {var i = 0 foo { foo { foo { foo { foo { foo { foo { foo { foo { foo { i++ } } } } } } } } } }}
   
   @Benchmarkfun testInlined() { var i = 0 fooInline { fooInline { fooInline { fooInline { fooInline { fooInline { fooInline { fooInline { fooInline { fooInline {
}

fun main() {

    val options = OptionsBuilder()
        .include(SequenceBenchmark::class.java.simpleName)
        .output("benchmark_sequence.log")
        .build()
    Runner(options).run()
}

测试结果如下:

从上面的性能测试数据我们可以看到,在嵌套了 10 个层级以后,我们 testInlined 的性能几乎没有什么变化;而当 testNonInlined 嵌套了 10 层以后,性能也比 1 层嵌套差了 6 倍。并且此时,两个函数的性能差距将近 200 倍。

那么 inline 关键字是如何让高阶函数的性能提高这么多的呢?

inline 原理

其实内联函数的工作原理很简单,就是 Kotlin 编译器会将内联函数中的代码在编译的时候自动替换到调用它的地方,这样也就不存在运行时的开销了

以下面这段代码作为例子:

// 使用inline的高阶函数
inline fun fooInline(block: () -> Unit) {
    block()
}

@Benchmark
fun testInlined() {
    var i = 0
    fooInline {
        fooInline {
            fooInline {
                fooInline {
                    fooInline {
                        fooInline {
                            fooInline {
                                fooInline {
                                    fooInline {
                                        fooInline {
                                            i++
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

根据内联函数的原理,上面的代码等价于下面这样:

// 使用inline的高阶函数
inline fun fooInline(block: () -> Unit) {
    block()
}

@Benchmark
fun testInlined() {
    var i = 0
    fooInline { 
        i++
    }
}

所以在嵌套了 10 个层级以后,testInlined 的性能几乎没有什么变化。把这段代码反编译成 Java 代码,也是如此:

@Benchmark
public final void testInlined() {
   int i = 0;
   int $i$f$fooInline = false;
   int var4 = false;
   int $i$f$fooInline = false;
   int var7 = false;
   int $i$f$fooInline = false;
   int var10 = false;
   int $i$f$fooInline = false;
   int var13 = false;
   int $i$f$fooInline = false;
   int var16 = false;
   int $i$f$fooInline = false;
   int var19 = false;
   int $i$f$fooInline = false;
   int var22 = false;
   int $i$f$fooInline = false;
   int var25 = false;
   int $i$f$fooInline = false;
   int var28 = false;
   int $i$f$fooInline = false;
   int var31 = false;
   int i = i + 1;
}

总结

如果一个函数的参数是函数类型或者返回值是函数类型,那么这个函数就是高阶函数。高阶函数可以简化我们的代码,并且利用 inline 关键字可以提高高阶函数的性能。

在 kotlin 源码的 Standard.kt 文件中定义了几个我们平时会经常用到的高阶函数,可以去看一看。

相关推荐
xlsw_1 小时前
java全栈day20--Web后端实战(Mybatis基础2)
java·开发语言·mybatis
神仙别闹2 小时前
基于java的改良版超级玛丽小游戏
java
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭2 小时前
SpringBoot如何实现缓存预热?
java·spring boot·spring·缓存·程序员
暮湫2 小时前
泛型(2)
java
超爱吃士力架2 小时前
邀请逻辑
java·linux·后端
南宫生3 小时前
力扣-图论-17【算法学习day.67】
java·学习·算法·leetcode·图论
转码的小石3 小时前
12/21java基础
java
李小白663 小时前
Spring MVC(上)
java·spring·mvc
GoodStudyAndDayDayUp3 小时前
IDEA能够从mapper跳转到xml的插件
xml·java·intellij-idea
装不满的克莱因瓶4 小时前
【Redis经典面试题六】Redis的持久化机制是怎样的?
java·数据库·redis·持久化·aof·rdb