Lambda表达式和高阶函数

在Kotlin当中,函数可以像普通变量一样作为参数传递或者作为返回值返回。

Lambda定义

Lambda就是一段可以作为参数传递的代码。Lambda表达式的语法结构

1
{参数名1: 参数类型, 参数名2: 参数类型 -> 函数体}

函数体中最后一行代码会自动作为Lambda表达式的返回值。

Lambda语法简化流程

一个完整的lambda表达式用法

1
2
3
4
5
6
fun main() {
    val fruits = listOf("Apple", "Orange", "Pear", "Grape")
    val lambda = { fruit: String -> fruit.length }
    val maxLengthFruit = fruits.maxBy(lambda)
    println(maxLengthFruit)
}

首先,我们不需要单独定义一个lambda变量,而是直接将lambda表达式传入maxBy函数当中。因此第一步简化为:

1
val maxLengthFruit = fruits.maxBy({ fruit: String -> fruit.length })

Kotlin当中规定,当Lambda作为最后一个参数时,可以将Lambda表达式移到括号外面。

1
val maxLengthFruit = fruits.maxBy(){ fruit: String -> fruit.length }

接下来,如果该Lambda参数是函数的唯一一个参数的话,可以将函数的括号省略掉:

1
val maxLengthFruit = fruits.maxBy { fruit: String -> fruit.length }

由于自动推导机制,Lambda表达式的参数列表大多数时候是不需要声明参数类型的。因此代码可以简化为

1
val maxLengthFruit = fruits.maxBy { fruit -> fruit.length }

最后,当Lambda表达式的参数列表只有一个参数时,也不必声明参数名,而是可以直接使用it关键字来代替,上面的代码可以简化为

1
val maxLengthFruit = fruits.maxBy { it.length }

Lambda中的return

Lambda表达式中,默认把最后一行作为返回值返回,一般不需要使用return关键字。但是它在结合高阶函数一起使用时,需要注意,比如下面的例子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
fun getListSize(): Int {
    val list = ArrayList<String>()
    val listSize = list.run {
        println("add hello")
        add("hello")
        if (size == 1) {
            return 1
        }
        println("add world")
        add("world")
        size
    }
    println("current list size is: $listSize")
    return listSize
}

// 输出
// add hello

上面的例子中,使用return语句时会直接结束run()函数外部的方法,也就是getListSize()方法,所以看到getListSize()方法后面的语句都没有被执行。

如果要只是结束当前Lambda表达式,应该使用下面的写法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fun getListSize(): Int {
    val list = ArrayList<String>()
    val listSize = list.run {
        println("add hello")
        add("hello")
        if (size == 1) {
            return@run 1
        }
        println("add world")
        add("world")
        size
    }
    println("current list size is: $listSize")
    return listSize
}

fun main() {
    getListSize()
}

// 输出
// add hello
// current list size is: 1

高阶函数

在前面的学习中,我们了解了map,filter, with, run, apply等函数,这几个函数有一个共同的特点:

它们都会要求我们传入一个Lambda表达式作为参数。像这种接收Lambda参数的函数可以称为具有函数式编程风格的API, 而如果想自己实现这样的函数式API,就需要借助高阶函数来实现了

什么是高阶函数

高阶函数就是一个接受函数作为参数,或者把函数作为返回值的函数。

如果一个函数接收另一个函数作为参数,或者返回值的类型是另外一个函数,那么该函数就被称为高阶函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
fun num1AndNumber2(num1: Int, num2: Int, operator: (Int, Int) -> Int): Int {
    return operator(num1, num2)
}

fun plus(num1: Int, num2: Int): Int {
    return num1 + num2
}

fun minus(num1: Int, num2: Int): Int {
    return num1 - num2
}

fun main() {
    val num1 = 10
    val num2 = 5
    println(num1AndNumber2(num1, num2, ::plus)) // 注意第三个参数: 函数引用的写法
    println(num1AndNumber2(num1, num2, ::minus))
}

上面的例子中num1AndNumber2就是一个高阶函数,但是如果每次调用高阶函数都要定义一个与函数参数类型相匹配的函数(如上面的plusminus函数),就略显麻烦了。

Kotlin还支持其它多种方式来调用高阶函数,比如Lambda表达式,匿名函数,成员引用等.因此上面的例子可以简化为下面的方式,plusminus函数都不再需要了。

