Kotlin 的 Scope Functions:let、run、apply、also、with

1. 总览

Kotlin 有五个看起来非常相似的函数:let、run、with、apply 和 also。它们都在一个对象上运行一段代码,但在访问对象的方式和函数返回值上有所不同。很多人随意选择一个函数,代码仍然能运行,但会更难理解。

本文解释了区分 scope function 的两个标准,并用具体例子说明每个函数。然后你将编写测试来验证它们的行为并了解常见错误。读完后,你将能够为每种情况选择正确的函数。

2. 两个区分标准

每个 scope function 都接收一个 lambda 并在一个 context object 上调用它。你只需回答两个问题:

  1. 在 lambda 中,你是通过 this(receiver)还是 it(参数)访问对象?
  2. 函数是返回 lambda 的结果 还是返回 对象本身?
函数 Context object 返回值 是否为扩展函数?
let it Lambda 结果 是
run this Lambda 结果 是(也有非扩展版本)
with this Lambda 结果 否,对象是参数
apply this 对象本身 是
also it 对象本身 是

你可以按对来快速记忆:

  • apply 和 also 返回对象,因此它们适合配置对象或在调用链中插入副作用。
  • let、run 和 with 返回结果,因此它们适合将对象转换为其他值。

下面的例子使用一些简单的类:

data class Customer(val name: String, val email: String?)

class Order(val id: String) {
    val items = mutableListOf<Double>()
    var discountPercent = 0
}

class Invoice {
    var number: String = ""
    var amount: Double = 0.0
    var paid: Boolean = false
}

3. let:转换并处理 nullable 值

let 将对象作为参数 it 传入 lambda 并返回 lambda 的结果。最常见的用法是与安全调用 ?. 结合,只在值不是 null 时运行代码:

fun sendWelcome(customer: Customer) {
    customer.email?.let { email ->
        println("向 $email 发送欢迎邮件")
    }
}

fun main() {
    sendWelcome(Customer("An", "an@example.com"))
    sendWelcome(Customer("Bình", null))
}
向 an@example.com 发送欢迎邮件

对于客户 Bình,email 是 null。表达式 ?. 在这里停止,lambda 不会执行。在 lambda 中,email 的类型是 String 而不是 String?,所以你可以直接使用它而无需进一步检查。这里我们将参数命名为 email 而不是 it 以使代码更易读。

当你想转换一个值而无需创建临时变量时,let 也很有用:

val domain = Customer("An", "an@example.com").email
    ?.let { it.substringAfter("@") }
    ?: "未知"

println(domain) // example.com

4. run:基于对象进行计算

run 类似于 let,但对象是通过 this 访问的。由于 this 可以省略,你可以直接调用对象的属性和方法:

val order = Order("ORD-42").apply {
    items += listOf(100_000.0, 50_000.0)
    discountPercent = 10
}

val total = order.run {
    val subtotal = items.sum()
    subtotal - subtotal * discountPercent / 100
}

println(total) // 135000.0

在 run 块中,items 和 discountPercent 是 order 的属性。返回值是最后一个表达式,即折扣后的金额。

run 还有一个 非扩展函数 版本。你可以用它来组合几个语句生成一个值,而不会让临时变量泄露到外部作用域:

val connectionUrl = run {
    val host = System.getenv("DB_HOST") ?: "localhost"
    val port = System.getenv("DB_PORT") ?: "5432"
    "jdbc:postgresql://$host:$port/shop"
}

在这个块之后,host 和 port 就不存在了。只有 connectionUrl 被保留。

5. with:聚合同一对象上的多个调用

with 的工作方式类似于 run,但它不是扩展函数。对象作为第一个参数传入:

val summary = with(order) {
    "订单 $id: ${items.size} 件商品,优惠 $discountPercent%"
}

println(summary)
订单 ORD-42: 2 件商品,优惠 10%

在语义上,with(order) { ... } 读作“用这个订单,做…”。当对象肯定不是 null 且你需要调用它的多个成员时,这个函数很合适。如果对象可能是 null,你应该使用 order?.run { ... },因为 with 不能与 ?. 结合使用。

6. apply:配置对象

apply 通过 this 访问对象并返回 对象本身。这是一种习惯用法,用于在一个表达式中初始化和配置对象:

val invoice = Invoice().apply {
    number = "INV-001"
    amount = 250_000.0
    paid = false
}

println("${invoice.number} - ${invoice.amount}") // INV-001 - 250000.0

lambda 的结果被忽略,apply 总是返回刚创建的 Invoice。当你使用 Java 类的 setter 时,你会经常看到 apply,例如在 Spring 中配置 bean 时:

@Bean
fun restTemplate(): RestTemplate = RestTemplate().apply {
    requestFactory = SimpleClientHttpRequestFactory().apply {
        setConnectTimeout(2_000)
        setReadTimeout(5_000)
    }
}

7. also:添加副作用而不破坏调用链

also 通过 it 访问对象并也返回对象本身。这个函数适合“顺便做”的事情,比如记录日志、验证或调用指标,这些操作不会改变主流程:

fun createOrder(id: String): Order =
    Order(id).also { println("已创建订单 ${it.id}") }

