5.7.2 高阶函数与 lambda

原文链接: https://kotlinlang.org/docs/lambdas.html

5.7.2 高阶函数与 lambda

Kotlin 函数是一等公民,也就是说它们可以存储在变量和数据结构中,也可以作为实参传给其他高阶函数,或由它们返回。凡是能对其他非函数值进行的操作,都能对函数进行。

为了实现这一点,作为静态类型编程语言的 Kotlin 使用一组函数类型来表示函数,并提供了一整套专门的语言结构,例如 lambda 表达式。

高阶函数

高阶函数是指接收函数作为参数,或返回函数的函数。

集合上的函数式编程惯用法 fold 就是高阶函数的一个好例子。它接收一个初始累加值和一个组合函数,通过将当前累加值与每个集合元素依次组合来构造返回值,并在每次组合后替换累加值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fun <T, R> Collection<T>.fold(
    initial: R,
    combine: (acc: R, nextElement: T) -> R
): R {
    var accumulator: R = initial
    for (element: T in this) {
        accumulator = combine(accumulator, element)
    }
    return accumulator
}

在上面的代码中,combine 参数的类型是函数类型 (R, T) -> R,因此它接收一个带有 R 和 T 两个参数、返回 R 类型值的函数。它在 for 循环中被调用,返回值随后赋给 accumulator。

要调用 fold,需要向它传入一个函数类型的实例作为实参;在高阶函数的调用处,lambda 表达式(详见下文)被广泛用于这一目的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
fun main() {
    val items = listOf(1, 2, 3, 4, 5)

    // lambda 是用花括号括起来的代码块。
    items.fold(0, {
        // lambda 有参数时,参数写在最前面,后面跟 '->'
        acc: Int, i: Int ->
        print("acc = $acc, i = $i, ")
        val result = acc + i
        println("result = $result")
        // lambda 中最后一个表达式被视为返回值:
        result
    })

    // 如果能被推断出来,lambda 中的参数类型是可选的:
    val joinedToString = items.fold("Elements:", { acc, i -> acc + " " + i })

    // 函数引用也可以用于高阶函数调用:
    val product = items.fold(1, Int::times)
    println("joinedToString = $joinedToString")
    println("product = $product")
}

函数类型

Kotlin 使用函数类型(例如 (Int) -> String)来声明与函数相关的内容:val onClick: () -> Unit = ...。

这些类型有一种与函数签名(即参数和返回值)相对应的特殊记法:

  • 所有函数类型都有带圆括号的参数类型列表和返回类型:(A, B) -> C 表示接收 A 和 B 两个参数、返回 C 类型值的函数类型。参数类型列表可以为空,例如 () -> A。Unit 返回类型不能省略。

  • 函数类型还可以额外带有一个接收者类型,写在记法中的点号之前:类型 A.(B) -> C 表示可以在接收者对象 A 上调用、接收参数 B 并返回 C 类型值的函数。带接收者的函数字面量常常与这类类型一起使用。

  • 挂起函数属于一类特殊的函数类型,其记法中带有 suspend 修饰符,例如 suspend () -> Unit 或 suspend A.(B) -> C。

函数类型记法中还可以选择性地包含函数参数名:(x: Int, y: Int) -> Point。这些名称可用于说明参数的含义。

要表示函数类型是可空的,请按如下方式使用圆括号:((Int, Int) -> Int)?。

函数类型也可以用圆括号组合起来:(Int) -> ((Int) -> Unit)。

注意: 箭头记法是右结合的,(Int) -> (Int) -> Unit 等价于上一个例子,但不等价于 ((Int) -> (Int)) -> Unit。

你也可以使用类型别名为函数类型指定替代名称:

1
typealias ClickHandler = (Button, ClickEvent) -> Unit

实例化函数类型

有几种方式可以获得函数类型的实例:

  • 在函数字面量中使用代码块,形式如下之一:
  • lambda 表达式:{ a, b -> a + b },
  • 匿名函数:fun(s: String): Int { return s.toIntOrNull() ?: 0 }

