作用域函数

用接收者与返回值两个维度选择 Kotlin 的 let、run、with、apply 和 also,并避开空值、嵌套接收者与链式返回陷阱。

难度 进阶 时长 标准深度约 13分钟
版本 Kotlin 2.4.10
what

Kotlin 的五个作用域函数都会立即调用一个以对象为上下文的 lambda;它们的实质差别是对象在块中叫 this 还是作为参数传入,以及整个表达式返回对象还是 lambda 结果。

trap

函数名不会约束副作用,嵌套的隐式接收者又可能遮蔽彼此;仅凭「let 处理空值、also 记录日志」这类口诀审查代码并不可靠。

fix

先确定调用链下一步需要的返回值,再决定用 this 还是显式参数更清楚;对可空值写出 ?.,嵌套接收者使用名称或标签。

是什么,为什么存在

作用域函数(scope function) 是 Kotlin 标准库中一组接收对象和 lambda 的内联函数。 它们调用 lambda 时提供该对象作为临时上下文,让一小组紧邻操作不必反复写对象名称。 五个函数是 letrunwithapplyalso

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

真正决定语义的是两个维度。 第一个维度是上下文对象在 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参数 itlambda 结果扩展函数
run接收者 thislambda 结果扩展函数
with接收者 thislambda 结果普通函数,with(value) {}
apply接收者 this上下文对象扩展函数
also参数 it上下文对象扩展函数

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

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

返回值决定链的下一步

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

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

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

with 与两种 run

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

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

扩展形式的 runlet 都返回计算结果。 对象主要作为多个函数的实参时,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 仍是这次创建的实例。

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)
}
audit: S-104/Rail
Shipment(id=S-104, carrier=Rail, insured=true, events=[configured])

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

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

let 转换可空值

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

nullable_contact.kt
data class Customer(val id: String, val email: String?)

fun findCustomer(id: String): Customer? = when (id) {
    "C-7" -> Customer(id, " [email protected] ")
    "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"))
}
C-7 <[email protected]>
no contact
no contact

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

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

runwith 计算结果

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

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)
}
INV-9: 3 lines, 900c

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

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

给嵌套接收者加标签

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

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())
}
city=Paris

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

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

陷阱

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

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

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

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

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

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

深入 接收者解析与嵌套作用域

接收者解析与嵌套作用域

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

设计清楚的调用链

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

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

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

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

延伸阅读

检查点

4个问题 · 2 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?