作用域函数

用接收者与返回值两个维度选择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时提供该对象作为临时上下文,让一小组紧邻操作不必反复写对象名称。 五个函数是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参数itlambda结果扩展函数
run接收者thislambda结果扩展函数
with接收者thislambda结果普通函数,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仍是这次创建的实例。

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

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

用run和with计算结果

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

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()是Int,summary是String。 若误把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@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道找错题

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