Kotlin作用域函数

什么是作用域函数(Scope Functions)

       Kotlin 标准库包含了几个特殊的函数,其目的是在调用对象的上下文环境(context)中执行代码块。当你在提供了 lambda 表达式的对象上调用此类函数时,它会形成一个临时作用域。在此作用域内,你可以在不使用其名称的情况下访问该对象,这些函数被称为作用域函数。在 Kotlin 中,作用域函数总共有五个,分别是:let(T.let)、run(run、T.run)、with、apply( T.apply)、also(T.also)。

       基本上,这些函数的作用是一样的:在一个对象上执行代码块。不同的是这个对象如何在块内可用,以及整个表达式的结果是什么。

       下面是个简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
data class Person(var name: String, var age: Int, var city: String) {
fun moveTo(newCity: String) { city = newCity }
fun incrementAge() { age++ }
}

fun main() {
Person("Alice", 20, "Amsterdam").let {
println(it)
it.moveTo("London")
it.incrementAge()
println(it)
}
}

       如果不使用 let 的话,你需要先创建出对象,然后再执行调用,并在每次使用它时重复其名称。

1
2
3
4
5
6
7
8
9
10
11
12
data class Person(var name: String, var age: Int, var city: String) {
fun moveTo(newCity: String) { city = newCity }
fun incrementAge() { age++ }
}

fun main() {
val alice = Person("Alice", 20, "Amsterdam")
println(alice)
alice.moveTo("London")
alice.incrementAge()
println(alice)
}

       作用域函数的目的就是尽可能的让你的代码变得更简洁更具可读性,尽可能少的创建对象,仅此而已。

       下面通过 run 函数再说明一下作用域:

1
2
3
4
5
6
7
8
9
fun test() {
var mood = "I am sad"

run {
val mood = "I am happy"
println(mood) // I am happy
}
println(mood) // I am sad
}

       在这个例子中,在test函数的内部有一个分隔开的作用域,在这个作用域内部,完全包含一个在输出之前的 mood 变量被重新定义并初始化为 I am happy 的操作实现。

       这个作用域函数本身似乎看起来不是很有用。但是它返回这个作用域内部的最后一个对象,以下的代码会变得更加简洁,我们可以将show()方法应用到两个View中,而不需要去调用两次show()方法。

1
2
3
run {
if (firstTimeView) introView else normalView
}.show()

作用域函数的三个属性特征

普通函数与扩展函数

       如果我们对比 with 和 T.run 这两个函数的话,它们实际上是十分相似的。下面是使用它们实现相同功能的例子:

1
2
3
4
5
6
7
8
9
with(webview.settings) {
javaScriptEnabled = true
databaseEnabled = true
}
// similarly
webview.settings.run {
javaScriptEnabled = true
databaseEnabled = true
}

       然后它们之间唯一不同在于 with 是一个普通函数,而 T.run是一个扩展函数。

       那么它们各自使用的优点是什么?

       想象一下如果 webview.settings 可能为空,那么下面两种方式实现如何去修改呢?

1
2
3
4
5
6
7
8
9
10
11
// 比较丑陋的实现方式
with(webview.settings) {
this?.javaScriptEnabled = true
this?.databaseEnabled = true
}
}
// 比较好的实现方式
webview.settings?.run {
javaScriptEnabled = true
databaseEnabled = true
}

       在这种情况下,显然 T.run 扩展函数更好,因为我们可以在使用它之前对可空性进行检查。

this 和 it 参数

       见下面区别一节。

返回 this 和 其它类型

       见下面区别一节。

区别

       由于作用域函数本质上非常相似,因此理解它们之间的差异非常重要。每个作用域函数有两个主要区别:

  • 引用上下文对象的方式
  • 返回值

上下文对象:this 还是 it

       在作用域函数的 lambda 中,上下文对象通过一个简短的引用而不是它的实际名称来提供。每个作用域函数使用两种方法中的一种来访问上下文对象:作为 lambda 接收器(this)或作为 lambda 参数(it)。两者都提供了相同的功能,下面描述它们各自在不同情况下的优缺点,并就它们的使用提供建议。

1
2
3
4
5
6
7
8
9
10
11
12
13
fun main() {
val str = "Hello"
// this
str.run {
println("The receiver string length: $length")
//println("The receiver string length: ${this.length}") // does the same
}

// it
str.let {
println("The receiver string's length is ${it.length}")
}
}

