Kotlin 知识梳理(11) - 内联函数

Kotlin 知识梳理系列文章

Kotlin 知识梳理(1) - Kotlin 基础
Kotlin 知识梳理(2) - 函数的定义与调用
Kotlin 知识梳理(3) - 类、对象和接口
Kotlin 知识梳理(4) - 数据类、类委托 及 object 关键字
Kotlin 知识梳理(5) - lambda 表达式和成员引用
Kotlin 知识梳理(6) - Kotlin 的可空性
Kotlin 知识梳理(7) - Kotlin 的类型系统
Kotlin 知识梳理(8) - 运算符重载及其他约定
Kotlin 知识梳理(9) - 委托属性
Kotlin 知识梳理(10) - 高阶函数:Lambda 作为形参或返回值
Kotlin 知识梳理(11) - 内联函数
Kotlin 知识梳理(12) - 泛型类型参数


一、本文概要

本文是对<<Kotlin in Action>>的学习笔记,如果需要运行相应的代码可以访问在线环境 try.kotlinlang.org,这部分的思维导图为:

二、内联函数

当我们使用lambda表达式时,它会被正常地编译成匿名类。这表示每调用一次lambda表达式,一个额外的类就会被创建,并且如果lambda捕捉了某个变量,那么每次调用的时候都会创建一个新的对象,这会带来运行时的额外开销,导致使用lambda比使用一个直接执行相同代码的函数效率更低。

如果使用inline修饰符标记一个函数,在函数被调用的时候编译器并不会生成函数调用的代码,而是 使用函数实现的真实代码替换每一次的函数调用

2.1 内联函数如何运作

当一个函数被声明为inline时,它的函数体是内联的,也就是说,函数体会被直接替换到函数被调用地方,下面我们来看一个简单的例子,下面是我们定义的一个内联的函数:

inline fun inlineFunc(prefix : String, action : () -> Unit) {
    println("call before $prefix")
    action()
    println("call after $prefix")
}

我们用如下的方法来使用这个内联函数:

fun main(args: Array<String>) {
    inlineFunc("inlineFunc") {
        println("HaHa")
    }
}

运行结果为:

>> call before inlineFunc
>> HaHa
>> call after inlineFunc

最终它会被编译成下面的字节码:

fun main(args: Array<String>) {
    println("call before inlineFunc")
    println("HaHa")
    println("call after inlineFunc")
}

lambda表达式和inlineFunc的实现部分都被内联了,由lambda生成的字节码成了函数调用者定义的一部分,而不是被包含在一个实现了函数接口的匿名类中。

传递函数类型的变量作为参数

在调用内联函数的时候,也可以传递函数类型的变量作为参数,还是上面的例子,我们换一种调用方式:

fun main(args: Array<String>) {
    val call : () -> Unit = { println("HaHa") }
    inlineFunc("inlineFunc", call)
}

那么此时最终被编译成的Java字节码为:

fun main(args: Array<String>) {
    println("call before inlineFunc ")
    action()
    println("call after inlineFunc")
}

在这种情况,只有inlineFunc的实现部分被内联了,而lambda的代码在内联函数被调用点是不可用的。

在两个不同的位置使用同一个内联函数

如果在两个不同的位置使用同一个内联函数,但是用的是不同的lambda,那么内联函数会在每一个被调用的位置分别内联,内联函数的代码会被拷贝到使用它的两个不同位置,并把不同的lambda替换到其中。

2.2 内联函数的限制

鉴于内联的运作方式,不是所有使用 lambda 的函数都可以被内联。当函数被内联的时候,作为参数的lambda表达式的函数体会被 替换到最终生成的代码中

这将限制函数体中的lambda参数的使用:

  • 如果lambda参数 被调用,这样的代码能被容易地内联。
  • 如果lambda参数 在某个地方被保存起来,以便以后继续使用,lambda表达式的代码 将不能被内联,因此必须要 有一个包含这些代码的对象存在

一般来说,参数如果 被直接调用或者作为参数传递 给另外一个inline函数,它是可以被内联的,否则,编译器会 禁止参数被内联 并给出错误信息Illeagal usage of inline-parameter

例如,许多作用于序列的函数会返回一些类的实例,这些类代表对应的序列操作并接收lambda作为构造方法的参数,以下是Sequence.map函数的定义:

fun <T, R> Sequence<T>.map(transform : (T) -> R) : Sequence<R> {
    return TransformingSequence(this, transform);
}

map函数没有直接调用作为transform参数传递进来的函数。而是将这个函数传递给一个类的构造方法,构造方法将它保存在一个属性当中。为了支持这一点,作为transform参数传递的lambda需要 被编译成标准的非内联表示法,即一个实现了函数接口的匿名类。

如果一个函数期望两个或更多的lambda函数,可以选择只内联其中一些参数,因为一个lambda可能会包含很多代码或者 以不允许内联的方式调用,接收这样的非内联lambda的参数,可以用noinline修饰符来标记它:

inline fun foo(inlined : () -> Unit, noinline noinlined : () -> Unit) {

}

