let、run、with、apply、also 这五个作用域函数,语法上都是"把一段代码包起来",但它们的组合有 2×2 两种维度共四种,加上 with 构成五兄弟。答这题靠背表格没用——从定义出发(参数式 vs 接收者式、返回旧对象 vs 返回新值)就能自己推出这五个的行为,不需要背。
先把结论放在前面:两个维度决定一切。维度一:对象怎么进来——let/also 把值作为参数传(it),run/apply/with 把值作为接收者(this)。维度二:返回什么——also/apply 返回原对象(用于链式中间步),let/run 返回lambda 的结果(用于变换)。with 特殊:它不是扩展函数而是普通函数,参数是接收者,返回 lambda 结果。组合起来就是:let\=参数+新值、also\=参数+原对象、run\=接收者+新值、apply\=接收者+原对象。
机制拆解
从签名看差异最清楚。let 的签名是 inline fun <T, R> T.let(block: (T) -> R): R——T 是扩展接收者,(T) -> R 的参数是那个 T,所以 lambda 里用 it;返回 R(新值)。apply 是 inline fun <T> T.apply(block: T.() -> Unit): T——参数类型是 T.()(带接收者的函数类型),所以 lambda 里用 this;返回 T(原对象)。参数类型从 T 变成 T.(),就是"参数式"变成"接收者式"的差别所在,其余行为由此推导。
with 的签名是 inline fun <T, R> with(receiver: T, block: T.() -> R): R——注意它不是扩展函数(receiver 在括号里),所以调用形态是 with(obj) { ... } 而不是 obj.with { ... }。这个设计是历史原因(Kotlin 1.1 之前 with 确实是扩展),但保留至今是因为它更适合表达"对已有的非空对象做一组操作"。
also 值得单独说:它的典型用途是在链式调用中做副作用——user.copy(name = "x").also { log("updated ${it.id}") }.let { render(it) }。apply 的典型用途是配置式构造——Builder().apply { timeout = 5; retries = 3 }。
这些坑的正确绕法
最常见的坑是五个函数混用无规范,代码评审里争论语义比写代码还耗时。 表现是同项目里同一件事有三种写法(有人用 let、有人用 apply、有人用 run),每次 review 都要重新判断作者的意图;新人干脆随手挑一个,形成"风格泥石流"。根因是缺少团队约定,而不是函数本身难懂。修法是给出按意图选型的明确规则并在 code review 里执行:
- 需要变换后的值 →
let(或需要连续几行操作 →run) - 需要"做点副作用但继续用原对象" →
also - 需要配置对象 →
apply - 已有对象、连续操作 →
with
其次是 run 与 with 混用造成语义模糊。 表现是同一个"对已有对象做几件事"的场景,有人写 obj.run { a(); b() }、有人写 with(obj) { a(); b() },两者行为完全相同但形态不同,读代码时要重新适应。修法是在同一项目里对这两个选一个,推荐用 run(扩展形式支持链式,with 保留是为了兼容与"这不是扩展"的语义强调)。
还有一个更隐蔽的坑:在 let/run 里 return,语义与直觉不一致。 表现是 list.forEach { v -> if (check(v)) return; handle(v) } ——因为 forEach 是 inline 的,Lambda 里的 return 会从外层函数返回,而不是"跳过这个元素"。这与前面讲 inline 时是同一个机制,但放在作用域函数里更容易误导(因为 let 读起来像一个代码块)。修法:只想跳过当前元素用 for + continue;只跳出 Lambda 用 return@let;确实要结束外层函数才用 return。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// 1) 参数式:let / also —— lambda 里是 it
val len = name.let {
it.trim().length } // 新值:变换
val shown = user.also {
log("loading ${it.id}") }.name // 原对象:副作用后继续用
// 2) 接收者式:run / apply —— lambda 里是 this
val summary = user.run {
"${name}, ${age}" } // 新值:连续写几行
val builder = HttpClient().apply {
// 原对象:配置式构造
timeoutMs = 5_000
retries = 3
}
// 3) with:不是扩展,receiver 在括号里
val msg = with(user) {
"$name/$age" } // 与上面的 run 等价
// 4) 五个并排对照,体会两个维度
fun demo(u: User) {
u.let {
} // it → 返回新值
u.also {
} // it → 返回原对象
u.run {
} // this → 返回新值
u.apply {
} // this → 返回原对象
with(u) {
} // this → 返回新值(不是扩展)
}
// 5) also 做链式中间步:这是它最不可替代的用法
render(
user.copy(name = newName)
.also {
log("updated id=${it.id} at ${System.currentTimeMillis()}") }
.also {
analytics.track("name_change") }
)
// 6) 嵌套时 this/it 会遮蔽
user.apply {
address.run {
// 这里的 this 是 Address,it 在这一层不存在
val city = this.city
}
val owner = this@apply.name // label 指明外层
}
// 7) return 语义:作用域函数 + inline
fun findFirst(items: List<Item>) {
items.forEach {
if (it.valid) return } // 从本函数返回!不是跳过
Log.d("t", "走到这里说明没有有效项")
items.forEach {
if (!it.valid) return@forEach // 只跳过当前元素
handle(it) }
}
这段代码值得盯三处:第一处,第 1~3 段把五个函数按"参数式/接收者式 + 旧值/新值"分组排列,读者能直接看出规律而不是背表格;第二处,第 5 段连续两个 also 做链式中间步,说明了 also 不可替代的位置;第三处,第 7 段把 return 与 return@forEach 并列,用注释点明"inline 让 return 从外层函数返回"。
这题在面试里怎么问、怎么答
"这五个函数编译器生成了什么?"答:都是 inline 的扩展函数(或 with 是普通函数),Lambda 类型是 T.() -> R 或 (T) -> R 的差别决定 lambda 里 this 还是 it。内联后不存在函数对象,this 在内联点上就是外层接收者本身。
"let 和 run 有什么区别?"答:只差参数式还是接收者式。let 里用 it、lambda 体不带接收者;run 里用 this、可以访问接收者的成员而不用 it. 前缀。需要写几行且都是同一对象的操作时 run 更简洁。
"apply 和 also 都返回原对象,怎么选?"答:看 lambda 里怎么引用对象。apply 用 this,写起来像"配置这个对象",适合构造;also 用 it,写起来像"把这个对象交给谁",适合副作用(打日志、上报、校验)。
"使用中遇到过什么问题?"案例一:项目里同一类"配置请求参数"的代码有 apply、run、let 三种写法,维护时容易看错;修复是统一为 apply 并写进开发规范。案例二:在一个 forEach 里写 return,以为只是跳过当前项,实际直接从整个函数返回,导致后续数据没处理;修复为改用 return@forEach。
再补一个工程上值得讲清的一点:Compose 里这五个函数用得最多的其实是 apply(给 Modifier 链式配置)与 let(取局部值)。 比如 Modifier.padding(16.dp).clip(RoundedCornerShape(8.dp)) 本身就是链式配置;val density = LocalDensity.current.density 这类取值则常用 let 做兜底。但 Compose 里还有更贴切的工具:remember + derivedStateOf 用来替代"每次重组都重新算"的写法——这与上一批讲的"链式计算要缓存"是同一条工程原则的另一面。能把作用域函数的知识与 Compose 的重组机制连起来,在面试里是很少见的深度。
给正在准备面试的你
把这题画成一张 2×2 矩阵图:横轴是"lambda 怎么引用对象"(参数 it / 接收者 this),纵轴是"返回什么"(原对象 / 新值)。四个格子填:also(参数+原对象)、let(参数+新值)、apply(接收者+原对象)、run(接收者+新值)。矩阵右侧单列一个 with(不是扩展,接收者+新值),并标注"兼容保留,语义同 run"。图的下方加一条红色警告:"作用域函数都是 inline —— lambda 里的 return 返回外层函数,不是跳过当前元素。"这张图就是完整的选型答案,且读者能自己推出来。
再补工程案例与踩坑——应用落点是把项目里作用域函数的使用统一成"按意图选型"的四条规则并写进开发规范;排查作用域函数里的裸 return 改成 label 形式;把 with 统一为 run(或明确保留理由);Compose 与 Android 侧检查链式计算是否需要 remember 缓存。
复习时别孤立刷题:inline 内联函数——五个作用域函数全是 inline 的,这正是它们能支持非局部 return 且无函数对象分配的原因。
划两句重点:两个维度决定行为(参数式 it / 接收者式 this,返回原对象 / 返回新值);apply 配置、also 副作用、let/run 取新值、with 不是扩展;作用域函数是 inline 的,lambda 里的 return 返回外层函数,要跳过当前项用 return@label。
下一篇聊委托属性与 Delegates:属性系统的深水区——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:reified-泛型:擦除限制的破局者
下一篇预告:委托属性与-Delegates:属性系统的深水区
有任何问题欢迎在评论区留言交流。