完全看透Kotlin作用域函数(run、let、apply、also、with)

1,336 阅读22分钟

我自己在学习的过程中,看了一篇又一篇网上的博客,但是有一些问题却一直没弄懂,比如 这些作用域函数究竟哪些场合该用,哪些场合不该用? 为什么要设计这么多不同的作用域函数?(或者说,如果让你来设计作用域函数,从it/this、返回值、是否为扩展函数等角度排列组合可以排出很多种,那为什么官方仅仅只设计了这五种? 用于指代上下文对象的 it 和 this 到底有什么区别? 另外,我看很多网文最后都列了一张表,用以判断使用时到底选择哪个函数,我想,这也太麻烦太难记了,有没有更好的办法快速让我判断什么场景应该用什么? 思考后便有了这篇文章。由于本人也是kotlin初学者,一些术语难免不会很专业,一些理解也可能不是很正确,请多多包涵。

这个文章的内容混合了官方文档和我自己的所想所思,官方文档里没有的内容全是我自己思考的结果。整篇文章的核心是2.1节和3.3节。

1 引子

什么是作用域函数?先看看官方文档怎么说的。

Kotlin标准库包含几个函数,其唯一目的是在对象上下文中执行代码块。当你调用某个作用域函数,而这个作用域函数接受一个lambda表达式类型的参数时,它将形成一个临时作用域。在此范围内,你可以访问对象而不使用其名称。

如果你一下子没反应过来这段话在说啥,没事,我们看个例子。

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

即使不知道let是干啥的,聪明的你也肯定能猜出这段代码的作用就是:新创建了一个人类对象,叫Alice,20岁,在阿姆斯特丹,随后打印出这个人类对象的信息,然后让她搬去伦敦,然后让她变老一岁,然后再打印出这个人类对象的信息。其中的let就是作用域函数,接收一个lambda表达式。如果不用let函数,也能完成一样的功能,就像下面所写。

val person = Person("Alice", 20, "Amsterdam")
println(person)
person.moveTo("London")
person.incrementAge()
println(person)

所以说,这个作用域函数有啥用呢?不用它不也能完成一样的功能吗?没错,因此,其实这个作用域函数更像是一种语法糖,它不会引入任何新的技术功能,但它们可以使代码更加简洁、更加易读。它们在某些特定场景下能简化大量代码且使语义更明确,非常有用。这就是为什么要设计这样一些作用域函数的目的。

作用域函数有五个,withrunletalsoapply,功能和适合的使用场景都有细微差异,因此有些人就容易把它们搞混,乱用一通

补充说明

  1. 语法糖是指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。

  2. 函数接收lambda表达式类型的参数:像Groovy语言一样,在Kotlin DSL(DSL:域特定语言)中,当一个函数的最后一个参数是lambda表达式时,lambda表达式可以放到外面,进一步,当lambda表达式是唯一参数时,可以不写小括号。上面例子中的let函数,原本的模样应该是这样的。

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

    因为lambda表达式作为最后一个参数,所以lambda表达式可以放到外面,就变成了这样。

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

    又因为lambda表达式是唯一参数,没有其他参数了,所以小括号也可以不写,最终就简化成了上面的样子。后文会大量出现类似的语法,请务必先搞懂lambda表达式有关的内容。具体可以看看 www.jianshu.com/p/791758395… 这篇博客,写的挺好的。

2 用法

引子中提到,Kotlin标准库为我们定义了五个作用域函数,那么下面先来看看这几个函数怎么用,以及使用上有什么区别。至于他们的适用场景,我们放在下一节说。

2.1 总览

用法全归纳在这张表里了:

函数lambda中上下文对象的指代形式返回值是否是扩展函数使用形式
letitlambda表达式的返回值any.let { }
runthislambda表达式的返回值any.run { }
run-lambda表达式的返回值不是,不用上下文对象就能调用,就是一个代码块run { }
withthislambda表达式的返回值不是,上下文对象是作为一个参数传进来,而不是作为调用者with(any) { }
applythis上下文对象any.apply { }
alsoit上下文对象any.also { }

让我们来解释一下表中出现的名词,以最开始见到的例子为例。

val person = Person("Alice", 20, "Amsterdam")
person.let {
    println(it)
    it.moveTo("London")
    it.incrementAge()
    println(it)
}
  • 上下文对象:即想要在lambda代码块中替代的对象,例子中这个上下文对象就是person
  • lambda中上下文对象的指代形式:即通过什么形式来指代这个对象,有两种形式,itthis,例子中引用上下文对象的形式是it,即在lambda代码块中,it就代表了person对象,对it的任何操作就等同于对person对象的操作(是不是很方便?只用打两个字母了!)
  • lambda表达式的返回值:一般情况下lambda表达式的返回值是最后一个表达式的返回值。根据表格,let函数的返回值就是lambda的返回值,因此例子中let的返回值就是Unit
  • 扩展函数:不知道什么是扩展函数的可以看看这个 www.jianshu.com/p/04f73e149… 或者这个 www.runoob.com/kotlin/kotl… 或者这个 juejin.cn/post/693502…

好,现在我们应该大概了解这些函数使用上的差异性了,主要就是引用上下文对象的形式不同以及返回值有差异,接下来我们具体看看这些差异。

2.1.1 lambda中上下文对象的指代形式

总共有两种形式:itthis,这两种形式都可以指代上下文对象,他们在功能上没有任何区别,那为什么要设计出两种形式呢?究竟有什么意义呢?应该是有两方面原因的。

2.1.1.1 贴合编程语义

it就是,而this这个 ,即当前对象自己

在编程中,我们的习惯是:在一个类的内部以this来指代当前类的对象本身,而外部的访问方式则是声明了一个指代这个对象的变量,这个变量其实就是it。看下面的例子:

class Person(var name: String, var height: Int) {
    fun eat() { this.height++ } // 内部用this指代当前对象
}

fun main() {
    val person = Person("李华", 180)
    println(person.name) // 外部则声明了一个指代Person对象的变量,我们通过person来访问name属性,这个perosn其实就是所谓的it
}

现在我们想在lambda代码块里找个东西来指代上下文对象,就自然而然产生了itthis两种指代方式。所以itthis其实在功能上没有任何区别,都是指代了我们的上下文对象,唯一的区别就是语义上的区别,它们被设计出来的目的之一就是为了在不同语境下能写出更贴合我们编程习惯的代码。

我们看看实际的例子:

fun main() {
    val str = "Hello"
    // this
    str.run {
        println("The receiver string length: $length")
        //println("The receiver string length: ${this.length}") // this可以省略,str的属性length可以直接用
    }

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

这是官方文档给的一个例子,用thisit实现了一模一样的功能,但是,编程语义上,我们明显更偏向用it来代指这个str对象,我们是要打印的长度,用it就更合适。

再看看 this的例子:

val adam = Person("Adam").apply { 
    //this.age = 20
    //this.city = "London"
    age = 20
    city = "London"
}
println(adam)

现在我们又造了一个人,叫Adam,我们用apply函数给我们的Adam先生赋了一些属性。从Person("Adam")开始到apply的右括号结束,都是在给Adam先生进行初始化,这个就很像我们在构造函数里面给它赋初值嘛,用this指代就很符合我们的编程习惯。

2.1.1.2 贴合英语语义

kotlin是老外开发的,那itthis的设计肯定会涉及到英语的表达习惯。英语中比起 let this 我们更习惯说 let it,比起 with it 我们更习惯说 with this,因此让let函数指代上下文对象的方式为it,而with函数指代上下文对象的方式为this,读起代码来更符合英语语言的习惯。

thisit的设计就对应英语语言中的主语和谓语,作用域函数的本质之一就是以一种自然、简洁的方式完成了对象的主谓语切换。

3.2和3.3中对itthis进行了进一步理解和区分。

2.1.2 返回值
  • applyalso返回上下文对象
  • letrunwith返回lambda表达式的返回值

所以这有什么用呢?比如,你想进行同一个对象链式调用,就可以使用返回上下文对象的作用域函数。看例子:

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 ${it.size}") }
        .sort()
    println(numberList)
}

alsoapply返回的都是上下文对象本身,所以我们可以进行链式调用,在进行一通操作之后还能sort(),挺好。

再看个返回值是lambda块的返回值的例子:

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.")
}

