什么是作用域函数(Scope Functions)?
Kotlin 标准库包含了几个特殊的函数,其目的是在调用对象的上下文环境(context)中执行代码块。当你在提供了 lambda 表达式的对象上调用此类函数时,它会形成一个临时作用域。在此作用域内,你可以在不使用其名称的情况下访问该对象,这些函数被称为作用域函数。在 Kotlin 中,作用域函数总共有五个,分别是:let
、run
、with
、apply
、also
。接下来我们逐个详细分析。
开始分析之前,你可能需要简单了解下它大概长什么样,下面是个简单示例
data class Person(var name:String){
fun say(words:String){
println("$name says $words")
}
}
fun main() {
Person("skyrin").let{
it.say("hello")
println(it)
}
}
如果不使用 let
的话,你需要先创建出对象,然后再执行调用
val person = Person("skyrin")
person.say("hello")
println(person)
所以,作用域函数的目的就是尽可能的让你的代码变得更简洁更具可读性,尽可能少的创建对象,仅此而已。
由于这 5 个作用域函数的性质有些相似,所以大家可能经常不知道在哪种情况下该使用哪个函数,以至于最终放弃使用作用域函数,所以为了避免类似悲剧发生,我们首先来讨论一下他们之间的区别以及使用场景。
区别
由于作用域函数本质上非常相似,因此理解它们之间的差异非常重要。每个作用域函数有两个主要区别:
- 引用上下文对象的方式
- 返回值
区别1:上下文对象(Context)是 this 还是 it
this
run
、with
和 apply
通过 this
关键字引用一个 context 对象作为 lambda 接收者。于是,在他们的 lambda 中,this 对象可用于普通类函数中。大多数情况下,在访问接收者的成员时,可以省略 this
关键字,让代码保持简洁。另一方面,如果省略了 this
,你就很难区分你操作的函数或变量是外部对象的还是接收者的了,所以,context 对象作为一个接收者(this)这种方式推荐用于调用接收者(this) 的成员变量或函数。示例如下
data class Person(var name: String,var age: Int = 0,var city: String = "")
fun main() {
val person = Person("Skyrin").apply {
age = 18 // 等价于 this.age = 18 或闭包外部的 person.age = 18
city = "Beijing"
}
// 如上写法可替代如下写法
// person.age = 18
// person.city = "Beijing"
println(person)
}
it
let
、also
有一个作为 lambda 参数传入的 context 对象,如果不指定参数名,则可以通过该 context 对象的隐式默认名称 it
来访问它,it
比 this
看上去更简洁,用于表达式中也会使代码更加清晰易读。但是,当你访问 context 对象的函数或者属性时,不能像 apply
那样省略 this
,因此,当 context 对象主要用作参数被其他函数调用时,用 it
更好一些。
import kotlin.random.Random
fun writeToLog(message: String) {
println("INFO: $message")
}
fun getRandomInt(): Int {
return Random.nextInt(100).also {
writeToLog("getRandomInt() generated value $it")
}
}
fun main() {
val i = getRandomInt()
}
你也可以为 context 对象指定任意参数名
import kotlin.random.Random
fun writeToLog(message: String) {
println("INFO: $message")
}
fun getRandomInt(): Int {
return Random.nextInt(100).also { value -> // use value replace it
writeToLog("getRandomInt() generated value $value")
}
}
fun main() {
val i = getRandomInt()
}
区别2:返回值是 Context 对象还是 Lambda 的结果
作用域函数的返回值不同:
-
applay
和also
返回 context 对象 -
let
、run
、with
返回闭包的运算结果
返回 Context 对象
applay
和 also
返回 context 对象,因此,它们可以结合起来进行链式调用
fun main() {
val memberList = mutableListOf<Int>()
memberList.also {
println("填充 $it")
}.apply {
add(35)
add(98)
add(1)
add(18)
}.also {
println("排序并打印 $it")
}.also {
it.sort()
println(it)
}
}
也可以在 return 语句中使用,将 context 对象作为函数的返回值
import kotlin.random.Random
fun main() {
fun getRandomInt(): Int {
return Random.nextInt(100).also { value ->
writeToLog("getRandomInt() generated value $value")
}
}
val i = getRandomInt()
}
fun writeToLog(message: String) {
println("INFO: $message")
}
返回 Lambda 闭包结果
let
、run
、with
返回 lambda 闭包结果。所以,你可以将其执行结果赋值给任意变量
fun main() {
val numbers = mutableListOf(1, 3, 5, 6, 7, 9)
val biggerThan6 = numbers.run {
add(10)
add(12)
filter { it > 6 }
}
println("The result of bigger than 6 is $biggerThan6")
}
此外,你可以忽略返回值,使用 with
作用域函数来为变量创建一个临时作用域
fun main() {
val numbers = mutableListOf(1, 3, 5, 6, 7, 9)
with(numbers){
val first = first()
val last = last()
println("first item is $first and last item is $last")
}
}
使用场景
下面介绍如何适当的选择作用域函数,从技术上来说,它们的功能在很多情况下都是可以互相转换的,所以下面的例子只是展示了一种通用做法,具体选择还是要看你的业务场景更适合哪种情况。
let
context 对象作为闭包参数(it)传入,返回值是闭包结果。
let
可用于在调用链的结果上调用一个或多个函数。例如,以下代码打印集合上的两个操作的结果
fun main() {
val numbers = mutableListOf("one", "two", "three", "four", "five")
val resultList = numbers.map { it.length }.filter { it > 3 }
println(resultList)
}
使用 let
可以重写为
fun main() {
val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3}.let {
println(it)
// 执行更多方法调用
}
}
如果闭包模块只有一个函数将 context 作为参数传入,你可以使用(::)替换 lambda
fun main() {
val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3}.let(::print)
}
let
也经常被用于执行闭包代码块中使用非空值的函数,要对非空对象执行操作,使用安全调用操作符 ?.
后跟 let
闭包,在此闭包中,原来的可空对象就可以被转换为非空对象执行操作
fun processNonNullString(str: String) {
println(str.length)
}
fun main() {
val str: String? = "Hello"
// processNonNullString(str) // 编译错误: str 为可空对象,要求参数为不可空对象
val length = str?.let {
println("let() called on $it")
processNonNullString(it) // 正常执行: 'it' 在 '?.let { }' 中为不可空对象
it.length
}
println("result for let is $length")
}
let
的另一种使用场景是引入局部变量,限制其作用域范围,以提高代码可读性。
fun main() {
val numbers = listOf("one", "two", "three", "four")
val modifiedFirstItem = numbers.first().let { firstItem ->
println("The first item of the list is '$firstItem'")
if (firstItem.length >= 5) firstItem else "!$firstItem!"
}.toUpperCase()
println("First item after modifications: '$modifiedFirstItem'")
}
with
非拓展函数。context 对象作为参数传递,但在 lambda 内部,它可用作接收器(this),返回值为 lambda 结果
官方建议是使用 context 对象调用函数而不提供 lambda 结果。在代码中,你可以简单的把 with
函数理解为 “使用此对象,执行以下操作”
fun main() {
val numbers = mutableListOf("one", "two", "three")
with(numbers) { // 使用 numbers 对象,执行 {} 中的操作
println("'with' is called with argument $this")
println("It contains $size elements")
}
}
with
的另一个用例是引入一个辅助对象,我们可以方便的使用此对象的属性或函数来计算值
fun main() {
val numbers = mutableListOf("one", "two", "three")
val firstAndLast = with(numbers) {
"The first element is ${first()}," +
" the last element is ${last()}"
}
println(firstAndLast)
}
run
context 对象可用作接收器(this),返回值为 lambda 结果
run
和 with
的作用类似,但是调用方法和 let
一样 —— 作为 context 对象的拓展函数
当你的 lambda 同时包含了对象初始化和返回值计算时,run
函数非常适合
lass MultiportService(var url: String, var port: Int) {
fun prepareRequest(): String = "Default request"
fun query(request: String): String = "Result for query '$request'"
}
fun main() {
val service = MultiportService("https://example.kotlinlang.org", 80)
val result = service.run {
port = 8080
query(prepareRequest() + " to port $port")
}
// 同样的代码使用 let() 函数重写:
val letResult = service.let {
it.port = 8080
it.query(it.prepareRequest() + " to port ${it.port}")
}
println(result)
println(letResult)
}
除了在接收器对象上调用run之外,还可以将其用作非扩展函数。非扩展 run
允许你执行需要表达式的多个语句块。
fun main() {
val hexNumberRegex = run {
val digits = "0-9"
val hexDigits = "A-Fa-f"
val sign = "+-"
Regex("[$sign]?[$digits$hexDigits]+")
}
for (match in hexNumberRegex.findAll("+1234 -FFFF not-a-number")) {
println(match.value)
}
}
apply
context 对象可用作接收器(this),返回调用者本身
使用apply不会返回代码块的值,主要对接收器对象的成员进行操作。 apply的常见用法是对象配置。此类调用可以看作“将以下赋值应用于对象”。
data class Person(var name: String,var age: Int = 0,var city: String = "")
fun main() {
val person = Person("Skyrin").apply {
age = 18
city = "Beijing"
}
}
将接收器作为返回值,你可以轻松进行链式调用以处理更复杂的操作。
also
context 对象作为参数传入,返回调用者本身
also
适用于执行将 context 对象作为参数进行的一些操作。还可用于不更改对象的其他操作,例如记录或打印调试信息。通常,你可以在不破坏程序逻辑的情况下从调用链中删除 also
调用。
fun main() {
val numbers = mutableListOf("one", "two", "three")
numbers
.also { println("The list elements before adding new one: $it") }
.add("four")
}
函数选择
以下是它们之间的差异表,以帮助你选择合适的作用域函数
函数 | 对象引用 | 返回值 | 扩展函数 |
---|---|---|---|
let | it | lambda 结果 | 是 |
run | this | lambda 结果 | 是 |
run | - | lambda 结果 | 否:无 context 对象 |
with | this | lambda 结果 | 否:将 context 对象作为参数 |
apply | this | 调用者本身(context) | 是 |
also | it | 调用者本身(context) | 是 |
以下是根据预期目的选择范围功能的简短指南:
- 在非 null 对象上执行 lambda:let
- 将表达式作为局部范围中的变量引入:let
- 对象配置:apply
- 对象配置并计算结果:run
- 运行需要表达式的语句:非扩展 run
- 附加效果:also
- 对函数进行分组调用:with
takeIf 和 takeUnless
除了作用域函数之外,标准库还包含函数 takeIf 和 takeUnless。这些函数允许你在调用链中嵌入对象状态的检查。
这两个函数的作用是对象过滤器,takeIf
返回满足条件的对象或 null。takeUnless
则刚好相反,它返回不满足条件的对象或 null。过滤条件位于函数的 {} 中。
import kotlin.random.*
fun main() {
val number = Random.nextInt(100)
val evenOrNull = number.takeIf { it % 2 == 0 }
val oddOrNull = number.takeUnless { it % 2 == 0 }
println("偶数: $evenOrNull, 奇数: $oddOrNull")
}
在 takeIf 和 takeUnless 之后链接其他函数时,不要忘记执行空检查或安全调用(?.),因为它们的返回值是可空的。
fun main() {
val str = "Hello"
val caps = str.takeIf { it.isNotEmpty() }?.toUpperCase()
//val caps = str.takeIf { it.isNotEmpty() }.toUpperCase() // 编译出错
println(caps)
}
takeIf 和 takeUnless 与作用域函数一起使用特别有用。一个很好的例子是使用 let 来链接它们,以便在与给定条件匹配的对象上运行代码块。
fun main() {
fun displaySubstringPosition(input: String, sub: String) {
input.indexOf(sub).takeIf { it >= 0 }?.let {
println("The substring $sub is found in $input.")
println("Its start position is $it.")
}
}
displaySubstringPosition("010000011", "11")
displaySubstringPosition("010000011", "12")
}
总结
以上,就是所有作用域函数的功能及使用场景的介绍,你可能已经发现,这其中有几个函数的功能相似甚至重叠,有人甚至觉得有这个时间去弄明白它们,我早就用其它常规方式实现功能了,但有人就觉得这些函数非常简洁实用,用过就再也回不去了。我觉得这就是 Kotlin 的一种优点和缺点的体现,优点是它很灵活,灵活的不像 Native 语言,缺点是它太灵活了,太多的语法糖导致你容易忘记写这些代码要实现的目的,所以,虽然作用域函数是使代码更简洁的一种方法,但还是要避免过度使用它们。
Reference
https://kotlinlang.org/docs/reference/scope-functions.html