Kotlin 中的高阶函数与 lambda

1. 总览

Kotlin 中的函数是“一等公民”。你可以将函数赋值给变量、作为参数传递给它,或从另一个函数返回它。接收或返回另一个函数的函数被称为高阶函数。创建这样一个函数最简洁的方式是使用 lambda。

这篇文章涵盖函数类型、lambda 语法、函数引用、闭包、lambda with receiver 和 inline 关键字。读完之后,你将能够编写自己的高阶函数。你还会了解到几个常见的错误。

2. 函数类型

Kotlin 中的每个函数都有一个类型,称为函数类型。语法包括括号内的参数类型列表、箭头,然后是返回类型:

fun main() {
    val add: (Int, Int) -> Int = { a, b -> a + b }
    val greet: (String) -> Unit = { name -> println("你好, $name") }
    val now: () -> Long = { System.currentTimeMillis() }

    println(add(2, 3))
    greet("Lan")
    println(now() > 0)
}

解释:

  • (Int, Int) -> Int 是一个接收两个 Int 并返回 Int 的函数。
  • (String) -> Unit 是一个没有有意义返回值的函数,类似于 Java 中的 void。
  • () -> Long 是一个没有参数的函数。
  • 你像调用普通函数一样调用函数变量:add(2, 3)。这相当于 add.invoke(2, 3)。
5
你好, Lan
true

函数类型也可以是 nullable。在这种情况下,你必须在整个类型周围加括号:((Order) -> Unit)?。要安全地调用,使用 callback?.invoke(order)。

3. 编写第一个高阶函数

我们在整个文章中使用一个简单的模型。金额使用 Long 类型(以越南盾为单位)以避免浮点误差:

data class Order(val id: String, val customer: String, val amount: Long)

fun calculateTotal(orders: List<Order>, priceOf: (Order) -> Long): Long {
    var total = 0L
    for (order in orders) {
        total += priceOf(order)
    }
    return total
}

fun main() {
    val orders = listOf(
        Order("A1", "Lan", 100_000),
        Order("A2", "Minh", 250_000),
        Order("A3", "Lan", 50_000),
    )

    val full = calculateTotal(orders) { it.amount }
    val discounted = calculateTotal(orders) { it.amount * 90 / 100 }

    println(full)
    println(discounted)
}

calculateTotal 是一个高阶函数,因为参数 priceOf 的类型是 (Order) -> Long。这个函数不知道如何计算每个订单的价格。它将这个任务交给调用者。这样,相同的循环可以用于原始价格和折扣价格。

400000
360000

4. Lambda 语法

4.1. 不同的写法

Lambda 总是放在 {} 中。参数在 -> 之前,函数体在之后。最后一个表达式就是返回值,你不需要写 return:

val isLarge: (Order) -> Boolean = { order ->
    val threshold = 200_000
    order.amount > threshold   // 返回值
}

val isLargeShort: (Order) -> Boolean = { it.amount > 200_000 }

val ignoreSecond: (Order, Int) -> String = { order, _ -> order.id }
  • 当 lambda 只有一个参数时,你可以省略声明并使用隐式名称 it。
  • 未使用的参数应该用 _ 表示,以使代码清晰,避免编译器警告。

4.2. 尾部 lambda

如果函数的最后一个参数是函数类型,你可以将 lambda 放到圆括号外。如果这是唯一的参数,你甚至可以省略 ():

val orders = listOf(
    Order("A1", "Lan", 100_000),
    Order("A2", "Minh", 250_000),
)

val large = orders.filter { it.amount > 200_000 }
val total = calculateTotal(orders) { it.amount }
val sorted = orders.sortedByDescending { it.amount }.map { it.id }

println(large.map { it.id })
println(sorted)
[A2]
[A2, A1]

这就是为什么 Kotlin 代码使用 collection API 时读起来几乎像自然语言。标准库中的所有 filter、map、sortedByDescending 都是高阶函数。

5. 函数引用

当你已经有一个签名正确的函数时,你不需要将它包装在 lambda 中。使用 :: 操作符:

fun isVip(order: Order): Boolean = order.amount >= 200_000

class TaxService(private val ratePercent: Long) {
    fun withTax(order: Order): Long = order.amount * (100 + ratePercent) / 100
}