run的最后一行,count()是Collections类的一个扩展方法,返回值是Int类型,因此,由于run函数的返回值就是lambda块的返回值,而lambda块的返回值就是最后一个语句count()函数的返回值,所以countEndsWithE其实就是一个Int变量,值为count()函数的返回值。

关于返回值就说这么多了,这一块很好理解。当你想让作用域函数的返回值就是上下文对象时,或者你想对上下文对象进行链式调用时,就用also或者apply。如果你不想有返回值或者想返回其他东西,就用其他三个,他们的返回值就是lambda块的返回值,可以根据需要设定。

2.2 使用案例

下面来看看这几个函数的具体使用例子。

2.2.1 let

上下文对象指代形式:it

返回值:lambda的返回值

使用形式:扩展函数的形式,即T.let { }

现在有个字符串list,然后我要从中获取长度大于3的所有子串,并打印出来。正常的代码是这么写的:

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

如果用let函数:

val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let { 
    println(it)
    // and more function calls if needed
} 

还可以进一步简化:使用函数引用的方式代替lambda

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

是不是很简洁?哈哈。

2.2.2 with

上下文对象指代形式:this

返回值:lambda的返回值

使用形式:with函数传入两个参数,第一个是上下文对象,第二个是要执行的lambda块。即with(T) { }