注意,编译器完全支持 内联跨模块的函数或者第三方库定义的函数,也可以在 Java 中调用绝大部分内联函数

2.3 内联集合操作

大部分标准库中的集合函数都带有lambda参数。例如filter,它被声明为内联函数,这意味着filter函数,以及传递给它的lambda字节码会被内联到filter被调用的地方,因此我们不用担心性能问题。

假如我们像下面这样,连续调用filtermap两个操作:

println(people.filter{ it.age > 30 }.map(Person :: name))

这个例子使用了一个lambda表达式和一个成员引用,filtermap函数都被声明为inline函数,所以不会额外产生类或者对象,但是上面的代码会创建一个中间集合来保存列表过滤的结果。

2.4 决定何时将函数声明成内联

对于普通函数的调用,JVM已经提供了强大的内联支持。它会分析代码的执行,并在任何通过内联能够带来好处的时候将函数调用内联。

带有lambda参数的函数内联能带来好处:

  • 节约了函数调用的开销,节约了为lambda创建匿名类,以及创建lambda实例对象的开销。
  • JVM目前并没有聪明到总是能够将函数调用内联。
  • 内联使得我们可以使用一些不可能被普通lambda使用的特性,例如 非局部返回

但是在使用inline关键字的时候,还是应该注意代码的长度,如果你要内联的函数很大,将它的字节码拷贝到每一个调用点将会极大地增加字节码的长度。在这种情况下,你应该将那些与lambda参数无关的代码抽取到一个独立的非内联函数中。

三、高阶函数中的控制流

当你使用lambda去替换像循环这样的命令式代码结构时,很快就会遇到return表达式的问题,把一个return语句放在循环的中间是很简单的事。但是如果将循环替换成一个类似filter的函数呢?

3.1 lambda 中的返回语句:从一个封闭的函数返回

下面,我们通过一个例子来演示,在集合当中寻找名为Alice的人,找到了就直接返回:

data class Person(val name: String, val age: Int)

val people = listOf(Person("Alice", 29), Person("Bob", 31))

fun lookForAlice(people: List<Person>) {
    people.forEach {
        if (it.name == "Alice") {
            println("Found!")
            return
        }
    }
    println("Alice is not found")
}

fun main(args: Array<String>) {
    lookForAlice(people)
}

运行结果为:

>> Found !

如果在lambda中使用return关键字,它会 从调用 lambda 的函数 中返回,并不只是 从 lambda 中返回,这样的return语句叫做 非局部返回,因为它从一个比包含return的代码块更大的代码块中返回了。

需要注意的是,只有 以 lambda 作为参数的函数是内联函数 的时候才能从更外层的函数返回。在一个非内联的lambda中使用return表达式是不允许的,一个非内联函数可以把它的lambda保存在变量中,以便在函数返回以后可以继续使用,这个时候lambda想要去影响函数的返回已经太晚了。

3.2 从 lambda 中返回:使用标签返回

也可以在lambda表达式中使用局部返回,类似于for循环中的break表达式,它会终止lambda的执行,并接着从调用lambda的代码处执行。

要区分局部返回和非局部返回,要用到标签。想从一个lambda表达式处返回你可以标记它,然后在return关键字后面引用这个标签。

data class Person(val name: String, val age: Int)

val people = listOf(Person("Alice", 29), Person("Bob", 31))

fun lookForAlice(people: List<Person>) {
    people.forEach label@{
        if (it.name == "Alice") return@label
    }
    println("Alice might be somewhere")
}

fun main(args: Array<String>) {
    lookForAlice(people)
}

运行结果为:

>> Alice might be somewhere

另一种选择是,使用lambda作为参数的函数的函数名可以作为标签,也就是上面的forEach,如果你显示地指定了lambda表达式的标签,再使用函数名作为标签没有任何效果。

3.3 匿名函数:默认使用局部返回

匿名函数是一种不同的用于编写传递给函数的代码块的方式,先来看一个示例:

data class Person(val name: String, val age: Int)

val people = listOf(Person("Alice", 29), Person("Bob", 31))

fun lookForAlice(people: List<Person>) {
    people.forEach(fun (person) {
        if (person.name == "Alice") return
        println("${person.name} is not Alice")
    })
}

fun main(args: Array<String>) {
    lookForAlice(people)
}

运行结果为:

>> Bob is not Alice

匿名函数和普通函数有相同的指定返回值类型的规则,代码块匿名函数 需要显示地指定返回类型,如果使用 表达式函数体,就可以省略返回类型。

在匿名函数中,不带return表达式会从匿名函数返回,而不是从包含匿名函数的函数返回,这条规则很简单:return从最近的使用fun关键字声明的函数返回。

  • lambda表达式没有使用fun关键字,所以lambda中的return从最外层的函数返回。
  • 匿名函数使用了fun,因此return表达式从匿名函数返回。

尽管匿名函数看起来和普通函数很相似,但它其实是lambda表达式的另一种语法形式而已。关于lambda表达式如何实现,以及在内联函数中如何被内联的讨论同样适用于匿名函数。


更多文章,欢迎访问我的 Android 知识梳理系列:

推荐阅读更多精彩内容