一、定义高阶函数

高阶函数和Lambda的关系密不可分。在Lambda编程的基础知识,使用的一些与集合相关的函数式API用法,如map、filter函数等。又比如Kotlin的标准函数,如run、apply函数等,这几个函数都有一个共同的特点:他们都会传入一个Lambda表达式作为参数。像这种接收Lambda参数的函数就可以称为具有函数式编程风格的API,而如果你想定义自己的函数式API,那就需要借助高阶函数来实现了。 高阶函数的定义:如果一个函数接收另一个函数作为参数,或者返回值的类型是另一个函数,那么该函数就称为高阶函数。 可能会问,一个函数怎么能接收另一个函数作为参数呢?这就涉及另外一个概念了:函数类型。我们知道,编程语言中有整型、布尔型等字段类型,而Kotlin又增加了一个函数类型的概念。如果我们将这种函数类型添加到一个函数的参数声明或者返回值声明当中,那么这就是一个高阶函数了。 如何定义一个函数类型?不同于定义一个普通的字段类型,函数类型的语法有点特殊,基本规则如下:

(String,Int)->Unit

既然是定义一个函数类型,那么最关键的就是要声明该函数接受什么参数,已经它的返回值是什么。因此,->左边的部分就是用来声明该函数接收什么,多个参数之间使用逗号隔开,如果不接收任何参数,写一对空括号就可以了。而->右边部分用于声明该函数返回值是什么类型,如果没有返回值就使用Unit,他大致相当于Java中的void。 现在将上述函数类型添加到某个函数的参数声明或者返回值声明上,那么这个函数就是一个高阶函数了,如下所示:

fun example(func:(String,Int)->Unit){

func("hello",123)

}

可以看到,这里的example()函数接收了一个函数类型的参数,因此example()函数就是一个高阶函数。而调用一个函数类型的参数,它的语法类似于调用一个普通的函数,只需要在参数名的后面加上一对括号,并在括号中传入必要的参数即可。 高阶函数允许让函数类型的参数来决定函数的执行逻辑。即使是同一个函数高阶函数,只要传入不同的函数类型参数,那么它的执行逻辑和最终返回结果就可能是完全不同的。 举个例子: 新建一个Function.kt文件,在这个文件中编写代码。 定义一个叫作num1AndNum2()的高阶函数,并让它接收两个整型和一个函数类型的参数。我们会在num1AndNum2()函数中对传入的两个整型参数进行某种运算,并返回最终的运算结果,但是具体进行什么运算是由传入的函数类型参数决定的。

fun num1AndNum2(num1:Int,num2:Int,operator:(Int,Int)->Int):Int{

val result=operator(num1,num2)

return result

}

num1AndNum2()函数的前两个参数没有什么需要解释的,第三个参数是一个接收两个整型参数并且返回值也是整型的函数类型参数。在num1AndNum2()函数中,我们并没有进行任何具体的运算操作,只是将num1和num2参数传给了第三个函数类型参数,并获取它的返回值,最终将得到的返回值返回。(函数名用什么都可以,这里主要设计功能是运算,所以我命名为operator) 现在高阶函数已经定义好了,那么我们该如何调用它呢?由于num1AndNum2()函数接收一个函数类型的参数,因此我们还得先定义与其函数类型相匹配的函数才行。添加如下代码:

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()函数删掉了,重新运行代码,结果也是一样的。 下面我们继续对高阶函数进行探究,我们知道apply函数,他可以用于给Lambda表达式提供一个指定的上下文,当需要连续调用同一个对象的多个方法时,apply函数可以让代码变得更加精简,比如StringBuilder就是一个典型的例子。接下来我们就使用高阶函数模仿实现一个类似的功能。 修改Function.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函数基本一样,而apply函数可以作用在所有类上面,而build函数想实现需要借助于Kotlin泛型。

二、内联函数的作用

高阶函数用途也十分广泛,可是它的实现原理是怎样的呢?我们来简单分析一下高阶函数的实现原理。 这里仍然使用刚才编写的numAndNum2()函数来举例,代码如下:

fun num1AndNum2(num1:Int,num2:Int,operator:(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支持的语法结构,上述代码大致会被转换成如下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的逻辑,并将结果返回。 这就是高阶函数背后的实现原理。会发现原来我们一直使用的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("println 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")

}

这里定义了一个叫作printlnString()的高阶函数,用于在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")

}

现在printlnString()函数变成了内联函数,那么我们就可以在Lambda表达式中使用return关键字了。此时的return代表的是返回外层的调用函数,也就是main()函数(因为内联函数在编译的时候会进行代码替换的过程,替换到函数调用的地方也就是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进行局部返回。

查看原文