Kotlin 学习之高阶函数与内联

211 阅读8分钟

Kotlin 中的函数是头等公民——我们不仅可以向类一样在顶层直接定义一个函数,也可以在一个函数内部定义一个局部函数。此外,我们还可以直接将函数像普通变量一样传递给另一个函数,或者在其他函数内被返回。这一特性就是 Kotlin 的新特性——高阶函数。

定义高阶函数

高阶函数的定义,如果一个函数接收另一个函数作为参数,或者返回值的类型是另一个函数,那么该函数就被称为高阶函数。
这个定义可能不太好理解,一个函数怎么能接收另一个函数作为参数呢?相比于整型、布尔类型等字段类型,Kotlin 增加了一个函数类型的概念。如果我们将这种函数类型添加到一个函数的参数声明或者返回值声明当中,那么这就是一个高阶函数了。

函数类型

定义一个函数类型的语法规则如下:

(String, Int) -> Unit

定义一个函数类型,那么最关键的就是要声明该函数接收什么参数,以及它的返回值是什么。Kotlin 中函数类型声明需要遵循以下几点:

  • 通过 -> 符号来组织参数类型和返回值类型,左边是参数类型,右边是返回值类型;
  • 必须用一对括号来包裹参数类型,如果不接收任何参数,写空括号,不能省略;
  • 返回值类型即使是 Unit,也必须显示声明; 举例,下面就是一个高阶函数:
fun example(func: (String, Int) -> Unit) {
    func("hello", 123)
}

高阶函数的用途

高阶函数允许让函数类型的参数来决定函数的执行逻辑。即使是同一个高阶函数,只要传入不同的函数类型参数,那么它的执行逻辑和最终的返回结果就可能完全不同。
举例,高阶函数定义如下:

fun num1Andnum2(num1: Int, num2: Int, operation: (Int, Int) -> Int): Int {
    val result = operation(num1, num2)
    return result
}

定义两个函数 plus 和 minus,这两个函数的参数声明和返回值声明都和 num1Andnum2 函数中的函数类型参数完全匹配:

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

fun minus(num1: Int, num2: Int): Int {
    return num1 - num2
}

调用如下:

val num1 = 100
val num2 = 80
val result1 = num1Andnum2(num1, num2, ::plus)
val result2 = num1Andnum2(num1, num2, ::minus)
println("result1 is $result1")
println("result2 is $result2")

注意这里使用了双冒号的写法, ::plus 和 ::minus,这是一种函数引用方式的写法,表示将 plus() 和 minus() 函数作为参数传递给 num1Andnum2() 函数。
使用这种函数引用的写法虽然可以,但还是需要先定义一个与其函数类型参数相匹配的函数。Kotlin 支持多种方式来调用高阶函数,比如 Lambda 表达式匿名函数成员引用等。其中,Lambda 表达式是最常见也是最普遍的高阶函数的调用方式,我们使用 Lambda 表达式修改上述代码:

val num1 = 100
val num2 = 80
val result1 = num1Andnum2(num1, num2) { n1, n2 -> n1 + n2 }
val result2 = num1Andnum2(num1, num2) { n1, n2 -> n1 - n2 }
println("result1 is $result1")
println("result2 is $result2")

我们再看看匿名函数的写法:

val num1 = 100
val num2 = 80
val result1 = num1Andnum2(num1, num2, fun (n1: Int, n2: Int): Int {
    return n1 + n2
})
val result2 = num1Andnum2(num1, num2, fun (n1: Int, n2: Int): Int {
    return n1 - n2
})
println("result1 is $result1")
println("result2 is $result2")

匿名函数的特点是没有函数名,相比于 Lambda 表达式,它需要显示指定函数的返回值类型。

内联函数

内联函数的用法

内联函数的用法很简单,只需要在定义高阶函数的时候加上 inline 关键字的声明即可:

inline fun num1Andnum2(num1: Int, num2: Int, operation: (Int, Int) -> Int): Int {
    val result = operation(num1, num2)
    return result
}

如果没有 inline 关键字, 高阶函数中的 Lambda 表达式参数在底层会被转换成匿名类(Function 系列接口)的实现方式。这就表明我们每一次调用 Lambda 表达式,都会创建一个新的匿名类实例。如果只有一个还好接收,但如果在循环体内将会创建很多个对象,会造成额外的内存和性能开销。

内联函数的工作原理

Kotlin 编译器会将内联函数中的代码在编译的时候自动替换到调用的地方。 调用上述的内联函数:

fun main() {
    val num1 = 100
    val num2 = 80
    val result = num1Andnum2(num1, num2) {n1, n2 -> n1 + n2}
}

