1、定义
如果一个函数接收另一个函数作为参数,或者返回值的类型是另一个函数,那么该函数就称为高阶函数。
不同于定义一个普通的字段类型,函数类型的语法规则是有点特殊的,基本规则如下:
(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{
val result = operation(num1, num2)
return result
}
这是一个非常简单的高阶函数,可能它并没有多少实际的意义,却是个很好的学习示例。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 = 40
val result1 = num1AndNum2(num1, num2, ::plus)
val result2 = num1AndNum2(num1, num2, ::minus)
println(result1)
println(result2)
}
注意这里调用num1AndNum2()函数的方式,第三个参数使用了::plus和::minus这种写法。这是一种函数引用方式的写法,表示将plus()和minus()函数作为参数传递给num1AndNum2()函数。而由于num1AndNum2()函数中使用了传入的函数类型参数来决定具体的运算逻辑,因此这里实际上就是分别使用了plus()和minus()函数来对两个数字进行运算。
使用这种函数引用的写法虽然能够正常工作,但是如果每次调用任何高阶函数的时候都还得先定义一个与其函数类型参数相匹配的函数,这是不是有些太复杂了?
没错,因此Kotlin还支持其他多种方式来调用高阶函数,比如Lambda表达式、匿名函数、成员引用等。其中,Lambda表达式是最常见也是最普遍的高阶函数调用方式,也是我们接下来要重点学习的内容。
上述代码如果使用Lambda表达式的写法来实现的话,代码如下所示:
fun main(){
val num1 = 100
val num2 = 40
val result1 = num1AndNum2(num1, num2){ n1, n2 ->
n1 + n2
}
val result2 = num1AndNum2(num1, num2){ n1, n2 ->
n1 - n2
}
println(result1)
println(result2)
}
Lambda表达式同样可以完整地表达一个函数的参数声明和返回值声明(Lambda表达式中的最后一行代码会自动作为返回值),但是写法却更加精简。
回顾之前学习的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")
val re = StringBuilder().build {
append("Start eating\n")
for (fruit in list){
append(fruit).append("\n")
}
append("Ate all fruits")
}
println(re.toString())
}
可以看到,build函数的用法和apply函数基本上是一模一样的,只不过我们编写的build函数目前只能作用在StringBuilder类上面,而apply函数是可以作用在所有类上面的。如果想实现apply函数的这个功能,需要借助于Kotlin的泛型才行,我们将在第8章学习泛型的相关内容。
2、内联函数的作用
为了接下来可以更好地理解内联函数这个知识点,我们还是简单分析一下高阶函数的实现原理。
这里仍然使用刚才编写的num1AndNum2()函数来举例,代码如下所示:
可以看到,上述代码中调用了num1AndNum2()函数,并通过Lambda表达式指定对传入的两个整型参数进行求和。这段代码在Kotlin中非常好理解,因为这是高阶函数最基本的用法。可是我们都知道,Kotlin的代码最终还是要编译成Java字节码的,但Java中并没有高阶函数的概念。
那么Kotlin究竟使用了什么魔法来让Java支持这种高阶函数的语法呢?这就要归功于Kotlin强大的编译器了。Kotlin的编译器会将这些高阶函数的语法转换成Java支持的语法结构,上述的Kotlin代码大致会被转换成如下Java代码:
可以看到,在这里num1AndNum2()函数的第三个参数变成了一个Function接口,这是一种Kotlin内置的接口,里面有一个待实现的invoke()函数。而num1AndNum2()函数其实就是调用了Function接口的invoke()函数,并把num1和num2参数传了进去。在调用num1AndNum2()函数的时候,之前的Lambda表达式在这里变成了Function接口的匿名类实现,然后在invoke()函数中实现了n1 + n2的逻辑,并将结果返回。
在调用num1AndNum2()函数的时候,之前的Lambda表达式在这里变成了Function接口的匿名类实现,然后在invoke()函数中实现了n1 + n2的逻辑,并将结果返回。
这就是Kotlin高阶函数背后的实现原理。你会发现,原来我们一直使用的Lambda表达式在底层被转换成了匿名类的实现方式。这就表明,我们每调用一次Lambda表达式,都会创建一个新的匿名类实例,当然也会造成额外的内存和性能开销。
为了解决这个问题,Kotlin提供了内联函数的功能,它可以将使用Lambda表达式带来的运行时开销完全消除。
内联函数的用法非常简单,只需要在定义高阶函数时加上inline关键字的声明即可,如下所示:
首先,Kotlin编译器会将Lambda表达式中的代码替换到函数类型参数调用的地方,如图6.13所示。
接下来,再将内联函数中的全部代码替换到函数调用的地方,如图6.14所示。
最终的代码就被替换成了如图6.15所示的样子。
也正是如此,内联函数才能完全消除Lambda表达式所带来的运行时开销。
noinline与crossinline
如果我们只想内联其中的一个Lambda表达式该怎么办呢?这时就可以使用noinline关键字了,如下所示:
在block2参数的前面又加上了一个noinline关键字,那么现在就只会对block1参数所引用的Lambda表达式进行内联了。这就是noinline关键字的作用。
那么为什么Kotlin还要提供一个noinline关键字来排除内联功能呢?这是因为内联的函数类型参数在编译的时候会被进行代码替换,因此它没有真正的参数属性。非内联的函数类型参数可以自由地传递给其他任何函数,因为它就是一个真实的参数,而内联的函数类型参数只允许传递给另外一个内联函数,这也是它最大的局限性。
内联函数和非内联函数还有一个重要的区别,那就是内联函数所引用的Lambda表达式中是可以使用return关键字来进行函数返回的,而非内联函数只能进行局部返回。
这里定义了一个叫作printString()的高阶函数,用于在Lambda表达式中打印传入的字符串参数。但是如果字符串参数为空,那么就不进行打印。注意,Lambda表达式中是不允许直接使用return关键字的,这里使用了return@printString的写法,表示进行局部返回,并且不再执行Lambda表达式的剩余部分代码。
现在我们就刚好传入一个空的字符串参数,运行程序,打印结果如图6.16所示。
但是如果我们将printString()函数声明成一个内联函数,那么情况就不一样了,如下所示:
现在printString()函数变成了内联函数,我们就可以在Lambda表达式中使用return关键字了。此时的return代表的是返回外层的调用函数,也就是main()函数,如果想不通为什么的话,可以回顾一下在上一小节中学习的内联函数的代码替换过程
可以看到,不管是main()函数还是printString()函数,确实都在return关键字之后停止执行了,和我们所预期的结果一致。
将高阶函数声明成内联函数是一种良好的编程习惯,事实上,绝大多数高阶函数是可以直接声明成内联函数的,但是也有少部分例外的情况。观察下面的代码示例:
这段代码在没有加上inline关键字声明的时候绝对是可以正常工作的,但是在加上inline关键字之后就会提示如图6.18所示的错误。
这个错误出现的原因解释起来可能会稍微有点复杂。首先,在runRunnable()函数中,我们创建了一个Runnable对象,并在Runnable的Lambda表达式中调用了传入的函数类型参数。而Lambda表达式在编译的时候会被转换成匿名类的实现方式,也就是说,上述代码实际上是在匿名类中调用了传入的函数类型参数。
而内联函数所引用的Lambda表达式允许使用return关键字进行函数返回,但是由于我们是在匿名类中调用的函数类型参数,此时是不可能进行外层调用函数返回的,最多只能对匿名类中的函数调用进行返回,因此这里就提示了上述错误。
也就是说,如果我们在高阶函数中创建了另外的Lambda或者匿名类的实现,并且在这些实现中调用函数类型参数,此时再将高阶函数声明成内联函数,就一定会提示错误。那么是不是在这种情况下就真的无法使用内联函数了呢?也不是,比如借助crossinline关键字就可以很好地解决这个问题:
可以看到,这里在函数类型参数的前面加上了crossinline的声明,代码就可以正常编译通过了。
那么这个crossinline关键字又是什么呢?前面我们已经分析过,之所以会提示图6.18所示的错误,就是因为内联函数的Lambda表达式中允许使用return关键字,和高阶函数的匿名类实现中不允许使用return关键字之间造成了冲突。而crossinline关键字就像一个契约,它用于保证在内联函数的Lambda表达式中一定不会使用return关键字,这样冲突就不存在了,问题也就巧妙地解决了。
声明了crossinline之后,我们就无法在调用runRunnable函数时的Lambda表达式中使用return关键字进行函数返回了,但是仍然可以使用return@runRunnable的写法进行局部返回。总体来说,除了在return关键字的使用上有所区别之外,crossinline保留了内联函数的其他所有特性。
|