fun main() {
    val order = createOrder("ORD-7")
        .also { it.items += 99_000.0 }
        .also { println("商品数量:${it.items.size}") }
}
已创建订单 ORD-7
商品数量:1

因为对象是通过 it 访问的而不是 this,你仍然可以使用外部类的 this 而不会混淆。这也是为什么当 lambda 主要是 使用 对象而不是 配置 它时,also 比 apply 更易读。

有一个小技巧是使用 also 来交换两个变量:

var a = 1
var b = 2
a = b.also { b = a }
println("$a $b") // 2 1

表达式 b.also { ... } 返回 b 的旧值(是 2),而 lambda 在分配给 a 之前将 b = a(是 1)。

8. 选择哪个函数?

你可以使用以下规则:

  • 需要在值不是 null 时运行代码,或需要转换值:使用 ?.let。
  • 需要从对象的多个成员计算一个值:使用 run,或如果对象肯定不是 null 则使用 with。
  • 需要将几个语句组合成一个表达式:使用没有 receiver 的 run { }。
  • 需要初始化或配置对象:使用 apply。
  • 需要日志、验证或添加副作用同时保留对象:使用 also。

9. 测试行为

要确保你正确理解返回值,请编写一些测试。首先,在 pom.xml 中添加依赖项:

<dependencies>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
        <version>2.0.21</version>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.26.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

如果项目是 Spring Boot 3.x,spring-boot-starter-test 已经包含 JUnit 5 和 AssertJ,因此你不需要单独声明这两个库。

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

class ScopeFunctionsTest {

    @Test
    fun `apply 和 also 返回对象本身`() {
        val invoice = Invoice()

        val fromApply = invoice.apply { amount = 100.0 }
        val fromAlso = invoice.also { it.paid = true }

        assertThat(fromApply).isSameAs(invoice)
        assertThat(fromAlso).isSameAs(invoice)
        assertThat(invoice.amount).isEqualTo(100.0)
        assertThat(invoice.paid).isTrue()
    }

    @Test
    fun `let、run 和 with 返回 lambda 的结果`() {
        val order = Order("ORD-1").apply { items += listOf(10.0, 20.0) }

        val count: Int = order.let { it.items.size }
        val sum: Double = order.run { items.sum() }
        val label: String = with(order) { "Order $id" }

        assertThat(count).isEqualTo(2)
        assertThat(sum).isEqualTo(30.0)
        assertThat(label).isEqualTo("Order ORD-1")
    }

    @Test
    fun `当值为 null 时,let 不执行`() {
        var called = false
        Customer("Bình", null).email?.let { called = true }

        assertThat(called).isFalse()
    }
}

第一个测试使用 isSameAs 来检查 apply 和 also 返回的是原始引用,而不是副本。第二个测试声明显式类型(Int、Double、String)以清楚地看到每个函数返回 lambda 的结果。第三个测试确认 ?.let 跳过 null 值。

10. 常见错误

10.1. apply 中 this 被遮挡

在 apply 内部,最近的 receiver 具有优先级。因此,外部类中同名的属性将被遮挡:

class InvoiceFactory(private val amount: Double) {
    fun create() = Invoice().apply {
        amount = amount // 将 Invoice.amount 分配给自己,结果仍然是 0.0
    }
}

你有两种方式来修复。第一种是用 this@InvoiceFactory.amount 明确指定 receiver。第二种是切换到 also:

fun create() = Invoice().also { it.amount = amount }

10.2. ?.let { } ?: ... 的陷阱

很多人写 x?.let { ... } ?: fallback 的意图类似于 if/else。这种写法 并不等价。如果 lambda 返回 null,?: 分支也会执行:

val cache = mapOf<String, String>()
val email: String? = "an@example.com"

email?.let { cache[it] } ?: println("没有邮件")
没有邮件

这里 email 不是 null,但 cache[it] 返回 null,所以消息仍然被打印出来,尽管它不正确。当你需要明确分支时,使用 if (email != null) ... else ...。

10.3. 嵌套过多层

当你将 scope function 相互嵌套时,it 和 this 会指向不同的对象,读者必须回头追踪每个是什么:

// 难以阅读
order.let { o -> o.items.firstOrNull()?.let { price -> price.run { this * 2 } } }

注意: 你不应该嵌套超过一层。如果必须嵌套,使用明确的参数名而不是 it。更好的是分解成局部变量或单独的函数。

10.4. 仅仅因为可以使用 scope function

代码片段 val name = customer.let { it.name } 与 val name = customer.name 相比没有任何好处。Scope function 只有在帮你消除临时变量、处理 null 或聚合配置代码时才有用。如果它做不了这些事,就不要用它。

11. 结论

Kotlin 的五个 scope function 只在两个方面不同:对象是通过 this 还是 it 访问的,以及函数是返回对象还是 lambda 的结果。由此你有相应的用法:apply 用于配置,also 用于添加副作用,let 用于处理 nullable 值和转换,而 run 和 with 用于基于对象的计算。你也需要小心 this 被遮挡的情况、?.let ?: 的陷阱,以及过度嵌套的问题。最后,请记住 scope function 的目标是使代码更易读,而不是不惜一切代价使其更短。

kotlinscope-functionsletapplyclean-code