Kotlin - 作用域函数

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

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

开始分析之前,你可能需要简单了解下它大概长什么样,下面是个简单示例

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

runwithapply 通过 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

letalso 有一个作为 lambda 参数传入的 context 对象,如果不指定参数名,则可以通过该 context 对象的隐式默认名称 it 来访问它,itthis 看上去更简洁,用于表达式中也会使代码更加清晰易读。但是,当你访问 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 的结果

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

  • applayalso 返回 context 对象
  • letrunwith 返回闭包的运算结果
返回 Context 对象

applayalso 返回 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 闭包结果

letrunwith 返回 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 结果

runwith 的作用类似,但是调用方法和 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

附:作用域函数适用场景图

1_cKwEowUXup3K7LmiMgn3XQ.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335

推荐阅读更多精彩内容