作用域函数
Kotlin 的标准库函数 let、apply、with、run、also 被统称为 作用域函数 (Scope Functions)。
以下是从原理、区别到最佳实践的深度解析。
1. 核心原理:为什么会有这些函数?
这五个函数的核心目的是:在一个对象的上下文中执行代码块。
它们的底层实现依赖于两个 Kotlin 关键特性:
内联函数 (inline) :编译时代码直接插入调用处,零运行时开销(不会创建额外的 Function 对象)。
带接收者的 Lambda (T.() -> R):允许在 Lambda 内部直接访问对象的 public 成员(即 this),而无需使用对象名。
源码一瞥(以 apply 为例):
// public inline fun T.apply(block: T.() -> Unit): T
public inline fun T.apply(block: T.() -> Unit): T {
block() // 调用 Lambda,此时 this 指向调用者 T
return this // 返回调用者本身
}
2. 五大函数全图谱 (决策矩阵)
区分它们只需要关注两个维度:
-
上下文对象怎么引用? (this 还是 it)
-
返回值是什么? (上下文对象本身 Context Object 还是 Lambda 的执行结果 Lambda Result)
|-----------|----------|-----------|------------|-----------------------------|
| 函数 | 对象引用 | 返回值 | 是否扩展函数 | 核心语义 (Mental Model) |
| let | it | Lambda 结果 | 是 | 处理并转换 (如果不为 null,则做...) |
| apply | this | 对象本身 | 是 | 配置 (对这个对象进行初始化配置...) |
| with | this | Lambda 结果 | 否 | 分组 (用这个对象做一系列操作...) |
| run | this | Lambda 结果 | 是 | 计算 (配置并计算出一个新值...) |
| also | it | 对象本身 | 是 | 附加操作 (做完这事,顺便做...) |
3. 深度场景解析与最佳实践
3.1 apply ------ 对象配置专家
场景:对象初始化、构建 Intent、配置 View。
特点:返回对象本身,适合链式调用。
代码:
// Android 经典场景
val intent = Intent(this, TargetActivity::class.java).apply {
putExtra("param", "value")
action = "ACTION_VIEW"
}
// 动态添加 View
parentView.addView(TextView(context).apply {
text = "Hello"
textSize = 14f
setOnClickListener { /*...*/ }
})
3.2 let ------ 判空与转换利器
场景:配合 ?. 进行非空执行,或者引入局部变量作用域。
特点:引用是 it(避免 this 冲突),返回最后一行。
代码:
Kotlin
// 1. 替代 if (obj != null)
mUser?.let { user -> // 建议重命名 it 以增加可读性
updateUI(user)
}
// 2. 转换作用域
val str: String = 123.let { "The number is $it" }
3.3 with ------ 批量操作符
场景 :由于它不是扩展函数,它适合对一个已经存在的对象进行一连串的方法调用,尤其是 ViewBinding。
代码:
Kotlin
// 它是 fun with(receiver: T, block: T.() -> R): R
with(binding) {
tvTitle.text = "Title"
btnSubmit.setOnClickListener { }
ivAvatar.load(url)
}
3.4 also ------ 链式调用中的"副作用"
场景:不想打断链式调用,但需要做一些额外的、不影响流程的事情(如打印日志、缓存数据)。
特点:返回对象本身,引用是 it。
代码:
Kotlin
// 创建目录,如果成功创建则打印日志,最后返回 file 对象
val file = File("xxx").also {
if (it.mkdirs()) {
Log.d("File", "Directory created: ${it.absolutePath}")
}
}
面试考点:apply 和 also 的区别?
-
apply 用 this,适合设置属性。
-
also 用 it,适合将对象作为参数传给外部函数(如 Log),或者在 this 被遮蔽时使用。
3.5 run ------ 复杂的初始化块
场景:run 是 with 和 let 的结合体。它是扩展函数(像 let),但用 this(像 with)。通常用于**"配置一个对象并计算出结果"**。
代码:
Kotlin
// 假设 StringBuilder 配置完后,我只需要它的 toString 结果
val result = StringBuilder().run {
append("Start")
append("End")
toString() // 返回值是 String,而不是 StringBuilder
}
4. 常见陷阱 (Senior 必知)
4.1 this 的遮蔽 (Shadowing)
在 apply, run, with 中,this 是隐式的。如果外层类也有同名变量,容易搞混。
Kotlin
class Activity {
val context = this
fun setup() {
val view = TextView(this)
view.apply {
// 这里的 context 是 View 的 context 还是 Activity 的?
// 答案:是 View 的 context (this.context)
text = "Hello"
}
}
}
建议 :如果代码块内对 this 的引用有歧义,或者需要将对象作为参数传递,优先使用 let 或 also (使用 it)。
4.2 嵌套地狱
不要过度嵌套作用域函数,否则可读性会呈指数级下降。
Kotlin
// BAD
obj?.let { it1 ->
it1.apply {
run {
// 这里的 this 到底是谁?it1 是谁?完全乱套
}
}
}
5. 总结速查表 (Cheat Sheet)
|--------------------------------------|-------------------|
| 我想做什么? | 推荐函数 |
| 对象非空检查 (if not null) | obj?.let { ... } |
| 对象初始化/配置 (Setting properties) | obj.apply { ... } |
| 配置对象并做副作用 (Logging/Caching) | obj.also { ... } |
| 对对象的一组成员进行操作 (Grouping calls) | with(obj) { ... } |
| 计算代码块结果 (Configuration + Return) | obj.run { ... } |
面试题
以下是按照难度分级的常见面试题及深度解析。
第一阶段:原理与机制 (Internals)
Q1: 作用域函数会不会带来性能损耗?比如创建额外的对象?
-
回答 :不会。
-
深度解析:
-
-
所有的作用域函数(let, run, with, apply, also)在 Kotlin 标准库中都被标记为 inline。
-
编译期行为 :编译器会将 Lambda 内部的代码逻辑直接拷贝到调用处。
-
结果:运行时不会创建 Function 对象(避免了匿名内部类的开销),也没有额外的栈帧调用开销。它们在字节码层面等同于普通的过程式代码。
-
Q2: 既然 with 和 run 功能非常相似(都使用 this,都返回 Lambda 结果),为什么需要两个?
-
回答 :它们的调用方式 和适用场景不同。
-
深度解析:
-
-
run 是扩展函数 (T.run):
-
- 支持安全调用 (?.run { ... }),非常适合处理"如果非空则执行并计算结果"的链式调用。
-
with 是普通函数 (with(T)):
-
-
不支持安全调用(不能写 ?.with)。
-
它在语义上更像是一个"语句块"或"分组操作",强调"对于这个对象,执行以下一堆操作"。
-
-
Code Review 建议:如果对象可能为 null,必用 run;如果对象确定的,用 with 阅读感稍好(但在链式调用头部通常用 run 或 apply 更顺手,with 逐渐在减少使用)。
-
第二阶段:实战与避坑 (Best Practices)
Q3: apply 和 also 都返回对象本身,开发中怎么抉择?(高频)
-
回答 :看你是要修改它 ,还是使用它。
-
深度解析:
-
-
apply (Context: this) :语义是 "Apply these settings"。用于初始化 或修改对象的属性。因为用 this,可以直接调用成员方法/属性。
-
- 例:fragment.apply { arguments = bundle }
-
also (Context: it) :语义是 "And also do this"。用于附加操作(Side-effects),通常不修改对象内部状态,而是利用该对象去做别的事(打印日志、存入缓存、notify)。
-
- 例:val file = makeFile().also { Log.d("TAG", "Created ${it.name}") }
-
杀手级区别 :当发生 this 遮蔽(Shadowing) 时,或者你需要把当前对象作为参数传给另一个函数时,also (用 it) 比 apply (用 this) 更安全、更清晰。
-
Q4: 请看下面的代码,指出潜在的问题(坑):
Kotlin
class MainActivity : AppCompatActivity() {
private var id: String = "ActivityID"
fun setupView() {
val view = View(this)
view.apply {
// 问题:这行代码实际上在给谁赋值?
id = "ViewID"
}
}
}
-
回答 :这是典型的 this 引用歧义。
-
解析:
-
-
View 也有一个 setId(int) 方法(虽然参数类型不同,但在属性赋值时容易混淆)。即便参数类型对不上,这种代码可读性极差。
-
如果是 TextView 的 text 属性,或者 context 属性,就更容易出错。在 apply 块内部,this 指向 View。如果 View 没有该属性,才会去查找外部类(Activity)的属性。
-
修正 :如果遇到重名属性,或者需要明确引用外部类,应使用 this@MainActivity.id。或者改用 also { it.id = ... } 来明确区分。
-
Q5: 为什么不推荐嵌套使用作用域函数?
-
回答:会导致 this 和 it 的语义混乱,极大地降低代码可读性。
-
解决:尽量展平调用,或者使用具体的参数名代替 it(如 user?.let { user -> ... })。
第三阶段:代码阅读题 (Code Prediction)
Q6: 下面这段代码的输出是什么?
Kotlin
val str = "Hello"
val result = str.run {
this + " World"
}.let {
it.length
}
println(result)
-
答案:11
-
解析:
-
-
str.run { ... }:this 是 "Hello"。"Hello" + " World" 返回 "Hello World"。run 返回 Lambda 结果(String)。
-
.let { ... }:it 是 "Hello World"。it.length 是 11。let 返回 Lambda 结果(Int)。
-
result 类型为 Int,值为 11。
-
Q7: takeIf 和 takeUnless 是作用域函数吗?配合作用域函数怎么用?
-
回答 :它们不是标准的作用域函数,但是是过滤函数,通常与作用域函数结合使用。
-
用法:
-
-
takeIf { predicate }:如果满足条件返回对象本身,否则返回 null。
-
经典面试场景:如何优雅地读取文件?
-
Kotlin
// 只有当文件存在且可读时,才读取内容
val content = File("config.txt")
.takeIf { it.exists() && it.canRead() }
?.readText()