fun main() {
    val orders = listOf(Order("A1", "Lan", 100_000), Order("A2", "Minh", 250_000))
    val taxService = TaxService(10)

    println(orders.filter(::isVip).map { it.id })   // 顶层函数
    println(orders.map(Order::amount))              // 属性引用
    println(calculateTotal(orders, taxService::withTax)) // 绑定引用
}
  • ::isVip 引用一个顶层函数。
  • Order::amount 引用一个属性。它的类型是 (Order) -> Long。
  • taxService::withTax 是一个绑定引用:函数已经绑定到对象 taxService。
[A2]
[100000, 250000]
385000

函数引用通常比 lambda 更易读,因为函数名已经表明了意图。

6. 返回函数和闭包

6.1. 返回函数的函数

高阶函数也可以返回一个函数。这在你想基于配置创建行为时很有用:

fun discountFor(level: String): (Long) -> Long {
    val percent = when (level) {
        "GOLD" -> 80
        "SILVER" -> 90
        else -> 100
    }
    return { amount -> amount * percent / 100 }
}

fun main() {
    val goldPrice = discountFor("GOLD")
    val normalPrice = discountFor("NONE")

    println(goldPrice(500_000))
    println(normalPrice(500_000))
}
400000
500000

返回的 lambda 仍然“记得”变量 percent,即使 discountFor 已经执行完成。这种机制被称为闭包。

注意: 不要在 when 中写 "GOLD" -> { amount -> amount * 80 / 100 }。在那个位置,{} 被理解为一个代码块,而不是 lambda,所以代码不会编译。先计算值然后返回 lambda,如上面的例子所示。你也可以用圆括号包装 lambda:({ amount -> ... })。

6.2. 闭包可以修改变量

不同于 Java 中的 lambda,Kotlin lambda 允许改变它捕获的 var 变量:

fun orderIdGenerator(prefix: String): () -> String {
    var next = 1
    return { "$prefix-${next++}" }
}

fun main() {
    val nextId = orderIdGenerator("ORD")
    println(nextId())
    println(nextId())
    println(nextId())
}
ORD-1
ORD-2
ORD-3

编译器将 next 包装在一个引用对象中,称为 Ref,以便 lambda 和外部函数可以共享一个值。

注意: 修改共享变量的闭包不是线程安全的。如果多个线程同时调用 nextId(),你可能得到重复的 ID。在这种情况下,使用 AtomicInteger。

7. Lambda with receiver

Kotlin 有一种特殊的函数类型:T.() -> R。在这个 lambda 内部,this 就是类型 T 的对象。你可以直接访问它的成员,无需前缀。这是 DSL 的基础,例如 buildString、apply 或 Gradle Kotlin DSL:

class OrderBuilder {
    var id: String = ""
    var customer: String = ""
    var amount: Long = 0

    fun build(): Order = Order(id, customer, amount)
}

fun order(block: OrderBuilder.() -> Unit): Order =
    OrderBuilder().apply(block).build()

fun main() {
    val o = order {
        id = "B1"
        customer = "Hoa"
        amount = 120_000
    }
    println(o)
}
Order(id=B1, customer=Hoa, amount=120000)

block 的类型是 OrderBuilder.() -> Unit。因此在 lambda 中,id = "B1" 实际上是 this.id = "B1"。apply(block) 以新创建的 builder 为接收方调用 lambda,然后返回该 builder。

8. inline 和 lambda 中的 return

8.1. Lambda 的代价

在 JVM 上,每个 lambda 通常被编译成一个对象。如果 lambda 捕获变量,每次调用可能会创建一个新对象。对于调用非常频繁的函数,这个成本累加起来很可观。

将函数标记为 inline 会解决这个问题。编译器会直接将函数体和 lambda 体复制到调用处,所以不会创建任何对象:

inline fun <T> measure(label: String, block: () -> T): T {
    val start = System.nanoTime()
    val result = block()
    println("$label: ${(System.nanoTime() - start) / 1_000} µs")
    return result
}

fun main() {
    val orders = (1..1_000).map { Order("O$it", "C$it", it * 1_000L) }
    val total = measure("计算总和") { calculateTotal(orders) { it.amount } }
    println(total)
}
计算总和: 412 µs
500500000