下面的代码想要打印数字List的一些信息。

val numbers = mutableListOf("one", "two", "three")
with(numbers) {
    println("'with' is called with argument $this")
    println("It contains $size elements")
}

官方文档推荐with函数的使用场景是lambda块没有返回值的场景,就像上面的例子一样。为什么?我们暂且按下不表。

2.2.3 run

上下文对象指代形式:this(扩展函数的run)

返回值:lambda的返回值

使用形式:扩展函数的形式,即T.run { } 或 普通代码块的形式,即run { }

其实有两个run函数,一个就是普通的(不是扩展的)run函数,另一个是扩展函数。

普通的run函数只接受一个lambda参数,就相当于Java中的普通代码块,由于Kotlin中普通代码块不会被执行,便可以以run函数的方式定义一段代码块,唯一和Java普通代码块不同的是,Kotlin中的普通run函数可以有返回值,即lambda块的值。例子如下:

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块生成了一个正则表达式,并对字符串进行正则匹配并输出。在这个例子中,用run块使得代码的语义更明确,可读性更好,因为这其实是在告诉别人,这一整个run块里面的代码都是同一个作用,即用于生成正则表达式。

再来看看作为扩展函数存在的run函数。

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")
        //等同于
        //this.port = 8080
        //this.query(this.prepareRequest() + " to port $this.port")
    }
    
    println(result)
}

上面的例子中,我们有一个URL和端口的封装类,叫MultiportService,我们用了run函数的方式去重设端口、准备并查询请求,然后把结果赋给result变量并打印。

2.2.4 apply

上下文对象指代形式:this

返回值:上下文对象

使用形式:扩展函数的形式,即T.apply { }

val numberList = mutableListOf<Double>()
numberList.apply {
    add(2.71)
    add(3.14)
    add(1.0)
    //同样省略了this,其实是
    //this.add(2.71)
}.sort()

上面的例子中我们给List添加了几个数,相当于初始化了这个List的数据,并且由于apply返回的是上下文对象自身,我们还可以链式调用sort函数进行排序,挺好。

2.2.5 also

上下文对象指代形式:it

返回值:上下文对象

使用形式:扩展函数的形式,即T.also { }

val numbers = mutableListOf("one", "two", "three")
numbers.also { println("The list elements before adding new one: $it") }
    .add("four")

