# 作用域函数

Source: https://codewiki.com/zh/kotlin/scope-functions/

> - **what**: Kotlin 的五个作用域函数都会立即调用一个以对象为上下文的 lambda；它们的实质差别是对象在块中叫 `this` 还是作为参数传入，以及整个表达式返回对象还是 lambda 结果。
> - **trap**: 函数名不会约束副作用，嵌套的隐式接收者又可能遮蔽彼此；仅凭「`let` 处理空值、`also` 记录日志」这类口诀审查代码并不可靠。
> - **fix**: 先确定调用链下一步需要的返回值，再决定用 `this` 还是显式参数更清楚；对可空值写出 `?.`，嵌套接收者使用名称或标签。

## 是什么，为什么存在

作用域函数（scope function）
是 Kotlin 标准库中一组接收对象和 lambda 的内联函数。
它们调用 lambda 时提供该对象作为临时上下文，让一小组紧邻操作不必反复写对象名称。
五个函数是 `let`、`run`、`with`、`apply` 和 `also`。

这些函数没有增加新的语言能力。
不用它们也能完成同样的赋值、调用和转换。
它们解决的是局部表达方式：把「配置这个对象」「用这个值计算结果」或「在链中观察这个值」写成一个边界清楚的块。

真正决定语义的是两个维度。
第一个维度是上下文对象在 lambda 中作为 `this` 还是普通参数出现；第二个维度是调用结束后返回上下文对象，还是返回 lambda 的最后结果。
只要先回答这两个问题，五个相似名字就不必靠死记。

作用域函数常出现在对象初始化、可空值转换、集合处理和构建器调用中。
短块能让主对象保持醒目，但长链或多层嵌套会隐藏当前值的类型、接收者和副作用。
当普通局部变量与顺序语句更清楚时，不使用作用域函数也是地道的 Kotlin。

`let` 并不拥有特殊的空安全能力。
真正跳过调用的是安全调用（safe call） `?.`，因此 `value?.run {}`、`value?.apply {}` 和 `value?.also {}` 也都能只在非空时执行。
反过来，`nullable.let {}` 没有 `?.` 时一定会执行，参数 `it` 仍可能是 `null`。

## 工作原理

### 两个选择轴

下表是选择函数时最可靠的起点。
「对象参数」表示 lambda 收到普通参数，默认名是 `it`；「接收者」表示 lambda 是带接收者的 lambda（lambda with receiver），成员可通过隐式 `this` 访问。

| 函数 | 块中的对象 | 整个表达式返回 | 调用形式 |
| --- | --- | --- | --- |
| `let` | 参数 `it` | lambda 结果 | 扩展函数 |
| `run` | 接收者 `this` | lambda 结果 | 扩展函数 |
| `with` | 接收者 `this` | lambda 结果 | 普通函数，`with(value) {}` |
| `apply` | 接收者 `this` | 上下文对象 | 扩展函数 |
| `also` | 参数 `it` | 上下文对象 | 扩展函数 |

`let` 与 `also` 把对象传给 `(T) -> ...` 形状的 lambda。
单参数 lambda 可以省略参数声明并使用 `it`，也可以改成 `customer`、`shipment` 等领域名称。
块中还有其他值或出现嵌套 lambda 时，命名参数通常比连续的 `it` 更容易检查。

`run`、`with` 与 `apply` 接收 `T.() -> ...` 形状的 lambda。
这类块把 `T` 设为隐式接收者，未限定的属性和成员调用可以落到 `this` 上。
它适合主要读写该对象成员的短块，但也会让局部名称、外层接收者和成员之间的解析不够显眼。

### 返回值决定链的下一步

`let`、`run` 和 `with` 返回 lambda 的结果。
通常就是块中最后一个表达式的值，所以它们适合转换对象、计算摘要或把多条语句组合成一个表达式。
块返回 `null` 时，整个调用也返回 `null`；函数不会替你区分「接收者为空」和「计算结果为空」。

`apply` 与 `also` 忽略块的结果并返回原上下文对象。
对引用类型而言，链的下一步仍拿到同一个实例，而不是副本。
`apply` 的接收者形式适合集中配置成员；`also` 的参数形式适合把对象交给日志、校验或注册函数，同时保留链上原值。

返回上下文对象不代表块没有修改它。
`also` 完全可以通过 `it` 改写可变对象，`apply` 也可以发起 I/O；名称只是阅读约定，不是编译器执行的效果系统。
审查时要看块内实际调用，而不是根据函数名推断纯度。

### `with` 与两种 `run`

`with(value) { ... }` 不是扩展调用，而是把 `value` 作为第一个实参传给普通函数。
它无条件执行块；如果 `value` 的类型是可空类型，块中的 `this` 也可能为 `null`。
需要跳过空值时，`value?.run { ... }` 往往把条件写得更直接。