this

       run、with 和 apply 通过 this 关键字引用一个 context 对象作为 lambda 接收者。在它们的 lambda 中,this 对象可用于普通类函数中。大多数情况下,在访问接收者的成员时,可以省略 this 关键字,让代码保持简洁。另一方面,如果省略了 this ,就很难区分你操作的函数或变量是外部对象的还是接收者的了,所以,context 对象作为一个接收者(this)这种方式推荐用于调用接收者(this) 的成员变量或函数。示例如下:

1
2
3
4
5
6
7
8
data class Person(var name: String, var age: Int = 0, var city: String = "")

fun main() {
val adam = Person("Adam").apply {
age = 20 // same as this.age = 20 or adam.age = 20
city = "London"
}
}

it

       let、also 有一个作为 lambda 参数传入的 context 对象,如果不指定参数名,则可以通过该 context 对象的隐式默认名称 it 来访问它,it 比 this 看上去更简洁,用于表达式中也会使代码更加清晰易读。但是,当你访问 context 对象的函数或者属性时,不能像 apply 那样省略 this ,因此,当 context 对象主要用作参数被其他函数调用时,用 it 更好一些。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import kotlin.random.Random

fun writeToLog(message: String) {
println("INFO: $message")
}

fun main() {
fun getRandomInt(): Int {
return Random.nextInt(100).also {
writeToLog("getRandomInt() generated value $it")
}
}

val i = getRandomInt()
}

       你也可以为 context 对象指定任意参数名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import kotlin.random.Random

fun writeToLog(message: String) {
println("INFO: $message")
}

fun main() {
fun getRandomInt(): Int {
return Random.nextInt(100).also { value ->
writeToLog("getRandomInt() generated value $value")
}
}

val i = getRandomInt()
}

T.run 和 T.let 举例对比

       如果对比 T.run 和 T.let 两个函数也是非常的相似,唯一的区别在于它们接收的参数不一样。下面显示了两种功能的相同逻辑。

1
2
3
4
5
6
7
stringVariable?.run {
println("The length of this String is $length")
}
// Similarly.
stringVariable?.let {
println("The length of this String is ${it.length}")
}

       如果你查看过 T.run 函数声明,你就会注意到 T.run 仅仅只是被当做了 block:T.() 扩展函数的调用块。因此,在其作用域内,T 可以被 this 指代。在编码过程中,在大多数情况下 this 是可以被省略的。因此我们上面的示例中,我们可以在 println 语句中直接使用 $length 而不是 ${this.lenght}. 所以我把这个称之为传递 this 参数。

       然而对于 T.let 函数的声明,你将会注意到 T.let 是传递它自己本身到函数中 block:(T)。因此这个类似于传递一个 lambda 表达式作为参数。它可以在函数作用域内部使用 it 来指代。所以我把这个称之为传递 it 参数。

       你也可以理解为 T.let 是有闭包参数的,谁调用了 let ,参数就是那个对象,而Kotlin中,lambda 如果只有一个参数,该参数可以省略不写,用 it 替代;而 T.run 没有闭包参数,但它可以通过 this 来获取到谁调用了 run 函数,this 指代了外层调用 run 的对象。这是二者的唯一区别。从上面看,似乎 T.run 比 T.let 更加优越,因为它更隐含,但是 T.let 函数具有一些微妙的优势,如下所示:

  1. T.let 函数提供了一种更清晰的区分方式去使用给定的变量函数/成员与外部类函数/成员。
  2. 例如当 it 作为函数的参数传递时,this 不能被省略,并且 it 写起来比 this 更简洁,更清晰。
  3. T.let 允许更好地命名已使用变量,即可以将 it 转换为其它有含义名称,而 T.run 则不能,内部只能用 this 指代或者省略。

返回值:Context 对象还是 Lambda 结果

       作用域函数的返回值不同:

  • apply 和 also 返回 context 对象
  • let、run、with 返回闭包的运算结果

返回 Context 对象

       apply 和 also 返回 context 对象,因此,它们可以结合起来进行链式调用。

1
2
3
4
5
6
7
8
9
10
11
12
fun main() {
val numberList = mutableListOf<Double>()
numberList.also { println("Populating the list") }
.apply {
add(2.71)
add(3.14)
add(1.0)
}
.also { println("Sorting the list") }
.sort()
println(numberList)
}

       也可以在 return 语句中使用,将 context 对象作为函数的返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import kotlin.random.Random

fun writeToLog(message: String) {
println("INFO: $message")
}

fun main() {
fun getRandomInt(): Int {
return Random.nextInt(100).also {
writeToLog("getRandomInt() generated value $it")
}
}

val i = getRandomInt()
}

返回 Lambda 闭包结果

       let、run、with 返回 lambda 闭包结果。所以,你可以将其执行结果赋值给变量,链式操作上一个闭包返回的结果。

