定义高阶函数
Kotlin 的标准函数,如run、apply函数
这几个函数有一个共同的特点:它们都会要求我们传入一个Lambda 表达式作
为参数。像这种接收Lambda 参数的函数就可以称为具有函数式编程风格的API,而如果你想要
定义自己的函数式API,那就得借助高阶函数来实现了,这也是我们本节Kotlin 课堂所要重点学
习的内容。
首先来看一下高阶函数的定义。如果一个函数接收另一个函数作为参数,或者返回值的类型是
另一个函数,那么该函数就称为高阶函数。
这个定义可能有点不太好理解,一个函数怎么能接收另一个函数作为参数呢?这就涉及另外一
个概念了:函数类型。我们知道,编程语言中有整型、布尔型等字段类型,而Kotlin 又增加了一
个函数类型的概念。如果我们将这种函数类型添加到一个函数的参数声明或者返回值声明当
中,那么这就是一个高阶函数了。
接下来我们就学习一下如何定义一个函数类型。不同于定义一个普通的字段类型,函数类型的
语法规则是有点特殊的,基本规则如下:
(String, Int) -> Unit
突然看到这样的语法规则,你一定一头雾水吧?不过不用担心,耐心听完我的解释之后,你就
能够轻松理解了。
既然是定义一个函数类型,那么最关键的就是要声明该函数接收什么参数,以及它的返回值是
什么。因此,->左边的部分就是用来声明该函数接收什么参数的,多个参数之间使用逗号隔
开,如果不接收任何参数,写一对空括号就可以了。而->右边的部分用于声明该函数的返回值
是什么类型,如果没有返回值就使用Unit,它大致相当于Java 中的void。
现在将上述函数类型添加到某个函数的参数声明或者返回值声明上,那么这个函数就是一个高
阶函数了,如下所示:
fun example(func: (String, Int) -> Unit) {
func("hello", 123)
}
可以看到,这里的example()函数接收了一个函数类型的参数,因此example()函数就是一个
高阶函数。而调用一个函数类型的参数,它的语法类似于调用一个普通的函数,只需要在参数
名的后面加上一对括号,并在括号中传入必要的参数即可。
现在我们已经了解了高阶函数的定义方式,但是这种函数具体有什么用途呢?由于高阶函数的
用途实在是太广泛了,这里如果要让我简单概括一下的话,那就是高阶函数允许让函数类型的
参数来决定函数的执行逻辑。即使是同一个高阶函数,只要传入不同的函数类型参数,那么它
的执行逻辑和最终的返回结果就可能是完全不同的。为了详细说明这一点,下面我们来举一个
具体的例子。
这里我准备定义一个叫作num1AndNum2()的高阶函数,并让它接收两个整型和一个函数类型的
参数。我们会在num1AndNum2()函数中对传入的两个整型参数进行某种运算,并返回最终的运
算结果,但是具体进行什么运算是由传入的函数类型参数决定的。
新建一个HigherOr derF unction.kt 文件,然后在这个文件中编写如下代码:
fun num1AndNum2(num1: Int, num2: Int, operation: (Int, Int) -> Int): Int {
val result = operation(num1, num2)
return result
}
这是一个非常简单的高阶函数,可能它并没有多少实际的意义,却是个很好的学习示例。
num1AndNum2()函数的前两个参数没有什么需要解释的,第三个参数是一个接收两个整型参数
并且返回值也是整型的函数类型参数。在num1AndNum2()函数中,我们没有进行任何具体的运
算操作,而是将num1和num2参数传给了第三个函数类型参数,并获取它的返回值,最终将得到
的返回值返回。
现在高阶函数已经定义好了,那么我们该如何调用它呢?由于num1AndNum2()函数接收一个函
数类型的参数,因此我们还得先定义与其函数类型相匹配的函数才行。在
HigherOr derF unction.kt 文件中添加如下代码:
fun plus(num1: Int, num2: Int): Int {
return num1 + num2
}
fun minus(num1: Int, num2: Int): Int {
return num1 - num2
}
这里定义了两个函数,并且这两个函数的参数声明和返回值声明都和num1AndNum2()函数中的
函数类型参数是完全匹配的。其中,plus()函数将两个参数相加并返回,minus()函数将两个
参数相减并返回,分别对应了两种不同的运算操作。
有了上述函数之后,我们就可以调用num1AndNum2()函数了,在main()函数中编写如下代
码:
fun main() {
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")
}
注意这里调用num1AndNum2()函数的方式,第三个参数使用了::plus和::minus这种写法。
这是一种函数引用方式的写法,表示将plus()和minus()函数作为参数传递给
num1AndNum2()函数。而由于num1AndNum2()函数中使用了传入的函数类型参数来决定具体
的运算逻辑,因此这里实际上就是分别使用了plus()和minus()函数来对两个数字进行运算。
现在运行一下程序,结果如图