上面的例子在list添加"four"之前,使用also打印出了这个list,also同样支持链式调用上下文对象,挺好。

2.3 补充案例

接下来是一些其他的用途,并提供了一些补充案例。

2.3.1 用于判空

以扩展函数形式存在的作用域函数都可以用于非常方便的判空,例如T.let()、T.run()、T.apply()、T.also()等。

方法是在上下文对象后面加?,如果上下文对象是空就不会执行里面的代码,例如不用run时代码是这样写的:

if (numberList != null) {
    numberList.add(1)
    numberList.set(0, 1)
    numberList.add(2)
}

我们先判断一下numberList是不是null,再进行操作,而用了run以后,代码简化为:

numberList?.run {
    add(1)
    set(0, 1)
    add(2)
}

如果numberList是null,则run的lambda块里的内容不会执行。

2.3.2 用于引入替代对象

其实,在上下文对象用it指代的作用域函数中,例如also或者let,可以给it重新命个名字,默认名是it。例如:

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 + "!"
    }.uppercase()
    println("First item after modifications: '$modifiedFirstItem'")
}

这段代码里,我们先获取了numbers的第一个元素,然后对第一个元素进行一系列操作,在let里面,我们并没有用it来代指第一个元素,而给它重新命了个名,叫firstItem,然后对firstItem进行一通操作。

从整体上看,整段lambda代码就是用了一个新的对象firstItem来代替numbers.first(),即引入了一个替代对象来完成后续操作,避免多次调用numbers.first()且可读性增强。

3 深度思考

接下来就是最重要的部分了,这些个玩意儿乱七八糟的,我怎么知道什么时候该用什么函数,以及我怎么记住他们的区别呢?往下看。

3.1 适用场景的判断

序号函数lambda中引用上下文对象的形式返回值是否是扩展函数
1letitlambda的返回值
2runthislambda的返回值
3run-lambda的返回值不是,不用上下文对象就能调用,就是一个代码块
4withthislambda的返回值不是,上下文对象是作为一个参数传进来,而不是作为调用者
5applythis上下文对象
6alsoit上下文对象

既然要谈适用场景,那肯定离不开它们使用的形式,2.1所列的表格挺直观了,所以我又把它拿过来了。根据这张表以及2.2、2.3的案例,我们可以得出判断的标准主要有以下几个方面:

  1. lambda块中引用上下文对象的形式:更偏向于it还是this
  2. 作用域函数的返回值:是要对这个上下文对象继续调用?还是想进行其他操作?还是说不想继续操作了,根本就没有返回值?
  3. 是否要进行上下文对象的判空操作:是的话T.let()、T.run()、T.apply()、T.also()是比较好的选择
  4. 其他方面:例如是否想引入一个替代对象?等等......

补充说明

进行两点补充说明:

  1. 从表格中可以看到,序号2的with和序号4的run在上下文对象指代形式和返回值上没有任何区别。唯一的区别就是with不是扩展函数而run是。with把上下文对象当作参数,而对于run来说上下文对象则是run的调用者。我在lambda块内部想要使用上下文对象都能正常获取并使用,这好像也造不成任何区别啊,那他们到底有什么区别啊?

    run可以用来判空,with不行。

  2. 要记住,这些函数被设计出来只是为了简化代码和使代码语义更明确的,能力上没有什么差异。另外,上面的判断标准也不是绝对的,只是提供了思考问题的几个角度,我们只能在不同的场景下使代码尽可能简单且合理,而并没有一套死板的判断流程能够规定具体的某个场景应该用什么。

3.2 作用域函数的初衷

读完2.2你会发现,几乎所有的案例都可以不用作用域函数来实现同样的功能。那我们为什么要使用作用域函数呢?目的已经说了很多次了,就是为了简化代码以及加强语义(即可读性),那到底为什么作用域函数能做到这两点呢?

3.2.1 简化代码