测量的时间在不同的机器上会有所不同。标准库中的 filter、map、forEach 都是 inline。

8.2. Non-local return 和 label

在传递给 inline 函数的 lambda 中,普通 return 语句会退出外部函数,而不仅仅是 lambda:

fun findFirstLarge(orders: List<Order>, threshold: Long): Order? {
    orders.forEach {
        if (it.amount > threshold) return it   // 退出 findFirstLarge
    }
    return null
}

fun printPaidOrders(orders: List<Order>) {
    orders.forEach {
        if (it.amount == 0L) return@forEach    // 只跳过这个元素
        println(it.id)
    }
    println("完成")
}
  • return it 是 non-local return。它立即终止 findFirstLarge。
  • return@forEach 是带 label 的 return。它只终止当前的 lambda 迭代,类似于 continue。

对于非 inline 函数,lambda 中的普通 return 会被编译器报错。你必须使用 label。

注意: 如果 inline 函数将 lambda 传递到其他上下文,例如在另一个线程中运行,请将参数标记为 crossinline 以禁止 non-local return。如果想保存 lambda 到变量或字段,使用 noinline。

9. 测试高阶函数

将测试依赖添加到 pom.xml。项目需要已配置 kotlin-maven-plugin:

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.25.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

高阶函数非常容易测试。你只需传递一个简单的 lambda 并检查结果:

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class PricingTest {

    private val orders = listOf(
        Order("A1", "Lan", 100_000),
        Order("A2", "Minh", 250_000),
        Order("A3", "Lan", 50_000),
    )

    @Test
    fun `calculateTotal applies the given price function`() {
        assertThat(calculateTotal(orders) { it.amount }).isEqualTo(400_000L)
        assertThat(calculateTotal(orders) { 1L }).isEqualTo(3L)
    }

    @Test
    fun `discountFor returns function matching customer level`() {
        assertThat(discountFor("GOLD")(500_000)).isEqualTo(400_000L)
        assertThat(discountFor("SILVER")(500_000)).isEqualTo(450_000L)
        assertThat(discountFor("UNKNOWN")(500_000)).isEqualTo(500_000L)
    }

    @Test
    fun `generator keeps its own state per closure`() {
        val first = orderIdGenerator("ORD")
        val second = orderIdGenerator("INV")

        assertThat(listOf(first(), first(), second()))
            .containsExactly("ORD-1", "ORD-2", "INV-1")
    }
}

第三个测试确认了一个重要的点:每次调用 orderIdGenerator 都会创建一个独立的闭包,拥有自己的 next 变量。

10. 常见错误和何时不应使用

  • Lambda 过长。 如果 lambda 体超过几行,将其提取为一个具名函数,然后用 :: 传递。代码会更易读和易测。
  • 滥用嵌套的 it。 如果你写 orders.map { it.items.filter { it.price > 0 } },内层的 it 会隐藏外层的 it。给参数起清晰的名称。
  • 对所有东西使用 inline。 只应该 inline 小的函数和带 lambda 参数的函数。Inline 大函数会增加字节码。没有 lambda 参数的 inline 函数几乎没有好处,编译器会警告你。
  • 混淆 return。 forEach 中的普通 return 会退出整个外部函数。这是许多难以发现的 bug 的根源。当只想跳过元素时,使用 return@forEach。
  • Capture 重对象。 长期保存的 lambda,例如作为监听器,会保持对它捕获的所有变量的引用。这可能导致内存泄漏,例如在 Android 中保持整个 Activity。
  • 不必要的抽象。 如果只有一种处理方式且它不会改变,一个普通函数就足够了。不要添加 lambda 参数只是为了“以防万一”。

11. 结论

在本文中,你看到了函数类型如何描述函数的签名。Lambda 和函数引用是创建函数值的两种方式。高阶函数接收或返回函数,以将“框架”与“行为”分离。你还熟悉了闭包、lambda with receiver 来构建 DSL,以及 inline 来消除对象成本和 non-local return 的工作原理。

掌握这些概念后,你会发现阅读 Kotlin 标准库变得容易得多。你还会知道何时应该为自己的代码编写高阶函数 API。

kotlinlambda高阶函数函数式编程