什么是作用域函数(Scope Functions)
Kotlin 标准库包含了几个特殊的函数,其目的是在调用对象的上下文环境(context)中执行代码块。当你在提供了 lambda 表达式的对象上调用此类函数时,它会形成一个临时作用域。在此作用域内,你可以在不使用其名称的情况下访问该对象,这些函数被称为作用域函数。在 Kotlin 中,作用域函数总共有五个,分别是:let(T.let)、run(run、T.run)、with、apply( T.apply)、also(T.also)。
基本上,这些函数的作用是一样的:在一个对象上执行代码块。不同的是这个对象如何在块内可用,以及整个表达式的结果是什么。
下面是个简单示例:
1 | data class Person(var name: String, var age: Int, var city: String) { |
如果不使用 let 的话,你需要先创建出对象,然后再执行调用,并在每次使用它时重复其名称。
1 | data class Person(var name: String, var age: Int, var city: String) { |
作用域函数的目的就是尽可能的让你的代码变得更简洁更具可读性,尽可能少的创建对象,仅此而已。
下面通过 run 函数再说明一下作用域:
1 | fun test() { |
在这个例子中,在test函数的内部有一个分隔开的作用域,在这个作用域内部,完全包含一个在输出之前的 mood 变量被重新定义并初始化为 I am happy 的操作实现。
这个作用域函数本身似乎看起来不是很有用。但是它返回这个作用域内部的最后一个对象,以下的代码会变得更加简洁,我们可以将show()方法应用到两个View中,而不需要去调用两次show()方法。
1 | run { |
作用域函数的三个属性特征
普通函数与扩展函数
如果我们对比 with 和 T.run 这两个函数的话,它们实际上是十分相似的。下面是使用它们实现相同功能的例子:
1 | with(webview.settings) { |
然后它们之间唯一不同在于 with 是一个普通函数,而 T.run是一个扩展函数。
那么它们各自使用的优点是什么?
想象一下如果 webview.settings 可能为空,那么下面两种方式实现如何去修改呢?
1 | // 比较丑陋的实现方式 |
在这种情况下,显然 T.run 扩展函数更好,因为我们可以在使用它之前对可空性进行检查。
this 和 it 参数
见下面区别一节。
返回 this 和 其它类型
见下面区别一节。
区别
由于作用域函数本质上非常相似,因此理解它们之间的差异非常重要。每个作用域函数有两个主要区别:
- 引用上下文对象的方式
- 返回值
上下文对象:this 还是 it
在作用域函数的 lambda 中,上下文对象通过一个简短的引用而不是它的实际名称来提供。每个作用域函数使用两种方法中的一种来访问上下文对象:作为 lambda 接收器(this)或作为 lambda 参数(it)。两者都提供了相同的功能,下面描述它们各自在不同情况下的优缺点,并就它们的使用提供建议。
1 | fun main() { |
this
run、with 和 apply 通过 this 关键字引用一个 context 对象作为 lambda 接收者。在它们的 lambda 中,this 对象可用于普通类函数中。大多数情况下,在访问接收者的成员时,可以省略 this 关键字,让代码保持简洁。另一方面,如果省略了 this ,就很难区分你操作的函数或变量是外部对象的还是接收者的了,所以,context 对象作为一个接收者(this)这种方式推荐用于调用接收者(this) 的成员变量或函数。示例如下:
1 | data class Person(var name: String, var age: Int = 0, var city: String = "") |
it
let、also 有一个作为 lambda 参数传入的 context 对象,如果不指定参数名,则可以通过该 context 对象的隐式默认名称 it 来访问它,it 比 this 看上去更简洁,用于表达式中也会使代码更加清晰易读。但是,当你访问 context 对象的函数或者属性时,不能像 apply 那样省略 this ,因此,当 context 对象主要用作参数被其他函数调用时,用 it 更好一些。
1 | import kotlin.random.Random |
你也可以为 context 对象指定任意参数名
1 | import kotlin.random.Random |
T.run 和 T.let 举例对比
如果对比 T.run 和 T.let 两个函数也是非常的相似,唯一的区别在于它们接收的参数不一样。下面显示了两种功能的相同逻辑。
1 | stringVariable?.run { |
如果你查看过 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 函数具有一些微妙的优势,如下所示:
- T.let 函数提供了一种更清晰的区分方式去使用给定的变量函数/成员与外部类函数/成员。
- 例如当 it 作为函数的参数传递时,this 不能被省略,并且 it 写起来比 this 更简洁,更清晰。
- T.let 允许更好地命名已使用变量,即可以将 it 转换为其它有含义名称,而 T.run 则不能,内部只能用 this 指代或者省略。
返回值:Context 对象还是 Lambda 结果
作用域函数的返回值不同:
- apply 和 also 返回 context 对象
- let、run、with 返回闭包的运算结果
返回 Context 对象
apply 和 also 返回 context 对象,因此,它们可以结合起来进行链式调用。
1 | fun main() { |
也可以在 return 语句中使用,将 context 对象作为函数的返回值。
1 | import kotlin.random.Random |
返回 Lambda 闭包结果
let、run、with 返回 lambda 闭包结果。所以,你可以将其执行结果赋值给变量,链式操作上一个闭包返回的结果。
1 | fun main() { |
此外,你可以忽略返回值,使用 with 作用域函数来为变量创建一个临时作用域。
1 | fun main() { |
T.let 和 T.also 举例对比
现在让我们看看 T.let 和 T.also ,如果看它的内部函数作用域,它们都是相同的。
1 | stringVariable?.let { |
然而,二者微妙的不同在于它们的返回值,T.let 返回一个不同类型的值,而 T.also 返回T类型本身。
这两个函数对于链式调用都很有用,其中 T.let 可以演变操作,而 T.also 则对相同的变量执行操作。
简单的例子如下:
1 | val original = "abc" |
T.also 似乎看上去没有意义,因为我们可以很容易地将它们组合成一个功能块。仔细思考,它有一些很好的优点。
- 它可以对相同的对象提供非常清晰的分离过程,即创建更小的函数部分。
- 在使用之前,它可以非常强大的进行自我操作,从而实现整个链式代码的构建操作。
当两者结合在一起使用时,即一个自身演变,一个自我保留,它能使一些操作变得更加强大。
1 | // Normal approach |
回顾所有属性特征
通过回顾这3个属性特征,我们可以非常清楚函数的行为。下面说明 T.apply 函数,它的三个属性如下:
- 它是一个扩展函数
- 它是传递this作为参数
- 它是返回 this (即它自己本身)
因此,可以想象下它可以被用作:
1 | // Normal approach |
或者我们也可以让无链对象创建链式调用。
1 | // Normal approach |
使用场景详细说明
下面介绍如何适当的选择作用域函数,从技术上来说,它们的功能在很多情况下都是可以互相转换的,所以下面的例子只是展示了一种通用做法,具体选择还是要看你的业务场景更适合哪种情况。
let
context 对象作为闭包参数(it)传入,返回值是lambda闭包结果。
let 可用于在调用链的结果上调用一个或多个函数。例如,以下代码打印集合上的两个操作的结果:
1 | fun main() { |
使用 let 可以重写为:
1 | fun main() { |
如果闭包模块只有一个函数将 context 作为参数传入,你可以使用(::)替换 lambda:
1 | fun main() { |
let 也经常被用于执行闭包代码块中使用非空值的函数,要对非空对象执行操作,使用安全调用操作符 ?. 后跟 let 闭包,在此闭包中,原来的可空对象就可以被转换为非空对象执行操作。
1 | fun processNonNullString(str: String) { |
let 可以与空合并操作符来一起使用,代替if-else,类似如下链式调用代码:
1 | fun formatGreeting(guestName: String?): String { |
使用 let 的另一种情况是引入局部变量,限制其作用域范围,以提高代码可读性。
1 | fun main() { |
with
context 对象作为闭包参数(it)传入,返回值是lambda闭包结果。
非拓展函数。context 对象作为参数传递,但在 lambda 内部,它可用作接收器(this),返回值为 lambda 结果。
官方建议是使用 context 对象调用函数而不提供 lambda 结果。在代码中,你可以简单的把 with 函数理解为 “使用此对象,执行以下操作”。
1 | fun main() { |
调用同一个类的多个方法时,可以省去类名重复,直接调用类的方法即可,例如用于Android中Recyclerview的 onBinderViewholder,数据model的属性映射到UI上:
1 |
|
with 的另一个用例是引入一个辅助对象,我们可以方便的使用此对象的属性或函数来计算值。
1 | fun main() { |
run
context 对象可用作接收器(this),返回值为 lambda 闭包结果。
run 和 with 的作用类似,但是调用方法和 let 一样——作为 context 对象的拓展函数。
当你的 lambda 同时包含了对象初始化和返回值计算时,run 函数非常适合。
1 | class MultiportService(var url: String, var port: Int) { |
除了在接收器对象上调用run之外,还可以将其用作非扩展函数。非扩展 run 允许你执行需要表达式的多个语句块。
1 | fun main() { |
run也能用来执行函数引用,如下代码来判断字符串是否过长:
1 | fun main() { |
返回值为true。当然,也可以按照如下写法:
1 | isLong("The peoples Republic of China.") |
如果有多个函数,第一种写法则会体现出优势,看如下代码:
1 | fun main() { |
结果如下:
1 | Name is too long. |
apply
context 对象可用作接收器(this),返回调用者本身。
使用apply不会返回值,主要对接收器对象的成员进行操作。 apply的常见情况是对象配置。此类调用可以理解为“将以下赋值应用于对象”。
1 | data class Person(var name: String, var age: Int = 0, var city: String = "") |
将接收器作为返回值,你可以轻松进行链式调用以处理更复杂的操作。
also
context 对象作为闭包参数(it)传入,返回调用者本身。
also 适用于执行将 context 对象作为参数进行的一些操作。还可用于不更改对象的其他操作,例如记录或打印调试信息。通常,你可以在不破坏程序逻辑的情况下从调用链中删除 also 调用。
在代码中看到“also”时,可以理解为“然后执行以下操作”。
1 | fun main() { |
函数选择
以下是它们之间的差异表,以帮助你选择合适的作用域函数。
函数 | 对象引用 | 返回值 | 是否是扩展函数 |
---|---|---|---|
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试听课