现在看来作用域函数能够简化代码的原因主要有二:

  1. 它们的返回值,是lambda的返回值或者是上下文对象本身,这给连续的链式调用创造了机会
  2. 源于kotlin的空安全机制,使得作用域函数可以被用作判空检查

这两点没啥好说的了,就不赘述了。举个例子吧,比如第1点,假设有个场景,我想写个函数,打印"mpga"这个字符串,并且返回它。

正常代码会这么写:

fun getAndPrint(): String {
    val s = "mpga"
    println(s)
    return s
}

而使用also之后就可以一行搞定:

fun getAndPrint(): String = "mpga".also { println(it) }
3.2.2 增强可读性

为什么作用域函数能增强可读性,我认为主要有两方面:

  • 一方面强调“块”代码,对同一个对象进行一系列连续的操作,突出了“作用域的概念”
  • 另一方面是引入替代对象,使语句意思的表达更加贴合人类语言习惯 ,因而可读性变好
3.2.2.1 “作用域”的概念

作用域,其实不就是我们说的这个代码块嘛。作用域或者说代码块本身就是一个局部的概念,描述了特定范围内发生的事。

想想这个场景:老师要给同学们分配任务了。假设这个分配任务的过程被描述成这样:

小明去浇花,李华去写作文,小明去擦黑板,小明去扫地,李华去写作文,李华教刘华强写作文。

写成代码即:

xiaoming.waterFlowers()
lihua.write()
xiaoming.cleanBlackboard()
xiaoming.sweepFloor()
lihua.write()
lihua.teachWrite(liuhuaqiang)

虽然这样能完成功能,但是表达的好混乱啊!在顺序不影响结果的情况下,我们改一改:

xiaoming.waterFlowers()
xiaoming.cleanBlackboard()
xiaoming.sweepFloor()
lihua.write()
lihua.write()
lihua.teachWrite(liuhuaqiang)

现在看起来好多了!如果我们再把它们用括号括起来:

with(xiaoming) {
    waterFlowers()
    cleanBlackboard()
    sweepFloor()
}

with(lihua) {
    write()
    write()
    teachWrite(liuhuaqiang)
}

这样是不是清楚多了?上面的括号里都是小明在干活,下面的括号都是李华在干活。这段代码对应的文字就应该是:

小明:你去浇花、擦黑板、扫地;

李华:你去写作文、写作文、教刘华强写作文。

这样是不是清楚了好多?这是因为我们把上下文对象发起或参与的行为都集中在一个区域内了。这就是作用域加强了可读性。

比如我们把共同完成某一目的的代码抽出来封装成一个函数,函数也是一个代码块,整体的可读性是不是也增强了?一样的道理。

3.2.2.2 引入替代对象

其实上面已经隐约能感觉到了,我们写代码或者理解代码,越能符合我们平常的表达习惯,可读性就越强。

引入替代对象就挺符合我们说话的习惯的。作用域函数的设计使我们能够比较轻松且自然地完成主谓语的转换。

还是用上面的例子,小明要干浇花、擦黑板、扫地三件事。如果按照以往代码编写的风格,应该是这样的:

xiaoming.waterFlowers()
xiaoming.cleanBlackboard()
xiaoming.sweepFloor()

翻译一下就是:小明去浇花。小明去擦黑板。小明去扫地。

看起来代码很好理解啊,不就是小明干了三件事嘛?这有什么好强调的吗?

但是现实生活中谁这么讲话啊?简直跟机器人一样!

在现实中,如果有多个连续的句子,主语相同,我们就习惯不用全称去称呼了,而用“他”之类的代词来称呼,例如:

小明去浇花了,他又去擦黑板了,他又去扫地了。

这样不就自然多了吗?等等?代词?这不就相当于我们代码里面引入的替代对象吗?不就是it或者this之类的吗?即:

with(xiaoming) {
    this.waterFlowers()
    this.cleanBlackboard()
    this.sweepFloor()
}

而当同一个主体发出了一连串动作,我们甚至习惯省略掉代词,最终就变成了:

