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道找错题