带接收者的函数字面量可以用作带接收者的函数类型的值。

  • 使用指向已有声明的可调用引用:
  • 顶层、局部、成员或扩展函数:::isOdd、String::toInt,
  • 顶层、成员或扩展属性:List<Int>::size,
  • 构造函数:::Regex

其中还包括指向某个特定实例成员的绑定可调用引用:foo::toString。

  • 使用把函数类型作为接口实现的自定义类的实例:
1
2
3
4
5
class IntTransformer: (Int) -> Int {
    override operator fun invoke(x: Int): Int = TODO()
}

val intFunction: (Int) -> Int = IntTransformer()

如果有足够的信息,编译器可以为变量推断函数类型:

1
val a = { i: Int -> i + 1 } // 推断出的类型是 (Int) -> Int

带接收者和不带接收者的函数类型,其非字面量值可以互换使用,因此接收者可以充当第一个参数,反之亦然。例如,类型为 (A, B) -> C 的值可以在期望 A.(B) -> C 类型值的地方传入或赋值,反过来也一样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fun main() {
    val repeatFun: String.(Int) -> String = { times -> this.repeat(times) }
    val twoParameters: (String, Int) -> String = repeatFun // OK

    fun runTransformation(f: (String, Int) -> String): String {
        return f("hello", 3)
    }
    val result = runTransformation(repeatFun) // OK
    println("result = $result")
}

注意: 默认情况下会推断为不带接收者的函数类型,即使变量是用某个扩展函数的引用初始化的。要改变这一点,请显式指定变量类型。

调用函数类型的实例

函数类型的值可以使用它的 invoke(...) 运算符来调用:f.invoke(x),或者直接写成 f(x)。

如果该值带接收者类型,则应把接收者对象作为第一个参数传入。调用带接收者的函数类型值的另一种方式,是把接收者对象写在它前面,就像该值是一个扩展函数一样:1.foo(2)。

示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fun main() {
    val stringPlus: (String, String) -> String = String::plus
    val intPlus: Int.(Int) -> Int = Int::plus

    println(stringPlus.invoke("<-", "->"))
    println(stringPlus("Hello, ", "world!"))

    println(intPlus.invoke(1, 1))
    println(intPlus(1, 2))
    println(2.intPlus(3)) // 类似扩展函数的调用
}

内联函数

对于高阶函数,有时使用能提供灵活控制流的内联函数会更有好处。

Lambda 表达式与匿名函数

Lambda 表达式和匿名函数都是函数字面量。函数字面量是没有被声明、而是作为表达式立即传递的函数。请看下面的例子:

1
max(strings, { a, b -> a.length < b.length })

函数 max 是高阶函数,因为它把函数值作为第二个参数。第二个参数本身就是一个函数表达式,称为函数字面量,它等价于下面这个具名函数:

1
fun compare(a: String, b: String): Boolean = a.length < b.length

你也可以使用 suspend 关键字创建挂起 lambda 表达式。挂起 lambda 的函数类型是 suspend () -> Unit,并且可以调用其他挂起函数:

1
val suspendingTask = suspend { doSuspendingWork() }

Lambda 表达式语法

Lambda 表达式的完整语法形式如下:

1
val sum: (Int, Int) -> Int = { x: Int, y: Int -> x + y }
  • lambda 表达式始终用花括号括起来。
  • 完整语法形式中的参数声明写在花括号内,类型标注是可选的。
  • 函数体写在 -> 之后。
  • 如果推断出的 lambda 返回类型不是 Unit,则 lambda 体中的最后一个(也可能是唯一的)表达式会被当作返回值。

如果把所有可选的标注都去掉,剩下的形式是这样的:

1
val sum = { x: Int, y: Int -> x + y }

传递尾随 lambda

按照 Kotlin 约定,如果函数的最后一个参数是函数,那么作为对应实参传入的 lambda 表达式可以放在圆括号之外:

1
val product = items.fold(1) { acc, e -> acc * e }

这种语法也称为尾随 lambda。

如果 lambda 是该调用中唯一的实参,圆括号可以完全省略:

1
run { println("...") }

it:单个参数的隐式名称

lambda 表达式只有一个参数的情况非常常见。