1
2
3
4
5
6
7
8
9
fun main() {
val numbers = mutableListOf("one", "two", "three")
val countEndsWithE = numbers.run {
add("four")
add("five")
count { it.endsWith("e") }
}
println("There are $countEndsWithE elements that end with e.")
}

       此外,你可以忽略返回值,使用 with 作用域函数来为变量创建一个临时作用域。

1
2
3
4
5
6
7
8
fun main() {
val numbers = mutableListOf("one", "two", "three")
with(numbers) {
val firstItem = first()
val lastItem = last()
println("First item: $firstItem, last item: $lastItem")
}
}

T.let 和 T.also 举例对比

       现在让我们看看 T.let 和 T.also ,如果看它的内部函数作用域,它们都是相同的。

1
2
3
4
5
6
7
stringVariable?.let {
println("The length of this String is ${it.length}")
}
// Exactly the same as below
stringVariable?.also {
println("The length of this String is ${it.length}")
}

       然而,二者微妙的不同在于它们的返回值,T.let 返回一个不同类型的值,而 T.also 返回T类型本身。

       这两个函数对于链式调用都很有用,其中 T.let 可以演变操作,而 T.also 则对相同的变量执行操作。

       简单的例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
val original = "abc"
// Evolve the value and send to the next chain
original.let {
println("The original String is $it") // "abc"
it.reversed() // evolve it as parameter to send to next let
}.let {
println("The reverse String is $it") // "cba"
it.length // can be evolve to other type
}.let {
println("The length of the String is $it") // 3
}
// Wrong
// Same value is sent in the chain (printed answer is wrong)
original.also {
println("The original String is $it") // "abc"
it.reversed() // even if we evolve it, it is useless
}.also {
println("The reverse String is ${it}") // "abc"
it.length // even if we evolve it, it is useless
}.also {
println("The length of the String is ${it}") // "abc"
}
// Corrected for also (i.e. manipulate as original string
// Same value is sent in the chain
original.also {
println("The original String is $it") // "abc"
}.also {
println("The reverse String is ${it.reversed()}") // "cba"
}.also {
println("The length of the String is ${it.length}") // 3
}

       T.also 似乎看上去没有意义,因为我们可以很容易地将它们组合成一个功能块。仔细思考,它有一些很好的优点。

  1. 它可以对相同的对象提供非常清晰的分离过程,即创建更小的函数部分。
  2. 在使用之前,它可以非常强大的进行自我操作,从而实现整个链式代码的构建操作。

       当两者结合在一起使用时,即一个自身演变,一个自我保留,它能使一些操作变得更加强大。

1
2
3
4
5
6
7
8
// Normal approach
fun makeDir(path: String): File {
val result = File(path)
result.mkdirs()
return result
}
// Improved approach
fun makeDir(path: String) = path.let{ File(it) }.also{ it.mkdirs() }

回顾所有属性特征

       通过回顾这3个属性特征,我们可以非常清楚函数的行为。下面说明 T.apply 函数,它的三个属性如下:

  1. 它是一个扩展函数
  2. 它是传递this作为参数
  3. 它是返回 this (即它自己本身)

       因此,可以想象下它可以被用作:

1
2
3
4
5
6
7
8
9
// Normal approach
fun createInstance(args: Bundle) : MyFragment {
val fragment = MyFragment()
fragment.arguments = args
return fragment
}
// Improved approach
fun createInstance(args: Bundle)
= MyFragment().apply { arguments = args }

       或者我们也可以让无链对象创建链式调用。

1
2
3
4
5
6
7
8
9
10
11
// Normal approach
fun createIntent(intentData: String, intentAction: String): Intent {
val intent = Intent()
intent.action = intentAction
intent.data=Uri.parse(intentData)
return intent
}
// Improved approach, chaining
fun createIntent(intentData: String, intentAction: String) =
Intent().apply { action = intentAction }
.apply { data = Uri.parse(intentData) }

使用场景详细说明

       下面介绍如何适当的选择作用域函数,从技术上来说,它们的功能在很多情况下都是可以互相转换的,所以下面的例子只是展示了一种通用做法,具体选择还是要看你的业务场景更适合哪种情况。

let

       context 对象作为闭包参数(it)传入,返回值是lambda闭包结果。

       let 可用于在调用链的结果上调用一个或多个函数。例如,以下代码打印集合上的两个操作的结果:

1
2
3
4
5
fun main() {
val numbers = mutableListOf("one", "two", "three", "four", "five")
val resultList = numbers.map { it.length }.filter { it > 3 }
println(resultList)
}

       使用 let 可以重写为:

