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。