小明去浇了花、擦了黑板、扫了地。

这样表达是不是简洁又自然?对应到代码里就是:

with(xiaoming) {
    waterFlowers()
    cleanBlackboard()
    sweepFloor()
}

所以说,这才是作用域函数被设计出来想要做到的事:简洁方便地写出更符合我们平常的语言习惯的代码。

以往很多人可能已经习惯了类似“小明去浇花。小明去擦黑板。小明去扫地。”的代码表达,因而会觉得,这个with函数有什么用啊,好像没什么用,确实,如果你执意要说“小明去浇花。小明去擦黑板。小明去扫地。”,而不说“小明去浇了花、擦了黑板、扫了地。”,那也是可以的。

表达习惯每个人都可能有差异,你更喜欢怎样说话,都是你的自由。如果你完全习惯了以往的表达方式,并且不想作出改变,那作用域函数确实对你没什么用。即使不用作用域函数,你也可以写出一样功能的代码。

3.3 理解和记忆

有了前文的铺垫,让我们重新再来看下这几个函数。

3.3.1 let

上下文对象指代形式:it

返回值:lambda的返回值

使用形式:扩展函数的形式,即T.let() { }

结合上面的信息,我们来看看let函数:

let就是“让”的意思。对于一个上下文对象,我们更习惯用祈使句表达成:“让它怎么怎么样”“让它干xxx”之类的,按照语言习惯来看,自然而然就能理解为什么let函数的上下文对象指代是用it了,意思就是“让它干某件事”嘛。而干完事肯定需要有个结果的嘛,那结果就是lambda块执行的结果嘛,顺理成章!

所以let函数其实可以翻译成:call the object as it, and then let it do the following....

再来看看官方文档推荐的let适用场景:

  • 在非空对象上执行lambda用 let
  • 在局部作用域内引入一个对象来指代某个表达式用 let

嗯,挺符合我们对let的理解的,现在拿到了一个表达式的结果,我们想对这个表达式结果进行后续操作,就可以把这个表达式结果称为“它”,然后“让它怎么怎么样”“让它干什么什么”之类的。比如:

val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let { 
    println(it)
    // and more function calls if needed
} 
3.3.2 with

上下文对象指代形式:this

返回值:lambda的返回值

使用形式:with函数传入两个参数,第一个是上下文对象,第二个是要执行的lambda块。即with(T) { }

同样,我们重新看看with函数:

对比let来看,let函数强调“我从别的表达式得到了一个结果,然后想要让它去干一些事”,而with则强调“我只是单纯地想让这个对象干一些事”,with只是描述了一个对象进行了一系列行为而已,除此之外再没有别的意思了。至于为什么要这样来描述?3.2.2.1已经给出了答案。

所以with可以被理解为:with this object, do the following.