实际编译的时候内联函数中的代码 n1 + n2 会替换函数 num1Andnum2() 本身,代码如下:

fun main() {
    val num1 = 100
    val num2 = 80
    val result = num1 + num2
}

noinline

如果一个高阶函数接收了两个或者更多的函数类型的参数,这时候给函数加上 inline 关键字,那么 Kotlin 编译器会自动将所有引用的 Lambda 表达式全部进行内联。如果不想让其中的一个 Lambda 表达式被内联,只需要使用 noinline 关键字修饰。

inline fun test(block1: () -> Unit, noinline block2: () -> Unit) {
    
}

内联函数由那么多好处,为什么会不想让 Lambda 表达式被内联?这是因为内联的函数类型参数在编译时会被进行代码替换,如果被替换了它就失去了真正的参数属性。函数类型的参数的本质是一个对象,因此我们可以把它当作参数来传递给人也函数,也可以在函数中当作返回值被返回。如果被内联的话,这些参数就不再是对象了,因为它们会被编译器拿到调用处展开。如果不想被内联,加上 noinline 即可。
所以,noinline 的作用是什么?是用来局部地、指向性地关掉函数的内联优化的。内联优化会导致函数中的函数类型的参数无法被当做对象使用,也就是说,这种优化会对 Kotlin 的功能做出一定程度的收窄。而当你需要这个功能的时候,就要使用 noinline 手动关闭。

crossinline

Kotlin 中有一条规定:普通的 Lambda 表达式不允许直接使用 return,除非这个 Lambda 是内联函数的参数。
既然允许 return ,那么这里究竟是从 Lambda 中返回,继续运行后面的代码?还是直接结束外层函数的运行呢?我们看一个例子:

inline fun testInline(block: () -> Unit) {
    println("before lambda")
    block()
    println("after lambda")
}

fun run() {
    testInline { return }
}

看一下 run() 方法的执行结果:

before lambda

从运行结果来看,是直接结束外层函数的运行。其实不难理解,这个 return 是直接内联到 run() 方法内部的,相当于在 run() 方法中直接调用 return。这样的场景叫做 non-local return (非局部返回)
但是有些时候我并不想直接退出外层函数,而是仅仅退出 Lambda 的运行,就可以这样写:

inline fun testInline(block: () -> Unit) {
    println("before lambda")
    block()
    println("after lambda")
}

fun run() {
    testInline { return@testInline }
}

return@label,这样就会继续执行 Lambda 之后的代码了。这样的场景叫做 局部返回
除了使用 reture@label 写法外,还可以使用 crossinline 的写法来解决非局部返回冲突。

inline fun testInline(block: () -> Unit) {
    println("before lambda")
    val runnable = Runnable {
        block()
    }
    println("after lambda")
}

上述代码会报错:

Can't inline 'block' here: it may contain non-local returns. Add 'crossinline' modifier to parameter declaration 'block'

即表明,内联函数里的函数类型的参数,不允许这种间接调用。在函数类型参数添加 crossinline 关键字即可。当你给一个需要被间接调用的参数加上 crossinline,就对它解除了这个限制,从而就可以对它进行间接调用:

inline fun testInline(crossinline block: () -> Unit) {
    println("before lambda")
    val runnable = Runnable {
        block()
    }
    println("after lambda")
}

这种场景经常出现在 API 的设计中,想使用 inline 定义高阶函数,但又不想 API 使用者进行非局部返回,因为 API 被调用的时候是很有可能出现以上间接调用的情况。
crossinline 可以在保持内联的情况下,禁止 lambda 从外层函数直接返回。但是 Kotlin 增加了一条额外规定:内联函数里被 crossinline 修饰的函数类型的参数,将不再享有 Lambda 表达式可以使用 return 的福利,也就是说,内联函数中使用 crossinline 间接调用 和 Lambda 中使用的 return,你只能选一个。
所以什么时候需要 crossinline?当你需要突破内联函数的不能间接调用参数的限制的时候。但其实和 noinline 一样,你并不需要亲自去判断,只要在看到 Android Studio 给你报错的时候把它加上就行了。

总结

到现在,inline、noinline 和 crossinline 的含义和使用已经讲完了。总结下来就是:

  1. inline 可以让你用内联——也就是函数内容直插到调用处——的方式来优化代码结构,从而减少函数类型的对象的创建;
  2. noinline 是局部关掉这个优化,来摆脱 inline 带来的「不能把函数类型的参数当对象使用」的限制;
  3. crossinline 是局部加强这个优化,让内联函数里的函数类型的参数可以被当做对象使用。