第083篇 作用域函数五兄弟:let、run、with、apply、also

简介: Kotlin五大作用域函数(`let`/`run`/`with`/`apply`/`also`)本质由两个维度决定:**对象传入方式**(参数`it` vs 接收者`this`)和**返回值类型**(原对象 vs lambda结果)。理解定义即可推导行为,无需死记硬背。`apply`适合配置,`also`专用于链式副作用,`let`/`run`用于变换,`with`是非扩展函数,语义等价`run`。所有函数均为`inline`,需注意`return`语义。

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:属性系统的深水区

有任何问题欢迎在评论区留言交流。

相关文章
|
1天前
|
JSON API 数据库
第131篇BroadcastReceiver:静态注册与动态注册的存亡
本文深入剖析 BroadcastReceiver 的三层认知:API 层(能收广播)、机制层(知限制规则)、工程层(懂替代方案)。详解静态/动态注册、`goAsync()` 与 ANR 避坑、隐式广播限制(Android 8+)、有序广播淘汰原因及 `WorkManager` 等现代替代方案,助你真正掌握广播本质。
24 1
|
2天前
|
前端开发 IDE 编译器
第112篇 Kotlin 2.x 演进:K2 编译器带来了什么
本节聚焦Kotlin 2.0编译器与工具链演进:核心是**K2前端重写(基于FIR)**,带来**编译提速、类型推断更准、插件生态更健壮**;同步推进KMP稳定化及上下文接收者、`guard`等语言实验特性。升级关键在**插件兼容性检查**与**隐式类型推断回归验证**。
24 0
|
3天前
|
安全 Java 编译器
第022篇 try-catch-finally 与 try-with-resources:资源释放正确姿势
本文用“场景—决策—踩坑—效果”四步法,讲透try-catch-finally与try-with-resources的工程实践。重点解析finally中return吞异常、手写关闭漏资源、twr如何保留主异常并挂suppressed等高频面试坑点,附可运行代码对比,助你面试答出深度与记忆点。
21 0
|
1天前
|
JSON API 定位技术
第136篇View 绘制流程:measure、layout、draw 三部曲
Android View绘制核心:一次完整流程含measure(定大小)、layout(定位置)、draw(画像素)三关,由ViewRootImpl驱动、Choreographer对齐VSync。关键判定:requestLayout走全流程;invalidate仅重绘;postInvalidate支持子线程。避坑要点:onLayout勿擅自改尺寸、善用clipChildren、数据层去重防无效刷新。
22 0
|
2天前
|
安全 Java 编译器
第097篇 Kotlin 与 Java 互操作:JvmStatic、JvmOverloads 与平台类型
本文详解 Kotlin 与 Java 混编的四大底层规则:空安全在 Java 侧失效、默认参数需 `@JvmOverloads` 才生成重载、属性/对象成员编译为 `getXxx()` 或 `INSTANCE`、顶层声明落入文件类。涵盖 `@JvmStatic`、`@JvmField`、`@JvmSynthetic`、`fun interface` 等关键注解的原理与避坑实践,助你打通真实工程与面试高频考点。
15 0
|
3天前
|
缓存 安全 Java
第019篇 Object 通用方法:equals、hashCode 与 clone 契约
Android面试高频题:Object通用方法(equals/hashCode/clone等)是判断候选人“用过”还是“懂原理”的试金石。核心在于——equals相等则hashCode必相等,否则HashMap/HashSet将失效;clone默认浅拷贝,可变字段需手动深拷贝;toString虽小,却是日志排查关键。重写必讲场景、取舍与代价。
26 0
|
3天前
|
算法 Java 编译器
第015篇 抽象类与接口:到底该怎么选才不丢分
本文深入剖析抽象类与接口的本质差异:抽象类聚焦“is-a”复用(带状态、构造器、模板方法),接口强调“can-do”契约(多实现、default/private方法演进)。直击常考误区——常量接口滥用、default方法状态依赖、抽象类this逃逸,并结合Android实战(BaseActivity、OnClickListener)与Kotlin新特性,讲清原理、场景与避坑方案。
37 0
|
2天前
|
Java 编译器 API
第080篇 Lambda 语法精讲:it、尾随 lambda 与隐式接收者
Kotlin Lambda面试核心在四个易错点:尾随vs括号传参(仅限最后一个参数)、单参隐式`it`与嵌套遮蔽、`return`非局部返回(仅inline函数允许)、块式Lambda返回类型由末行推断。具名参数+inline标注+显式label是避坑关键。
17 0
|
3天前
|
缓存 安全 Java
第029篇 HashMap 底层原理:数组、链表与红黑树的演进
HashMap底层是“数组+链表+红黑树”复合结构:键经扰动哈希后,用(n-1)&hash定位桶;链表≥8且容量≥64时树化;负载因子0.75平衡时空开销;线程不安全,自定义key须重写equals与hashCode。
26 0
|
3天前
|
IDE Java 编译器
第036篇 注解与元注解:Override 背后的机制
注解是附着于程序元素的结构化元数据,本身不执行逻辑,其作用完全取决于`@Retention`(生命周期)与`@Target`(作用位置)。`RUNTIME`级可反射读取,`CLASS`级仅存于字节码,`SOURCE`级编译即弃。元注解如`@Repeatable`(需容器)、`@Inherited`(仅类继承链生效)常被误用。编译期APT处理(如Room、Dagger)比运行时反射更高效。关键:显式声明Retention,勿信默认值;接口注解不被实现类继承;注解仅为意图声明,非功能保证。
27 0

热门文章

最新文章