高阶函数的运行结果
使用这种函数引用的写法虽然能够正常工作,但是如果每次调用任何高阶函数的时候都还得先
定义一个与其函数类型参数相匹配的函数,这是不是有些太复杂了?
没错,因此Kotlin 还支持其他多种方式来调用高阶函数,比如Lambda 表达式、匿名函数、成员
引用等。其中,Lambda 表达式是最常见也是最普遍的高阶函数调用方式,也是我们接下来要
重点学习的内容。
上述代码如果使用Lambda 表达式的写法来实现的话,代码如下所示:
fun main() {
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")
}
你会发现,Lambda 表达式同样可以完整地表达一个函数的参数声明和返回值声明
(Lambda 表达式中的最后一行代码会自动作为返回值),但是写法却更加精简。
现在你就可以将刚才定义的plus()和minus()函数删掉了,重新运行一下代码,你会发现结果
是一模一样的。
下面我们继续对高阶函数进行探究。回顾之前在第3章学习的apply函数,它可以用于给
Lambda 表达式提供一个指定的上下文,当需要连续调用同一个对象的多个方法时,apply函
数可以让代码变得更加精简,比如StringBuilder就是一个典型的例子。接下来我们就使用高
阶函数模仿实现一个类似的功能。
修改HigherOr derF unction.kt 文件,在其中加入如下代码:
fun StringBuilder.build(block: StringBuilder.() -> Unit): StringBuilder {
block()
return this
}
这里我们给StringBuilder类定义了一个build扩展函数,这个扩展函数接收一个函数类型参
数,并且返回值类型也是StringBuilder。
注意,这个函数类型参数的声明方式和我们前面学习的语法有所不同:它在函数类型的前面加
上了一个StringBuilder. 的语法结构。这是什么意思呢?
其实这才是定义高阶函数完整的语法规则,在函数类型的前面加上ClassName. 就表示这个函数类型是定义在哪个类当中的。
那么这里将函数类型定义到StringBuilder类当中有什么好处呢?好处就是当我们调用build
函数时传入的Lambda 表达式将会自动拥有StringBuilder的上下文,同时这也是apply函数
的实现方式。
现在我们就可以使用自己创建的build函数来简化StringBuilder构建字符串的方式了。这里
仍然用吃水果这个功能来举例:
fun main() {
val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape")
val result = StringBuilder().build {
append("Start eating fruits.\n")
for (fruit in list) {
append(fruit).append("\n")
}
append("Ate all fruits.")
}
println(result.toString())
}
可以看到,build函数的用法和apply函数基本上是一模一样的,只不过我们编写的build函
数目前只能作用在StringBuilder类上面,而apply函数是可以作用在所有类上面的。如果想
实现apply函数的这个功能,需要借助于Kotlin 的泛型才行
内联函数的作用
高阶函数确实非常神奇,用途也十分广泛,可是你知道它背后的实现原理是怎样的吗?当然,
这个话题并不要求每个人都必须了解,但是为了接下来可以更好地理解内联函数这个知识点,
我们还是简单分析一下高阶函数的实现原理。
这里仍然使用刚才编写的num1AndNum2()函数来举例,代码如下所示:
fun num1AndNum2(num1: Int, num2: Int, operation: (Int, Int) -> Int): Int {
val result = operation(num1, num2)
return result
}
fun main() {
val num1 = 100
val num2 = 80
val result = num1AndNum2(num1, num2) { n1, n2 ->
n1 + n2
}
}
可以看到,上述代码中调用了num1AndNum2()函数,并通过Lambda 表达式指定对传入的两个
整型参数进行求和。这段代码在Kotlin 中非常好理解,因为这是高阶函数最基本的用法。可是我
们都知道,Kotlin 的代码最终还是要编译成Java 字节码的,但Java 中并没有高阶函数的概念。
那么Kotlin 究竟使用了什么魔法来让Java 支持这种高阶函数的语法呢?这就要归功于Kotlin 强大
的编译器了。Kotlin 的编译器会将这些高阶函数的语法转换成Java 支持的语法结构,上述的
Kotlin 代码大致会被转换成如下Java 代码:
public static int num1AndNum2(int num1, int num2, Function operation) {
int result = (int) operation.invoke(num1, num2);
return result;
}
public static void main() {
int num1 = 100;
int num2 = 80;
int result = num1AndNum2(num1, num2, new Function() {
@Override
public Integer invoke(Integer n1, Integer n2) {
return n1 + n2;
}
});
}
考虑到可读性,我对这段代码进行了些许调整,并不是严格对应了Kotlin 转换成的Java 代码。可
以看到,在这里num1AndNum2()函数的第三个参数变成了一个Function接口,这是一种
Kotlin 内置的接口,里面有一个待实现的invoke()函数。而num1AndNum2()函数其实就是调
用了Function接口的invoke()函数,并把num1和num2参数传了进去。
在调用num1AndNum2()函数的时候,之前的Lambda 表达式在这里变成了Function接口的匿
名类实现,然后在invoke()函数中实现了n1 + n2的逻辑,并将结果返回。
这就是Kotlin 高阶函数背后的实现原理。你会发现,原来我们一直使用的Lambda 表达式在底层
被转换成了匿名类的实现方式。这就表明,我们每调用一次Lambda 表达式,都会创建一个新
的匿名类实例,当然也会造成额外的内存和性能开销。
为了解决这个问题,Kotlin 提供了内联函数的功能,它可以将使用Lambda 表达式带来的运行时
开销完全消除。
内联函数的用法非常简单,只需要在定义高阶函数时加上inline关键字的声明即可,如下所
示:
inline fun num1AndNum2(num1: Int, num2: Int, operation: (Int, Int) -> Int): Int {
val result = operation(num1, num2)
return result
}
那么内联函数的工作原理又是什么呢?其实并不复杂,就是Kotlin 编译器会将内联函数中的代码
在编译的时候自动替换到调用它的地方,这样也就不存在运行时的开销了。
当然,仅仅一句话的描述可能还是让人不太容易理解,下面我们通过图例的方式来详细说明内
联函数的代码替换过程。
首先,Kotlin 编译器会将Lambda 表达式中的代码替换到函数类型参数调用的地方,如图