1
2
3
4
5
fun main() {
    val num1 = 10
    val num2 = 5
    println(num1AndNumber2(num1, num2) { n1, n2 -> n1 + n2 })
    println(num1AndNumber2(num1, num2) { n1, n2 -> n1 - n2 })

使用高阶函数实现类似标准函数apply的功能

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
fun StringBuilder.build(block: StringBuilder.() -> Unit): StringBuilder {
    block()
    return this
}

fun main() {
    val fruits = listOf("Apple", "Orange", "Pear", "Grape")
    val result = StringBuilder().build {
        append("Start eating fruits\n")
        for (fruit in fruits) {
            append("eat $fruit\n")
        }
        append("Ate all fruits.")
    }
    println(result)
}

上面的代码中,给StringBuilder类定义了扩展函数build来实现apply函数的功能。

这里要注意函数类型参数的声明方式: 它在函数类型的前面加上了StringBuiler.的语法结构。这才是定义高阶函数的完整语法,在函数类型的前面加上ClassName.就表示函数类型是定义在哪个类当中的,这样的好处就是当我们在调用build函数时,Lambda表达式将自动拥有StringBuilder的上下文,同时这也是apply函数的实现方式(这里只是一个简化版本的apply函数实现方式)

内联函数(inline)

如下面的高阶函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
fun num1AndNumber2(num1: Int, num2: Int, operator: (Int, Int) -> Int): Int {
    return operator(num1, num2)
}

fun main() {
    val num1 = 100
    val num2 = 80
    val result = num1AndNumber2(num1, num2) { n1, n2 ->
        n1 + n2
    }
    println(result)
}

Kotlin的代码最后还是要编译成Java字节码的,但是Java中并没有高阶函数的概念。 那么Kotlin究竟使用了什么魔法让Java支持高阶函数的语法呢?这就要归功于Kotlin的编译器了。Kotlin的编译器会将这些代码转换成Java支持的语法结构。上述的Kotlin代码大致会转成下面的Java代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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()函数,并将num1num2参数传了进去。

在调用num1AndNum2()函数的时候,之前的Lambda表达式在这里变成了Function接口的匿名类实现,然后在invoke()函数中实现了n1 + n2的逻辑,并将结果返回。

这就是高阶函数背后的实现原理。你会发现,原来我们一直使用的Lambda表达式在底层被转换成了匿名类的实现方式。这就表明,我们每调用一次Lambda表达式,都会创建一个新的匿名类实例,当然也就造成额外的内存和性能开销。

为了解决这个问题,Kotlin提供了内联函数的功能,它可以将Lambda表达式带来的运行时开销完全消除。

内联函数的用法非常简单,只需要在定义高阶函数的时候加上inline关键字的声明即可。

1
2
3
inline fun num1AndNumber2(num1: Int, num2: Int, operator: (Int, Int) -> Int): Int {
    return operator(num1, num2)
}

内联函数的工作原理

内联函数的工作原理是什么呢?其实并不复杂,就是Kotlin编译器会将内联函数中的代码在编译的时候自动替换到调用它的地方,那么也就不存在运行时的开销了。具体步骤如下:

原始代码为

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
inline fun num1AndNumber2(num1: Int, num2: Int, operator: (Int, Int) -> Int): Int {
	val reuslt = operator(num1, num2)
	return result
}

fun main() {
    val num1 = 100
    val num2 = 80
    val result = num1AndNumber2(num1, num2) { n1, n2 ->
        n1 + n2
    }
    println(result)
}

第一步, Kotlin编译器会将Lambda表达式中的代码替换到函数类型参数调用的地方,代码变成下面的形式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
inline fun num1AndNumber2(num1: Int, num2: Int): Int {
    val result = num1 + num2
    return result
}

fun main() {
    val num1 = 100
    val num2 = 80
    val result = num1AndNumber2(num1, num2)
    println(result)
}

第二步, 将内联函数中的全部代码替换到函数调用的地方,最后代码变成下面的形式

1
2
3
4
5
6
fun main() {
    val num1 = 100
    val num2 = 80
    val result = num1 + num2
    println(result)
}

也正式如此,内联函数才能完全消除Lambda表达式所带来的运行时开销

noinline

一个高阶函数中如果接收了两个或者很多的函数类型的参数,这时我们给函数加上inline关键字,那么Kotlin编译器就会自动将所有引用的Lambda表达式全部进行内联。

但是,假如我们只想内联其中一个Lambda表达式该怎么办呢?这时就可以使用noinline关键字。

1
2
3
inline fun inlineTest(block1: ()->Unit, noinline block2: ()->Unit){
	TODO("Implementation this")
}

可以看到,这里使用了inline关键字声明了inlineTest()函数,原本block1()block2()这两个函数类型参数所引用的Lambda表达式都会被内联。但是我们在block2参数的前面加上了noinline关键字,那么现在只会对block1参数所引用的Lambda表达式进行内联了,这就是noinline关键字的作用。

前面我们已经解释了内联函数的好处?那么为什么Kotlin还要提供一个noinline关键字来排除内联呢?这是因为内联的函数类型参数在编译的时候会进行代码替换,因此它没有真正的参数属性。

非内联的函数参数类型可以自由地传递给其它任何函数,因为它就是一个真实的参数,而内联的函数类型参数只能传递给另外一个内联函数,这也是它的最大局限性

另外,内联函数和非内联函数还有一个重要的区别,那就是内联函数所引用的Lambada表达式中是可以使用return关键字来进行函数返回的,而非内联的函数只能进行局部返回。为了说明这个例子,让我们看下面的例子。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 finish")
    }
    println("main end")
}