1
2
3
4
5
6
7
fun main() {
val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let {
println(it)
// and more function calls if needed
}
}

       如果闭包模块只有一个函数将 context 作为参数传入,你可以使用(::)替换 lambda:

1
2
3
4
fun main() {
val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let(::println)
}

       let 也经常被用于执行闭包代码块中使用非空值的函数,要对非空对象执行操作,使用安全调用操作符 ?. 后跟 let 闭包,在此闭包中,原来的可空对象就可以被转换为非空对象执行操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
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
}
}

       let 可以与空合并操作符来一起使用,代替if-else,类似如下链式调用代码:

1
2
3
4
5
fun formatGreeting(guestName: String?): String {
return guestName?.let {
"Welcome, $it."
} ?: "What's your name?"
}

       使用 let 的另一种情况是引入局部变量,限制其作用域范围,以提高代码可读性。

1
2
3
4
5
6
7
8
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 对象作为闭包参数(it)传入,返回值是lambda闭包结果。

       非拓展函数。context 对象作为参数传递,但在 lambda 内部,它可用作接收器(this),返回值为 lambda 结果。

       官方建议是使用 context 对象调用函数而不提供 lambda 结果。在代码中,你可以简单的把 with 函数理解为 “使用此对象,执行以下操作”。

1
2
3
4
5
6
7
fun main() {
val numbers = mutableListOf("one", "two", "three")
with(numbers) {
println("'with' is called with argument $this")
println("It contains $size elements")
}
}

       调用同一个类的多个方法时,可以省去类名重复,直接调用类的方法即可,例如用于Android中Recyclerview的 onBinderViewholder,数据model的属性映射到UI上:

1
2
3
4
5
6
7
8
@Override
public void onBindViewHolder (ViewHolder holder , int position){
with (itemView){
shop_tv_edit.setOnClickListener(listener);
shop_tv_insert.setOnClickListener(listener);
shop_tv_show.setOnClickListener(listener);
shop_tv_hide.setOnClickListener(listener);
}

       with 的另一个用例是引入一个辅助对象,我们可以方便的使用此对象的属性或函数来计算值。

1
2
3
4
5
6
7
8
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 函数非常适合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class 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 允许你执行需要表达式的多个语句块。

1
2
3
4
5
6
7
8
9
10
11
12
13
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)
}
}

       run也能用来执行函数引用,如下代码来判断字符串是否过长:

1
2
3
4
5
6
7
fun main() {

val result = "The peoples Republic of China.".run(::isLong)
println(result)
}

fun isLong(name: String) = name.length >= 10

       返回值为true。当然,也可以按照如下写法:

1
isLong("The peoples Republic of China.")

       如果有多个函数,第一种写法则会体现出优势,看如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fun main() {

val result = "The peoples Republic of China."
.run(::isLong)
.run(::showMessage)
.run(::println)
}

fun isLong(name: String) = name.length >= 10

fun showMessage(isLong:Boolean):String{
return if(isLong){
"Name is too long."
}else{
"Please rename."
}
}

       结果如下:

1
Name is too long.

apply

       context 对象可用作接收器(this),返回调用者本身。

       使用apply不会返回值,主要对接收器对象的成员进行操作。 apply的常见情况是对象配置。此类调用可以理解为“将以下赋值应用于对象”。

1
2
3
4
5
6
7
8
data class Person(var name: String, var age: Int = 0, var city: String = "")

fun main() {
val adam = Person("Adam").apply {
age = 32
city = "London"
}
}

       将接收器作为返回值,你可以轻松进行链式调用以处理更复杂的操作。

also

       context 对象作为闭包参数(it)传入,返回调用者本身。

       also 适用于执行将 context 对象作为参数进行的一些操作。还可用于不更改对象的其他操作,例如记录或打印调试信息。通常,你可以在不破坏程序逻辑的情况下从调用链中删除 also 调用。

       在代码中看到“also”时,可以理解为“然后执行以下操作”。

1
2
3
4
5
6
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

       下图是作用域函数选用图
作用域函数选用图

       虽然作用域函数可以使代码更简洁,但是要避免过度使用它们:它会降低代码的可读性并导致错误。避免嵌套使用作用域函数,在链式调用它们时要小心:context 对象、this 或者 it 是很容易混淆的。

参考资料:
Kotlin
SkyRiN Kotlin | 作用域函数
极客熊猫 [译]掌握Kotlin中的标准库函数: run、with、let、also和apply
Geekholt Kotlin学习手册(八)内联函数let、with、run、apply、also
动脑学院 《Android高级课程》VIP试听课

Fork me on GitHub