第二步替换过程

内联函数完全消除Lambda 表达式所带来的运行时开销。
noinline与crossinline
接下来我们要讨论一些更加特殊的情况。比如,一个高阶函数中如果接收了两个或者更多函数
类型的参数,这时我们给函数加上了inline关键字,那么Kotlin 编译器会自动将所有引用的
Lambda 表达式全部进行内联。
但是,如果我们只想内联其中的一个Lambda 表达式该怎么办呢?这时就可以使用noinline关
键字了,如下所示:
inline fun inlineTest(block1: () -> Unit, noinline block2: () -> Unit) {
}
可以看到,这里使用inline关键字声明了inlineTest()函数,原本block1和block2这两
个函数类型参数所引用的Lambda 表达式都会被内联。但是我们在block2参数的前面又加上了
一个noinline关键字,那么现在就只会对block1参数所引用的Lambda 表达式进行内联了。
这就是noinline关键字的作用。
前面我们已经解释了内联函数的好处,那么为什么Kotlin 还要提供一个noinline关键字来排除
内联功能呢?这是因为内联的函数类型参数在编译的时候会被进行代码替换,因此它没有真正
的参数属性。非内联的函数类型参数可以自由地传递给其他任何函数,因为它就是一个真实的
参数,而内联的函数类型参数只允许传递给另外一个内联函数,这也是它最大的局限性。
另外,内联函数和非内联函数还有一个重要的区别,那就是内联函数所引用的Lambda 表达式
中是可以使用return关键字来进行函数返回的,而非内联函数只能进行局部返回。为了说明这
个问题,我们来看下面的例子。
fun printString(str: String, block: (String) -> Unit) {
println("printString begin")
block(str)
println("printString end")
}
fun main() {
println("main start")
val str = ""
printString(str) { s ->
println("lambda start")
if (s.isEmpty()) return@printString
println(s)
println("lambda end")
}
println("main end")
}
这里定义了一个叫作printString()的高阶函数,用于在Lambda 表达式中打印传入的字符串
参数。但是如果字符串参数为空,那么就不进行打印。注意,Lambda 表达式中是不允许直接
使用return关键字的,这里使用了return@printString的写法,表示进行局部返回,并且
不再执行Lambda 表达式的剩余部分代码。
现在我们就刚好传入一个空的字符串参数,运行程序,打印结果如图

