高阶函数详解

从本章的 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 表达式中的代码替换到函数类型参数调用的地方,如下图所示。

image.png

接下来,再将内联函数中的全部代码替换到函数调用的地方,如下图所示。

image.png

最终的代码就被替换成了如下图所示的样子。

image.png

也正是如此,内联函数才能完全消除 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 表达式的剩余部分代码。

现在我们就刚好传入一个空的字符串参数,运行程序,打印结果如下图所示。

image.png

可以看到,除了 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() 函数,如果想不通为什么的话,可以回顾一下在上一小节中学习的内联函数的代码替换过程。

现在重新运行一下程序,打印结果如下图所示。

image.png

可以看到,不管是 main() 函数还是 printString() 函数,确实都在 return 关键字之后停止执行了,和我们所预期的结果一致。

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

inline fun runRunnable(block: () -> Unit) {
    val runnable = Runnable {
        block()
    }
    runnable.run()
}

这段代码在没有加上 inline 关键字声明的时候绝对是可以正常工作的,但是在加上 inline 关键字之后就会提示如下图所示的错误。

image.png

这个错误出现的原因解释起来可能会稍微有点复杂。首先,在 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 以及高阶函数相关的很多知识是建立在本节课堂的基础之上的。


Maenj_Ba_lah
28 声望7 粉丝

真正的大师,永远怀着一颗学徒的心。