//输出
// main start
// printString begin
// Lambda start
// printString end
// main end

Lambda表达式中是不允许直接使用return关键字的,上面使用了return@printString进行局部返回,并且不再执行Lambda表达式的剩余部分代码。 从输出结果可以看到,除了Lambda表达式中return@printString语句之后的代码没有打印,其它的日志都是正常打印了的,说明return@printString确实只能进行局部返回。

但是我们将printSring()函数声明成一个内联函数,那么情况就不一样了.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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 finish")
    }
    println("main end")
}
// 输出
// main start
// printString begin
// Lambda start

现在的printString()函数变成了内联函数,我们就可以在Lambda的表达式中使用return关键字,此时的return代表的是返回外层的调用函数,也就是main()函数。

crossinline

将高阶函数声明成内联函数是一种良好的编程习惯, 事实上,绝大多数的高阶函数可以是直接声明成内联函数的,但是也有少部分例外的情况。观察下面的代码:

1
2
3
4
5
6
inline fun runRunnable(block: () -> Unit) {
    val runnable = Runnable {
        block()
    }
    runnable.run()
}

这点代码在没有加上inline关键字的时候绝对是可以正常工作的,但是在加上inline关键字之后,上面的代码在block部分会有错误提示

1
Can't inline 'block' here: it may contain non-local returns. Add 'crossinline' modifier to parameter declaration 'block'

这个错误出现的原因解释起来可能会稍微有点复杂。首先,在runRunnable()函数中,我们创建了一个Runnable对象,并在Runnable的Lambda表达式中调用了传入的函数类型参数。而Lambda表达式在编译的时候会被转换成匿名类的实现方式。也就是说,上述代码实际上在匿名类中调用了传入的函数类型参数。

而内联函数所引用的Lambda表达式允许使用return关键字进行函数返回,但是由于我们是在匿名类中调用的函数类型参数,此时是不可能进行外层调用函数返回的,最多只能对匿名类中的函数调用进行返回,因此这里就出现了上述错误。

也就是说,如果我们在高阶函数中创建了Lambda或者匿名的实现,并且在这些实现中调用了函数类型参数,此时再将高阶函数声明为内联函数,就一定会提示错误。

借助crossinline关键字就可以很好地解决这个问题:

1
2
3
4
5
6
inline fun runRunnable(crossinline block: () -> Unit) {
    val runnable = Runnable {
        block()
    }
    runnable.run()
}

这里可以看到,在函数类型参数block前面加上了crossinline的声明,代码就可以正常编译通过了。

那么这个crossinline关键字又是什么呢?

前面我们已经分析过,上面的代码之所以在没有添加crossinline时会报错,就是因为内联函数的Lambda表达式中允许使用return关键字,和高阶函数的匿名类实现中不允许使用return关键字之间造成了冲突。而crossinline关键字就像一个契约,它用于保证在内联函数的Lambda表达式中一定不会使用return关键字,这样冲突就不存在了,问题也就巧妙地解决了。

声明了crossinline之后,我们就无法在调用runRunnable()函数时的Lambda表达式中使用return关键字进行函数返回了,但是仍然可以使用return@runRunnable的写法进行局部返回。总体来说, 除了return关键字的使用上有所区别之外,crossinline保留了内联函数的其它所有特性。

高阶函数的应用

高阶函数非常适合简化各种API的调用,一些API的原有用法在使用高阶函数简化之后,不管是在易用性还是可读性方面,都可能会有很大的提升。

简化SharedPreferences的用法

原来的用法

1
2
3
4
val editor = getSharedPreferences("data", Context.MODE_PRIVATE).edit()
editor.putString("name", "Tom")
editor.putString("age", 10)
editor.apply()

上面的写法更多还是使用的Java的编程方式来编写代码的,利用高阶函数,我们可以简化SharedPreferences的用法

1
2
3
4
5
fun SharedPreferences.open(block: SharedPreferences.Editor.() -> Unit) {
	val editor = edit() //由于open函数内拥有SharedPreferences的上下文,因此这里可以直接使用edit()方法来获取SharedPreferences.Editor对象。
	editor.block() // 由于open函数接受的是一个SharedPreferences.Editor的函数类型参数,因此这里要调用editor.block()对函数类型参数进行调用。
	editor.apply()
}

定义好上面的函数之后,以后我们只需要在项目中使用

1
2
3
4
getSharedPreferences("data", Context.MODE_PRIVATE).open {
	putString("name", "Tom")
	putString("age", 10)
}

相比之下,代码简化了很多。

其实Google提供的KTX扩展库中已经包含了上述SharedPreferences的简化用法。实际上,我们可以在项目中直接使用

1
2
3
4
getSharedPreferences("data", Context.MODE_PRIVATE).edit {
	putString("name", "Tom")
	putString("age", 10)
}