(哈哈,正好跟使用形式很像,with(T) { },就是with T, do { }

所以,既然是with this object, do the following,那上下文指代自然而然是this了。

再来看看with的返回值,2.2.2提到了官方推荐with函数的使用场景是lambda块没有返回值的场景,其实也很好理解了。with更侧重于描述一个对象进行了一系列行为,with把这些行为组织起来成为了一个代码块,with更想描述的是“某对象干了一系列事情”,而不侧重于运行的结果。所以官方推荐with函数的使用场景是lambda块没有返回值的场景。

而退一步来说,即使真的想要有返回值,那返回值肯定是干了这些事的结果,也就是lambda块的结果了嘛,所以with的返回值就是lambda的返回值。

看看官方文档推荐的with的适用场景:

  • 把一个对象的一系列函数调用组织起来用 with

嗯,官方文档的描述很符合我们的理解。顺便给个例子:

val numbers = mutableListOf("one", "two", "three")
with(numbers) {
    println("'with' is called with argument $this")
    println("It contains $size elements")
}
3.3.3 run

上下文对象指代形式:this(扩展函数的run)

返回值:lambda的返回值

使用形式:扩展函数的形式,即T.run { } 或 普通代码块的形式,即run { }

先看普通函数形式的run

run 这个单词的意思是“运行”,哈哈,那光看这个单词的意思我们就可以直接理解作为普通函数的run了,它接受一个lambda块,意为“运行这个代码块”,除此之外再没有别的意思了,所以它的返回值自然就是lambda的执行结果了。至于为什么要写这样一个代码块?在2.2.3的例子以及3.2.2.1都表明了,run块的作用是语义方面的作用。

按照惯例,翻译一下,非扩展run想表达的就是:run the lambda.

2023.06.29补充:普通的run的两个常用使用场景

  • run用于判空后能立刻执行一段代码块:
    fun a() {
        ruturn nullableValue ?: run {
            ...
        }
    }
  • run用于执行一段lambda,相当于JavaScript中,先定义一个箭头函数,再在需要的地方调用它,只不过js是用小括号调用,而kotlin用run {}

再看扩展函数的run

对比普通的run来看,普通的run就只是想表明“运行这个代码块”,而作为扩展函数的T.run { },它更强调“上下文对象去执行这个代码块”。

也就是说,扩展的run想表达的就是:this object run the following lambda.

因此扩展runthis,记住了吧?另外,和上面的letwith同理,既然是运行一个代码块,返回值就是lambda的结果了咯~

看看官方推荐的run的适用场景:

  • 对象构造且需要计算结果时用 run
  • 运行需要表达式的语句用 非扩展run

对于非扩展的run,和我们分析的一致,其实就是运行一段代码块并可获取返回值。

对于扩展的run,官方推荐在对象构造的时候使用。为什么官方特意提到了对象构造?让我们先来比较下letrun这两个函数 ,单从英语语言角度看,let it do the followingthis object run the lambda 好像没有任何区别,都能描述“一个对象进行了一些行为”,就像下面的例子:

//翻译:即str run the lambda: {println}
str.run {
    println("The receiver string length: $length")
    //等同于
    //println("The receiver string length: ${this.length}")
}

//翻译:即let str do the {println} operation
str.let {
    println("The receiver string's length is ${it.length}")
}

runlet都能描述“str被打印”这一操作。那他们究竟有什么区别呢?答案其实已经在2.1.1.1揭晓了,就是因为runthisletit,造成了编程语义上的区别。继续看这个例子,这种场景下其实我们更推荐用let来完成,而不是run,因为run虽然在英语语义上没问题,但并不符合编程语义!

想想官方的推荐:在对象构造的时候去用run,你会发现它是完美符合编程语义的!

3.3.4 apply

上下文对象指代形式:this

返回值:上下文对象

使用形式:扩展函数的形式,即T.apply { }

apply的意为“应用”,单看“应用”一词好像揣摩不出什么端倪。但是请回想一下,你是不是听过“将更改应用于整个文档”、“将更改应用于整个项目”之类的说法?其实说白了就是,你给某个东西(重新)配置了一些属性、更改了它的某些设置,随后,“应用这些更改”其实就是让你“确认更改”、“使这些更改生效”的意思嘛!

那就好理解了,其实apply被设计出来的意图就是对某个对象的成员或属性进行设置、修改等,所以apply就是“应用这些更改”的意思,其中,“这些更改”指的是lambda表达式里对上下文对象的配置、设置操作。

那么我们的apply想表达的其实就是:apply the following assignments to this object. (assign可以理解为赋值)

apply仅仅是强调“应用这些更改”,那更改、配置完一些属性后我们还应该可以继续对这个对象进行操作才对,所以我们apply的返回值就是这个上下文对象。什么?你不理解?看看我们的Android Studio吧!

image-20210805012949335.png

打开Settings,随便改一些东西,然后你想保存更改。你发现右下角有三个按钮,Cancel是取消,OK是确定,而Apply是应用更改!你会发现点了Apply以后Settings面板不会消失,你还能继续对面板操作,但是你已经应用了这些更改,你的改动已经确确实实生效了。而点了OK则是在更改生效的同时,面板也消失了,表示不想再继续操作了,一切已经OK。回到我们的apply函数,我们在lambda里对上下文对象的属性或成员进行了设置或更改,现在我们要“应用这些更改”,也就是“使这些更改生效”,然后我们还想继续对这个上下文对象操作,是不是就和这个Settings面板的Apply按钮一样!现在你理解为什么apply的返回值是上下文对象本身了嘛!哈哈。

(其实现实中“应用完这些更改还能继续操作”不就相当于我们编程时所谓的“链式调用”嘛!)

再让我们看看为什么apply指代上下文对象用的是this。既然apply的本意是对属性或成员进行设置或更改,那不就很贴合我们this的编程语义吗?或者你也可以很直接地看到我们刚刚的翻译里就直接说了apply the following assignments to this object,所以apply就用this

接下来再看看官方的推荐:

  • 对象构造用 apply

哈哈哈,非常简单的描述,不过也确实跟我们想的一模一样。

最后再对比下applyrun的推荐场景,你会发现二者都是被推荐用于对象构造,而run由于返回值是lambda的返回值,所以run更适合在对象构造完还想顺便进行一些其他操作的场景。

例子:像这种场景选择apply构造还是run构造就完全看个人喜好了咯~

//apply构造
numberList.apply {
    add(2.71)
    add(3.14)
    add(1.0)
}.sort()

//run构造
numberList.run {
    add(2.71)
    add(3.14)
    add(1.0)
    sort()
}
3.3.5 also

上下文对象指代形式:it

返回值:上下文对象

使用形式:扩展函数的形式,即T.also { }

also的意思是“也”,所以我们可以把T.also { }顺利地理解为上下文对象此外也可以干一些别的事。

所以also想表达的自然而然就是:call the object as it, and it will also do the following.

为什么also的上下文对象指代形式是it?从英语语义角度来说,it will also do the following 和 this object will also do the following 好像没什么区别,但是还是那个原因!从编程语义的角度来说,我们更习惯在这种场景下用it来指代。

至于返回值的话,可以这么理解:由于also里面的操作是额外的操作,我们不想also函数耽误上下文对象原本想干的事,所以设计成链式调用的形式方便使用。

再来看官方文档的推荐:

  • 要产生额外效果时用 also

其实就是说,also函数可以帮助这个对象额外进行一些操作,看例子:在添加元素"four"之前,我们额外对numbers进行了打印操作。

val numbers = mutableListOf("one", "two", "three")
numbers
    .also { println("The list elements before adding new one: $it") }
    .add("four")

3.4 其他的话

看到这里我想其实这几个作用域函数你应该能够理解并记住了,下面是一些想说的补充的话。

关于记忆:

  • 只有两个a开头的函数applyalso的返回值是上下文对象
  • 只有letalsoit
  • 构造对象用runapply
  • 只有with不能用于上下文对象判空

当然,通过理解作用域函数的语义来记忆也是完全ok的~

关于思考:

  • 哈哈,其实这个所谓的深度思考有点神棍忽悠人的味道,有的时候甚至更像是“为了解释这个现象而硬生生圆了一套说辞”,所以说如果你觉得很没道理的话,上面的分析全当我在放屁好了~
  • 另外,上面说的所有东西都不是绝对的!你看官方他也只是建议你这么用而已。就算你不喜欢用作用域函数,或者你就是想用with来构造一个对象之类的,也没人管的着你~

4 原理

其实这些作用域函数的实现非常简单啦,我们看看函数源码就能明白:

public inline fun <R> run(block: () -> R): R {
    return block()
}

public inline fun <T, R> T.run(block: T.() -> R): R {
    return block()
}

public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
    return receiver.block()
}

@kotlin.internal.InlineOnly
public inline fun <T> T.apply(block: T.() -> Unit): T {
    block()
    return this
}

public inline fun <T> T.also(block: (T) -> Unit): T {
    block(this)
    return this
}

public inline fun <T, R> T.let(block: (T) -> R): R {
    return block(this)
}

就写到这里吧~