标准库还有不带上下文对象的 `run { ... }`。
它只执行 `() -> R` 并返回结果，适合在表达式位置创建几个局部变量。
它不是第六种对象作用域函数，也没有隐式 `this`；不要把两种 `run` 的调用形状混在一起解释。

扩展形式的 `run` 与 `let` 都返回计算结果。
对象主要作为多个函数的实参时，`let` 的具名参数更清楚；块主要调用对象成员时，`run` 的接收者形式更紧凑。
两者可以相互改写，选择依据是可读性而非功能差异。

### 空值与调用位置

`value?.let { transform(it) }` 的求值顺序是先检查接收者，再决定是否调用 `let`。
接收者为 `null` 时 lambda 不执行，整个安全调用产生 `null`；非空时，块内参数被收窄为非空类型。
同一规则适用于其他扩展作用域函数。

安全调用的位置会改变边界。
`source?.let { parse(it) }?.also { save(it) }` 会在 `source` 为空或 `parse` 返回空时跳过 `also`。
若写成 `source?.let { parse(it).also { save(it) } }`，内层 `also` 是否执行只取决于 `parse` 的静态调用方式，而且它的参数可能就是可空值。

Elvis 运算符接在可空结果后时，同样无法说明空值来自哪里。
如果「未找到客户」与「客户没有邮箱」需要不同处理，就应使用显式分支或密封结果，而不是把两条路径都压成 `?.let { ... } ?: fallback`。

### 选择顺序

选择函数时可以按固定顺序判断：

1. 先确定整个表达式应返回上下文对象，还是一个新结果。
2. 再判断块内用隐式接收者，还是具名参数更容易读。
3. 如果需要跳过空接收者，在扩展调用前明确写 `?.`。
4. 最后检查嵌套、链长和副作用；结构仍不清楚时改回局部变量。

这个顺序比按场景背诵函数名更稳。
例如「用于日志」并不能单独决定 `also`，因为日志后若需要返回日志调用的结果，`let` 反而符合返回形状。
先看类型流，再看意图词。

## 示例

下面四个程序从保留对象的配置链开始，再展示可空转换、结果计算和嵌套接收者。
它们均使用 Kotlin 2.4.10 编译，并在 JRE 21 上运行；输出块来自实际执行。

### 配置后保留同一对象

`apply` 配置 `Shipment` 的成员，`also` 接着记录事件并输出审计信息。
两个调用都返回接收到的 `Shipment`，所以最后的 `shipment` 仍是这次创建的实例。

<!-- quick -->

```kotlin
// file: configure_shipment.kt
data class Shipment(
    val id: String,
    var carrier: String = "",
    var insured: Boolean = false,
    val events: MutableList<String> = mutableListOf(),
)

fun main() {
    val shipment = Shipment("S-104").apply {
        carrier = "Rail"
        insured = true
    }.also { configured ->
        configured.events += "configured"
        println("audit: ${configured.id}/${configured.carrier}")
    }

    println(shipment)
}
```

```text
audit: S-104/Rail
Shipment(id=S-104, carrier=Rail, insured=true, events=[configured])
```

<!-- /quick -->

`apply` 块中的未限定赋值落到 `Shipment` 接收者上。
`also` 显式命名参数后，事件列表属于哪个对象一目了然。
输出也证明 `also` 并非只读：它返回原对象，但块仍增加了事件。

若审计函数可能抛出异常，链不会返回 `shipment`，后面的语句也不会运行。
作用域函数不会隔离副作用失败；是否允许「配置完成但审计失败」需要由调用方的错误策略决定。

### 用 `let` 转换可空值

查询可能找不到客户，邮箱字段也可能缺失。
两层安全调用分别守住这两个边界，内层 `let` 把规范化邮箱转换成最终文本。

```kotlin
// file: nullable_contact.kt
data class Customer(val id: String, val email: String?)

fun findCustomer(id: String): Customer? = when (id) {
    "C-7" -> Customer(id, " ADA@EXAMPLE.COM ")
    "C-8" -> Customer(id, null)
    else -> null
}

fun contactLine(id: String): String =
    findCustomer(id)?.let { customer ->
        customer.email
            ?.trim()
            ?.lowercase()
            ?.let { email -> "${customer.id} <$email>" }
    } ?: "no contact"

fun main() {
    println(contactLine("C-7"))
    println(contactLine("C-8"))
    println(contactLine("C-9"))
}
```

```text
C-7 <ada@example.com>
no contact
no contact
```

`C-8` 与 `C-9` 得到相同文本，因为 Elvis 左侧在两条路径上都产生 `null`。
这对当前函数契约是有意的；若调用方必须区分客户缺失和邮箱缺失，就应保留两种结果，而不是继续增加 `let`。

