作用域函数
作用域函数 创建了一个临时作用域,您可以在其中访问对象而无需使用其名称。
作用域函数存在的目的仅在于使您的代码更简洁和可读。它们不提供额外的功能。
有五个作用域函数:let()、run()、with()、apply() 和 also()。它们设计用于与 lambda 一起工作,不需要 import。它们在访问 上下文对象 方面存在差异,使用 it 或 this,以及它们返回的内容。with() 使用与其他作用域函数不同的调用语法。下面您可以看到它们之间的区别:
// ScopeFunctions/Differences.kt
package scopefunctions
import atomictest.eq
data class Tag(var n: Int = 0) {
var s: String = ""
fun increment() = ++n
}
fun main() {
// let(): 使用 'it' 访问对象
// 返回 lambda 中的最后一个表达式的结果
Tag(1).let {
it.s = "let: ${it.n}"
it.increment()
} eq 2
// 带有命名 lambda 参数的 let():
Tag(2).let { tag ->
tag.s = "let: ${tag.n}"
tag.increment()
} eq 3
// run(): 使用 'this' 访问对象
// 返回 lambda 中的最后一个表达式的结果
Tag(3).run {
s = "run: $n" // 隐式 'this'
increment() // 隐式 'this'
} eq 4
// with(): 使用 'this' 访问对象
// 返回 lambda 中的最后一个表达式的结果
with(Tag(4)) {
s = "with: $n"
increment()
} eq 5
// apply(): 使用 'this' 访问对象
// 返回修改后的对象
Tag(5).apply {
s = "apply: $n"
increment()
} eq "Tag(n=6)"
// also(): 使用 'it' 访问对象
// 返回修改后的对象
Tag(6).also {
it.s = "also: ${it.n}"
it.increment()
} eq "Tag(n=7)"
// 带有命名 lambda 参数的 also():
Tag(7).also { tag ->
tag.s = "also: ${tag.n}"
tag.increment()
} eq "Tag(n=8)"
}
有多个作用域函数是因为它们满足不同的需求组合:
- 使用
this访问上下文对象的作用域函数(run()、with()和apply())在其作用域块中产生最干净的语法。 - 使用
it访问上下文对象的作用域函数(let()和also())允许您提供命名的 lambda 参数。 - 返回其 lambda 中的最后一个表达式的作用域函数(
let()、run()和with())用于创建结果。 - 返回修改后的上下文对象的作用域函数(
apply()和also())用于将表达式链接在一起。
run() 是一个常规函数,而 with() 是一个扩展函数;除此之外,它们是相同的。对于调用链和接收者可为空的情况,优先选择 run()。
下面是作用域函数特性的总结:
this 上下文 | it 上下文 | |
|---|---|---|
| 生成最后一个表达式的结果 | with、run | let |
| 生成接收者 | apply | also |
您可以使用 安全访问运算符 ?. 将作用域函数应用于可空接收者,只有在接收者不为 null 时才会调用作用域函数:
// ScopeFunctions/AndNullability.kt
package scopefunctions
import atomictest.eq
import kotlin.random.Random
fun gets(): String? =
if (Random.nextBoolean()) "str!" else null
fun main() {
gets()?.let {
it.removeSuffix("!") + it.length
}?.eq("str4")
}
在 main() 中,如果 gets() 产生非空结果,则会调用 let。let 的非空接收者变为 lambda 内部的非空 it。
将安全访问运算符应用于上下文对象会对整个作用域进行 null 检查,如下所示的 [1]-[4]。否则,在作用域内的每个调用都必须单独进行 null 检查:
// ScopeFunctions/Gnome.kt
package scopefunctions
class Gnome(val name: String) {
fun who() = "Gnome: $name"
}
fun whatGnome(gnome: Gnome?) {
gnome?.let { it.who() } // [1]
gnome.let { it?.who() }
gnome?.run { who() } // [2]
gnome.run { this?.who() }
gnome?.apply { who() } // [3]
gnome.apply { this?.who() }
gnome?.also { it.who() } // [4]
gnome.also { it?.who() }
// 对于 nullability 没有帮助:
with(gnome) { this?.who() }
}
当在 let()、run()、apply() 或 also() 上使用安全访问运算符时,如果上下文对象为 null,则整个作用域都将被忽略:
// ScopeFunctions/NullGnome.kt
package scopefunctions
import atomictest.*
fun whichGnome(gnome: Gnome?) {
trace(gnome?.name)
gnome?.let { trace(it.who()) }
gnome?.run { trace(who()) }
gnome?.apply { trace(who()) }
gnome?.also { trace(it.who()) }
}
fun main() {
whichGnome(Gnome("Bob"))
whichGnome(null)
trace eq """
Bob
Gnome: Bob
Gnome: Bob
Gnome: Bob
Gnome: Bob
null
"""
}
trace 显示,当 whichGnome() 接收到 null 参数时,没有作用域函数会执行。
尝试从 Map 中检索对象会产生可空的结果,因为没有保证它会找到该键的条目。在下面的示例中,我们展示了不同作用域函数应用于 Map 查找结果的情况:
// ScopeFunctions/MapLookup.kt
package scopefunctions
import atomictest.*
data class Plumbus(var id: Int)
fun display(map: Map<String, Plumbus>) {
trace("displaying $map")
val pb1: Plumbus = map["main"]?.let {
it.id += 10
it
} ?: return
trace(pb1)
val pb2: Plumbus? = map["main"]?.run {
id += 9
this
}
trace(pb2)
val pb3: Plumbus? = map["main"]?.apply {
id += 8
}
trace(pb3)
val pb4: Plumbus? = map["main"]?.also {
it.id += 7
}
trace(pb4)
}
fun main() {
display(mapOf("main" to Plumbus(1)))
display(mapOf("none" to Plumbus(2)))
trace eq """
displaying {main=Plumbus(id=1)}
Plumbus(id=11)
Plumbus(id=20)
Plumbus(id=28)
Plumbus(id=35)
displaying {none=Plumbus(id=2)}
"""
}
尽管 with() 可以在此示例中强制使用,但结果太难看,难以考虑。
在 trace 中,您可以看到在第一次调用 display() 时创建了每个 Plumbus 对象,但在第二次调用中没有创建任何对象。查看 pb1 的定义并回想起 Elvis 操作符。如果 ?: 左侧的表达式不为 null,则它变为结果并赋值给 pb1。但是,如果该表达式为 null,则 ?: 的右侧变为结果,即 return,因此在完成初始化 pb1 之前,display() 返回,因此 pb1-pb4 的任何值都不会创建。
在可链式调用中,作用域函数可以与可空类型一起使用:
// ScopeFunctions/NameTag.kt
package scopefunctions
import atomictest.trace
val functions = listOf(
fun(name: String?) {
name
?.takeUnless { it.isBlank() }
?.let { trace("$it in let") }
},
fun(name: String?) {
name
?.takeUnless { it.isBlank() }
?.run { trace("$this in run") }
},
fun(name: String?) {
name
?.takeUnless { it.isBlank() }
?.apply { trace("$this in apply") }
},
fun(name: String?) {
name
?.takeUnless { it.isBlank() }
?.also { trace("$it in also") }
},
)
fun main() {
functions.forEach { it(null) }
functions.forEach { it(" ") }
functions.forEach { it("Yumyulack") }
trace eq """
Yumyulack in let
Yumyulack in run
Yumyulack in apply
Yumyulack in also
"""
}
functions 是一个函数引用的 List,由 main() 中的 forEach 调用应用,使用 it 与函数调用语法。functions 中的每个函数都使用不同的作用域函数。对于 null 或空白输入,forEach 调用 it(null) 和 it(" ") 会被忽略。
在嵌套作用域函数时,在给定上下文中可能会有多个 this 或 it 对象可用。有时很难知道选择了哪个对象:
// ScopeFunctions/Nesting.kt
package scopefunctions
import atomictest.eq
fun nesting(s: String, i: Int): String =
with(s) {
with(i) {
toString()
}
} +
s.let {
i.let {
it.toString()
}
} +
s.run {
i.run {
toString()
}
} +
s.apply {
i.apply {
toString()
}
} +
s.also {
i.also {
it.toString()
}
}
fun main() {
nesting("X", 7) eq "777XX"
}
在所有情况下,对 toString() 的调用都是应用于 Int,因为最近的隐式 this 或 it 是 Int。apply() 和 also() 返回修改后的对象 s,而不是计算结果。由于作用域函数旨在提高可读性,因此嵌套作用域函数是一个值得怀疑的做法。
没有作用域函数提供类似于 use() 的 资源清理 功能:
// ScopeFunctions/Blob.kt
package scopefunctions
import atomictest.*
data class Blob(val id: Int) : AutoCloseable {
override fun toString() = "Blob($id)"
fun show() { trace("$this")}
override fun close() = trace("Close $this")
}
fun main() {
Blob(1).let { it.show() }
Blob(2).run { show() }
with(Blob(3)) { show() }
Blob(4).apply { show() }
Blob(5).also { it.show() }
Blob(6).use { it.show() }
Blob(7).use { it.run { show() } }
Blob(8).apply { show() }.also { it.close() }
Blob(9).also { it.show() }.apply { close() }
Blob(10).apply { show() }.use { }
trace eq """
Blob(1)
Blob(
2)
Blob(3)
Blob(4)
Blob(5)
Blob(6)
Close Blob(6)
Blob(7)
Close Blob(7)
Blob(8)
Close Blob(8)
Blob(9)
Close Blob(9)
Blob(10)
Close Blob(10)
"""
}
尽管 use() 在视觉上与 let() 和 also() 相似,但 use() 不允许从其 lambda 中返回任何内容。这防止了表达式链接或生成结果。
没有 use(),对于任何作用域函数,close() 都不会被调用。要使用作用域函数并保证清理,将作用域函数置于 use() lambda 内部,如 Blob(7) 所示。Blob(8) 和 Blob(9) 展示了如何显式调用 close(),以及如何交替使用 apply() 和 also()。
Blob(10) 使用 apply(),结果传递给 use(),在其 lambda 结束时调用 close()。
作用域函数是内联的
通常,将 lambda 作为参数传递会将 lambda 代码存储在辅助对象中,与常规函数调用相比,会增加一些运行时开销,但考虑到 lambda 的好处(可读性和代码结构),这种开销通常不是一个问题。此外,JVM 中包含许多优化,通常可以弥补开销。
不管开销有多小,只要有运行时开销,无论多么小,都会产生“谨慎使用某个功能”的建议。通过将作用域函数定义为 inline,可以消除所有的运行时开销。这样,就可以毫不犹豫地使用作用域函数。
当编译器看到 inline 函数调用时,它将函数体替换为函数调用,将所有参数替换为实际参数。
内联对于小型函数效果很好,其中函数调用的开销可能是整个调用的重要部分。随着函数变得越来越大,与整个调用所需的时间相比,调用的成本会缩小,从而降低了内联的价值。与此同时,生成的字节码会增加,因为在每个调用点都插入了整个函数体。
当内联函数接受一个 lambda 参数时,编译器将 lambda 体与函数体一起内联。因此,在将 lambda 直接调用或传递给另一个 inline 函数时,不会创建其他类或对象来传递 lambda。 (这仅在直接调用 lambda 或传递给另一个 inline 函数时才有效)。
尽管可以将其应用于任何函数,但 inline 用于内联 lambda 体或创建 具体化泛型 是有意义的。您可以在 这里 找到有关内联函数的更多信息。
练习和解答可在 www.AtomicKotlin.com 找到。