共计 8693 个字符,预计需要花费 22 分钟才能阅读完成。
高阶函数详解
从本章的 Kotlin 课堂起,咱们就将辞别基础知识,开始转向 Kotlin 的高级用法,从而进一步晋升你的 Kotlin 程度。
那么就从高阶函数开始吧。
定义高阶函数
高阶函数和 Lambda 的关系是密不可分的。在第 2 章疾速入门 Kotlin 编程的时候,咱们曾经学习了 Lambda 编程的基础知识,并且把握了一些与汇合相干的函数式 API 的用法,如 map、filter 函数等。另外,在第 3 章的 Kotlin 课堂中,咱们又学习了 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() 函数中对传入的两个整型参数进行某种运算,并返回最终的运算后果,然而具体进行什么运算是由传入的函数类型参数决定的。
新建一个 HigherOrderFunction.kt 文件,而后在这个文件中编写如下代码:
fun num1AndNum2(num1: Int, num2: Int, operation: (Int, Int) -> Int): Int {return operation(num1, num2)
}
这是一个非常简单的高阶函数,可能它并没有多少理论的意义,却是个很好的学习示例。num1AndNum2() 函数的前两个参数没有什么须要解释的,第三个参数是一个接管两个整型参数并且返回值也是整型的函数类型参数。在 num1AndNum2() 函数中,咱们没有进行任何具体的运算操作,而是将 num1 和 num2 参数传给了第三个函数类型参数,并获取它的返回值,最终将失去的返回值返回。
当初高阶函数曾经定义好了,那么咱们该如何调用它呢?因为 num1AndNum2() 函数接管一个函数类型的参数,因而咱们还得先定义与其函数类型相匹配的函数才行。在 HigherOrderFunction.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 表达式同样能够残缺地表白一个函数的参数申明和返回值申明(Lambda 表达式中的最初一行代码会主动作为返回值),然而写法却更加精简。
当初你就能够将方才定义的 plus() 和 minus() 函数删掉了,从新运行一下代码,你会发现后果是截然不同的。
上面咱们持续对高阶函数进行探索。回顾之前在学习的 apply 函数,它能够用于给 Lambda 表达式提供一个指定的上下文,当须要间断调用同一个对象的多个办法时,apply 函数能够让代码变得更加精简,比方 StringBuilder 就是一个典型的例子。接下来咱们就应用高阶函数模拟实现一个相似的性能。
批改 HigherOrderFunction.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 的泛型才行,咱们将在第 8 章学习泛型的相干内容。
当初,你曾经齐全把握了高阶函数的基本功能,接下来咱们要学习一些更加高级的常识。
内联函数的作用
高阶函数的确十分神奇,用处也非常宽泛,可是你晓得它背地的实现原理是怎么的吗?当然,这个话题并不要求每个人都必须理解,然而为了接下来能够更好地了解内联函数这个知识点,咱们还是简略剖析一下高阶函数的实现原理。
这里依然应用方才编写的 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 关键字又是什么呢?后面咱们曾经剖析过,之所以会提醒上图所示的谬误,就是因为内联函数的 Lambda 表达式中容许应用 return 关键字,和高阶函数的匿名类实现中不容许应用 return 关键字之间造成了抵触。而 crossinline 关键字就像一个契约,它用于保障在内联函数的 Lambda 表达式中肯定不会应用 return 关键字,这样抵触就不存在了,问题也就奇妙地解决了。
申明了 crossinline 之后,咱们就无奈在调用 runRunnable 函数时的 Lambda 表达式中应用 return 关键字进行函数返回了,然而依然能够应用 return@runRunnable 的写法进行部分返回。总体来说,除了在 return 关键字的应用上有所区别之外,crossinline 保留了内联函数的其余所有个性。
好了,以上就是对于高阶函数的简直所有的重要内容,心愿你能将这些内容好好把握,因为前面与 Lambda 以及高阶函数相干的很多常识是建设在本节课堂的根底之上的。