这里显式命名了 `customer` 和 `email`。
若两层都写 `it`，内层参数会遮蔽外层参数，字符串模板很容易引用错对象。

### 用 `run` 和 `with` 计算结果

扩展 `run` 在发票接收者上完成校验与求和，并返回最后的 `Int`。
`with` 再把同一发票作为接收者，返回格式化字符串。

```kotlin
// file: invoice_summary.kt
data class Invoice(val number: String, val lineCents: List<Int>)

fun Invoice.totalCents(): Int = run {
    require(lineCents.isNotEmpty()) { "invoice must have lines" }
    lineCents.sum()
}

fun main() {
    val invoice = Invoice("INV-9", listOf(450, 325, 125))
    val summary = with(invoice) {
        "$number: ${lineCents.size} lines, ${totalCents()}c"
    }

    println(summary)
}
```

```text
INV-9: 3 lines, 900c
```

这里两个函数都返回 lambda 结果，所以 `totalCents()` 是 `Int`，`summary` 是 `String`。
若误把 `run` 改成 `apply`，扩展函数就会尝试返回 `Invoice` 而不是声明的 `Int`，编译器会立即报告类型不匹配。

`with` 适合已经有一个非空对象、只想集中读取成员并得到结果的情况。
它没有提供空值短路，因此不要仅为了句式对称把 `invoice?.run` 改写成 `with(invoice)`。

### 给嵌套接收者加标签

外层 `apply` 的接收者是 `Form`，内层 `run` 的接收者是 `Profile`，两者都有 `city`。
标签把赋值目标和来源都写明，避免最近接收者接管未限定名称。

```kotlin
// file: nested_receivers.kt
data class Profile(val city: String)

class Form {
    var city: String = "unset"
    fun render(): String = "city=$city"
}

fun buildForm(profile: Profile): Form =
    Form().apply formScope@{
        profile.run profileScope@{
            this@formScope.city = this@profileScope.city
        }
    }

fun main() {
    println(buildForm(Profile("Paris")).render())
}
```

```text
city=Paris
```

没有标签时，内层块中的裸 `city` 首先面对最近的 `Profile` 接收者。
因为该属性是 `val`，错误写法可能编译失败；若两个对象都暴露可写同名成员，更危险的结果是代码通过编译却更新错对象。

实际代码通常可以用局部变量或具名辅助函数消除这种嵌套。
标签适合接收者关系本身有意义的短块，不应成为维持多层作用域函数的借口。

## 陷阱

> **陷阱:** 只按使用场景背诵「`let` 用于空值、`apply` 用于配置」，会忽略真正的返回类型。生成式重构尤其容易替换函数名后保留原链，导致后续调用落到 lambda 结果或错误对象上。

**修复方法：** 为链中每一步写出输入静态类型与输出静态类型，然后用返回维度先筛选函数。编译通过后仍要检查对象身份与副作用，因为相同类型不代表相同值。

> **陷阱:** 把 `nullable.let {}` 当成空安全写法时，lambda 会照常执行，`it` 的类型仍然可空。真正的短路来自 `nullable?.let {}` 中的 `?.`。

**修复方法：** 测试空接收者，并标出安全调用符位于哪一层。块确实要接收 `null` 时保留普通点号，同时给参数写出能说明含义的名字，不要让读者误以为调用被跳过。

> **陷阱:** 嵌套 `run`、`apply` 或 `with` 会叠加隐式接收者；再嵌套使用 `it` 的 lambda 后，短名称可能指向完全不同的对象。若对象有同名成员，错误不一定触发编译失败。

**修复方法：** 优先拆成局部变量或具名函数。嵌套确有结构意义时，为接收者加标签、为普通参数命名，并在赋值位置显式写 `this@label`。

> **陷阱:** `also` 返回原对象，却不保证块只观察对象或只执行一次业务副作用。网络重试、上层重复调用或块中异常都可能让日志、消息发送和数据库写入与链的直观外观不一致。

**修复方法：** 把不可重复的 I/O 放到名称和错误策略明确的语句中。若保留 `also`，记录其异常传播与幂等要求，并用重复调用和失败注入测试副作用次数。

> **陷阱:** `let`、`run` 和 `with` 都能把可空 lambda 结果送入 Elvis 分支，因此一条紧凑链可能抹掉不同失败原因。它还可能让中间类型在多次转换后变得难以从调用点看出。

**修复方法：** 只有多种空值原因对调用方等价时才合并它们。业务需要区分原因时使用显式分支或结果类型；链超过一次实质转换时，给中间结果命名并写出类型。