如果编译器能够在不带任何参数的情况下解析签名,就不需要声明该参数,-> 也可以省略。此时参数会被隐式声明为 it:

1
ints.filter { it > 0 } // 该字面量的类型是 '(it: Int) -> Boolean'

从 lambda 表达式返回值

你可以使用带限定符的 return语法从 lambda 中显式返回值。否则,最后一个表达式的值会被隐式返回。

因此,下面两段代码是等价的:

1
2
3
4
5
6
7
8
9
ints.filter {
    val shouldFilter = it > 0
    shouldFilter
}

ints.filter {
    val shouldFilter = it > 0
    return@filter shouldFilter
}

这一约定再加上把 lambda 表达式放在圆括号之外的写法,使得 LINQ 风格的代码成为可能:

1
strings.filter { it.length == 5 }.sortedBy { it }.map { it.uppercase() }

用下划线表示未使用的变量

如果 lambda 参数未被使用,可以用下划线代替它的名字:

1
map.forEach { (_, value) -> println("$value!") }

lambda 中的解构

lambda 中的解构作为解构声明的一部分来介绍。

匿名函数

上面的 lambda 表达式语法缺少一样东西——无法指定函数的返回类型。大多数情况下并不需要这样做,因为返回类型可以自动推断。但如果你确实需要显式指定,可以使用另一种语法:匿名函数。

1
fun(x: Int, y: Int): Int = x + y

匿名函数看起来与普通函数声明非常相似,只是省略了名称。它的函数体可以是表达式(如上所示),也可以是代码块:

1
2
3
fun(x: Int, y: Int): Int {
    return x + y
}

参数和返回类型的指定方式与普通函数相同,只是如果能从上下文推断出参数类型,就可以省略它们:

1
ints.filter(fun(item) = item > 0)

匿名函数的返回类型推断与普通函数完全一样:带表达式函数体的匿名函数会自动推断返回类型,而带块体的匿名函数则必须显式指定返回类型(否则会被假定为 Unit)。

注意: 当把匿名函数作为参数传递时,请把它们放在圆括号内。允许把函数放在圆括号之外的简写语法只适用于 lambda 表达式。

lambda 表达式与匿名函数的另一个区别在于非局部返回的行为。不带标签的 return 语句总是从用 fun 关键字声明的函数返回。这意味着 lambda 表达式中的 return 会从外层函数返回,而匿名函数中的 return 则从匿名函数本身返回。

闭包

lambda 表达式或匿名函数(以及局部函数和对象表达式)可以访问其闭包,其中包含在外层作用域中声明的变量。闭包中捕获的变量可以在 lambda 中被修改:

1
2
3
4
5
var sum = 0
ints.filter { it > 0 }.forEach {
    sum += it
}
print(sum)

带接收者的函数字面量

带接收者的函数类型,例如 A.(B) -> C,可以用一种特殊形式的函数字面量——带接收者的函数字面量——来实例化。

如前所述,Kotlin 支持在提供接收者对象的同时调用带接收者的函数类型实例。

在函数字面量内部,调用时传入的接收者对象会成为隐式的 this,这样你无需任何额外限定符就能访问该接收者对象的成员,也可以用 this 表达式访问该接收者对象。

这种行为与扩展函数类似,后者同样允许你在函数体内访问接收者对象的成员。

下面是一个带接收者的函数字面量示例及其类型,其中 plus 是在接收者对象上调用的:

1
val sum: Int.(Int) -> Int = { other -> plus(other) }

匿名函数语法允许你直接指定函数字面量的接收者类型。当你先声明一个带接收者的函数类型变量、之后再使用它时,这会很有用。

1
val sum = fun Int.(other: Int): Int = this + other

当接收者类型可以从上下文推断出来时,lambda 表达式可以用作带接收者的函数字面量。它们最重要的用例之一是类型安全的构建器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class HTML {
    fun body() { ... }
}

fun html(init: HTML.() -> Unit): HTML {
    val html = HTML() // 创建接收者对象
    html.init() // 把接收者对象传给 lambda
    return html
}

html { // 带接收者的 lambda 从这里开始
    body() // 在接收者对象上调用方法
}