局部返回的运行结果
可以看到,除了Lambda 表达式中return@printString语句之后的代码没有打印,其他的日
志是正常打印的,说明return@printString确实只能进行局部返回。
但是如果我们将printString()函数声明成一个内联函数,那么情况就不一样了,如下所示:
inline fun printString(str: String, block: (String) -> Unit) {
println("printString begin")
block(str)
println("printString end")
}
fun main() {
println("main start")
val str = ""
printString(str) { s ->
println("lambda start")
if (s.isEmpty()) return
println(s)
println("lambda end")
}
println("main end")
}
现在printString()函数变成了内联函数,我们就可以在Lambda 表达式中使用return关键
字了。此时的return代表的是返回外层的调用函数,也就是main()函数,如果想不通为什么
的话,可以回顾一下在上一小节中学习的内联函数的代码替换过程。
现在重新运行一下程序,打印结果如图
非局部返回的运行结果
可以看到,不管是main()函数还是printString()函数,确实都在return关键字之后停止
执行了,和我们所预期的结果一致。
将高阶函数声明成内联函数是一种良好的编程习惯,事实上,绝大多数高阶函数是可以直接声
明成内联函数的,但是也有少部分例外的情况。观察下面的代码示例:
inline fun runRunnable(block: () -> Unit) {
val runnable = Runnable {
block()
}
runnable.run()
}
这段代码在没有加上inline关键字声明的时候绝对是可以正常工作的,但是在加上inline关
键字之后就会提示如图

使用内联函数可能出现的错误
这个错误出现的原因解释起来可能会稍微有点复杂。首先,在runRunnable()函数中,我们创
建了一个Runnable对象,并在Runnable 的Lambda 表达式中调用了传入的函数类型参数。而
Lambda 表达式在编译的时候会被转换成匿名类的实现方式,也就是说,上述代码实际上是在
匿名类中调用了传入的函数类型参数。
而内联函数所引用的Lambda 表达式允许使用return关键字进行函数返回,但是由于我们是在
匿名类中调用的函数类型参数,此时是不可能进行外层调用函数返回的,最多只能对匿名类中
的函数调用进行返回,因此这里就提示了上述错误。
也就是说,如果我们在高阶函数中创建了另外的Lambda 或者匿名类的实现,并且在这些实现
中调用函数类型参数,此时再将高阶函数声明成内联函数,就一定会提示错误。
那么是不是在这种情况下就真的无法使用内联函数了呢?也不是,比如借助crossinline关键
字就可以很好地解决这个问题:
inline fun runRunnable(crossinline block: () -> Unit) {
val runnable = Runnable {
block()
}
runnable.run()
}
可以看到,这里在函数类型参数的前面加上了crossinline的声明,代码就可以正常编译通过
了。
那么这个crossinline关键字又是什么呢?前面我们已经分析过,之所以会提示图6.18 所示的
错误,就是因为内联函数的Lambda 表达式中允许使用return关键字,和高阶函数的匿名类实
现中不允许使用return关键字之间造成了冲突。而crossinline关键字就像一个契约,它用
于保证在内联函数的Lambda 表达式中一定不会使用return关键字,这样冲突就不存在了,问
题也就巧妙地解决了。
声明了crossinline之后,我们就无法在调用runRunnable函数时的Lambda 表达式中使用
return关键字进行函数返回了,但是仍然可以使用return@runRunnable的写法进行局部返
回。总体来说,除了在return关键字的使用上有所区别之外,crossinline保留了内联函数
的其他所有特性