> **陷阱:** 作用域函数是内联函数，块中的裸 `return` 可能成为非局部返回（non-local return），直接退出包围它的具名函数。审查者若把它当成「只结束 lambda」，会漏掉后续代码永远不执行的路径。

**修复方法：** 只结束块时使用 `return@let`、`return@run` 等带标签返回；退出逻辑复杂时改用普通条件或具名函数。测试早期命中与未命中两条路径，并确认返回目标。

<!-- deep -->

## 接收者解析与嵌套作用域

接收者 lambda 并没有创建一个可随意取名的新对象。
`this` 仍由静态函数类型提供，未限定成员按照 Kotlin 的声明与隐式接收者规则解析。
块外的局部变量仍可见，所以属性名与局部名相同时，短写法可能掩盖数据来自哪里。

嵌套接收者时，最近且适用的隐式接收者通常拥有更高优先级。
显式标签能选择外层 lambda 的 `this`，例如 `this@formScope`；普通 lambda 参数则可以直接命名。
如果一段代码需要频繁跨过两层接收者读写，拆开作用域通常比继续增加限定符更容易维护。

成员与扩展的解析规则也仍然有效。
作用域函数自身是扩展函数（extension function）时，点号只是调用语法；它不会把新成员注入接收者类型。
若同一块里有多个可见扩展和隐式接收者，导入、声明位置与静态类型都会影响候选集合。

DSL 经常有意嵌套接收者，因为层级本身就是模型的一部分。
这时可用 `@DslMarker` 限制不应同时隐式访问的外层接收者。
普通业务代码没有这种设计边界时，局部变量往往是更小也更清楚的工具。

## 内联、契约与控制流

Kotlin 2.4.10 标准库中的五个函数都以内联形式实现。
函数体分别调用 `block(this)`、`this.block()` 或 `receiver.block()`，然后选择返回块结果或上下文对象。
这解释了比较表中的差异，不需要把函数名当成特殊语法。

它们的标准库实现还声明 `callsInPlace(block, InvocationKind.EXACTLY_ONCE)` 契约。
编译器可以利用这个信息分析块内初始化等控制流事实。
契约描述调用约定，但不是监控业务副作用的运行时防护，也不能保证包围作用域函数的外层函数只调用一次。

内联使某些控制流可以穿过 lambda 边界。
裸 `return` 能退出包围调用的具名函数，而 `return@let` 或显式标签只返回当前 lambda 调用。
如果以后把代码移到非内联高阶函数中，同一个裸 `return` 可能不再合法，因此重构必须重新编译并覆盖控制流测试。

不要由 `inline` 推导未经测量的速度结论。
它可能消除部分函数对象或间接调用，也可能增加调用点代码体积；后端与上下文决定最终机器码。
本主题没有可复现基准，因此只说明语义和实现形状，不给出性能数字。

## 设计清楚的调用链

一条链的可读性取决于读者能否持续回答「当前值是什么」。
`apply` 和 `also` 保持上下文值，`let` 和 `run` 改为块结果，`with` 则从括号内对象开始一个结果计算。
在纸面或审查评论中写出每一步静态类型，常能立刻暴露错误替换。

作用域函数边界不会创建事务、锁或资源作用域。
块中打开文件后仍要明确关闭，修改共享对象后仍要同步，调用远程服务后仍要定义超时和重试。
「作用域」只描述名称访问的局部上下文，不表示资源生命周期自动受控。

返回上下文对象的链尤其容易隐藏部分修改。
`apply` 中前两项赋值成功、第三项抛出异常时，对象可能已经改变，只是整个表达式没有正常返回。
需要全有或全无的配置时，应先验证输入、构造不可变值，或把提交动作放在能维护不变量的类型中。

公共 API 不应要求调用方猜测一串作用域函数的中间契约。
重复出现的转换适合提取为具名函数，复杂构建适合让构建器在 `build()` 时验证，带多个失败原因的流程适合显式结果类型。
作用域函数最好服务于清楚的结构，而不是代替结构。

<!-- /deep -->

[检查点: kotlin/scope-functions](https://codewiki.com/zh/kotlin/scope-functions/#checkpoint)

## 延伸阅读

- [Kotlin 文档：作用域函数](https://kotlinlang.org/docs/scope-functions.html)
- [Kotlin 2.4.10 标准库源码：`Standard.kt`](https://raw.githubusercontent.com/JetBrains/kotlin/v2.4.10/libraries/stdlib/src/kotlin/util/Standard.kt)
- [Kotlin 文档：高阶函数与 lambda](https://kotlinlang.org/docs/lambdas.html)
- [Kotlin 文档：内联函数](https://kotlinlang.org/docs/inline-functions.html)
- [Kotlin 语言规范：重载解析](https://kotlinlang.org/spec/overload-resolution.html)
