Kotlin 的五个作用域函数都会立即调用一个以对象为上下文的 lambda;它们的实质差别是对象在块中叫 this 还是作为参数传入,以及整个表达式返回对象还是 lambda 结果。
函数名不会约束副作用,嵌套的隐式接收者又可能遮蔽彼此;仅凭「let 处理空值、also 记录日志」这类口诀审查代码并不可靠。
先确定调用链下一步需要的返回值,再决定用 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。
选择顺序
选择函数时可以按固定顺序判断:
- 先确定整个表达式应返回上下文对象,还是一个新结果。
- 再判断块内用隐式接收者,还是具名参数更容易读。
- 如果需要跳过空接收者,在扩展调用前明确写
?.。 - 最后检查嵌套、链长和副作用;结构仍不清楚时改回局部变量。
这个顺序比按场景背诵函数名更稳。
例如「用于日志」并不能单独决定 also,因为日志后若需要返回日志调用的结果,let 反而符合返回形状。
先看类型流,再看意图词。
示例
下面四个程序从保留对象的配置链开始,再展示可空转换、结果计算和嵌套接收者。 它们均使用 Kotlin 2.4.10 编译,并在 JRE 21 上运行;输出块来自实际执行。
配置后保留同一对象
apply 配置 Shipment 的成员,also 接着记录事件并输出审计信息。
两个调用都返回接收到的 Shipment,所以最后的 shipment 仍是这次创建的实例。
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 把规范化邮箱转换成最终文本。
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 contactC-8 与 C-9 得到相同文本,因为 Elvis 左侧在两条路径上都产生 null。
这对当前函数契约是有意的;若调用方必须区分客户缺失和邮箱缺失,就应保留两种结果,而不是继续增加 let。
这里显式命名了 customer 和 email。
若两层都写 it,内层参数会遮蔽外层参数,字符串模板很容易引用错对象。
用 run 和 with 计算结果
扩展 run 在发票接收者上完成校验与求和,并返回最后的 Int。
with 再把同一发票作为接收者,返回格式化字符串。
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() 是 Int,summary 是 String。
若误把 run 改成 apply,扩展函数就会尝试返回 Invoice 而不是声明的 Int,编译器会立即报告类型不匹配。
with 适合已经有一个非空对象、只想集中读取成员并得到结果的情况。
它没有提供空值短路,因此不要仅为了句式对称把 invoice?.run 改写成 with(invoice)。
给嵌套接收者加标签
外层 apply 的接收者是 Form,内层 run 的接收者是 Profile,两者都有 city。
标签把赋值目标和来源都写明,避免最近接收者接管未限定名称。
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@let、return@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 推导未经测量的速度结论。
它可能消除部分函数对象或间接调用,也可能增加调用点代码体积;后端与上下文决定最终机器码。
本主题没有可复现基准,因此只说明语义和实现形状,不给出性能数字。
设计清楚的调用链
一条链的可读性取决于读者能否持续回答「当前值是什么」。
apply 和 also 保持上下文值,let 和 run 改为块结果,with 则从括号内对象开始一个结果计算。
在纸面或审查评论中写出每一步静态类型,常能立刻暴露错误替换。
作用域函数边界不会创建事务、锁或资源作用域。 块中打开文件后仍要明确关闭,修改共享对象后仍要同步,调用远程服务后仍要定义超时和重试。 「作用域」只描述名称访问的局部上下文,不表示资源生命周期自动受控。
返回上下文对象的链尤其容易隐藏部分修改。
apply 中前两项赋值成功、第三项抛出异常时,对象可能已经改变,只是整个表达式没有正常返回。
需要全有或全无的配置时,应先验证输入、构造不可变值,或把提交动作放在能维护不变量的类型中。
公共 API 不应要求调用方猜测一串作用域函数的中间契约。
重复出现的转换适合提取为具名函数,复杂构建适合让构建器在 build() 时验证,带多个失败原因的流程适合显式结果类型。
作用域函数最好服务于清楚的结构,而不是代替结构。
延伸阅读
4个问题 · 2 道输出预测题 · 1 道找错题