Edit Page

作用域函数(Scope Function)

最终更新: 2024/03/21

Kotlin 标准库提供了一系列函数, 用来在某个指定的对象上下文中执行一段代码. 你可以对一个对象调用这些函数, 并提供一个 Lambda 表达式, 函数会创建一个临时的作用域(scope). 在这个作用域内, 你可以访问这个对象, 而不需要指定名称. 这样的函数称为 作用域函数(Scope Function). 有 5 个这类函数: let, run, with, apply, 以及 also.

基本上, 这些函数都执行同样的操作: 在一个对象上执行一段代码. 它们之间的区别在于, 在代码段内如何访问这个对象, 以及整个表达式的最终结果值是什么.

下面是使用作用域函数的典型例子:

data class Person(var name: String, var age: Int, var city: String) {
    fun moveTo(newCity: String) { city = newCity }
    fun incrementAge() { age++ }
}

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

如果不使用 let 函数, 为了实现同样的功能, 你就不得不引入一个新的变量, 并在每次用到它的时候使用变量名来访问它.

data class Person(var name: String, var age: Int, var city: String) {
    fun moveTo(newCity: String) { city = newCity }
    fun incrementAge() { age++ }
}

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

作用域函数并没有引入技术上的新功能, 但它能让你的代码变得更简洁易读.

由于作用域函数都很类似, 因此选择一个适合你使用场景的函数会稍微有点难度. 具体的选择取决于你的意图, 以及在你的项目内作用域函数的使用的一致性. 下面我们详细解释各个作用域函数之间的区别, 以及他们的使用惯例.

选择作用域函数

为了帮助你选择适合需要的作用域函数, 我们整理了这张表, 总结这些函数之间的关键区别.

函数 上下文对象的引用方式 返回值 是否扩展函数
let it Lambda 表达式的结果值
run this Lambda 表达式的结果值
run - Lambda 表达式的结果值 不是: 不使用上下文对象来调用.
with this Lambda 表达式的结果值 不是: 上下文对象作为参数传递.
apply this 上下文对象本身
also it 上下文对象本身

这些函数的详情会在本章的后续小节中专门介绍.

下面是根据你的需求来选择作用域函数的简短指南:

  • 在非 null 对象上执行 Lambda 表达式: let
  • 在一个局部作用域内引入变量: let
  • 对一个对象的属性进行设置: apply
  • 对一个对象的属性进行设置, 并计算结果值: run
  • 在需要表达式的地方执行多条语句: 非扩展函数形式的 run
  • 对一个对象进行一些附加处理: also
  • 对一个对象进行一组函数调用: with

不同的作用域函数的使用场景存在重叠, 因此你可以根据你的项目或你的开发组所使用的编码规约来进行选择.

尽管作用域函数可以使得你的代码变得更简洁, 但也要注意不要过度使用: 可能会是你的代码难以阅读, 造成错误. 我们也建议不要嵌套使用作用域函数, 对作用域函数的链式调用要特别小心, 因为很容易导致开发者错误理解当前的上下文对象, 以及 thisit 的值.

作用域函数之间的区别

由于作用域函数很类似, 因此理解它们之间的差别是很重要的. 它们之间主要存在两大差别:

  • 它们访问上下文对象的方式.
  • 它们的返回值.

访问上下文对象: 使用 this 或 使用 it

在传递给作用域函数的 Lambda 表达式内部, 可以通过一个简短的引用来访问上下文对象, 而不需要使用它的变量名. 每个作用域函数都会使用两种方法之一来引用上下文对象: 作为 Lambda 表达式的 接受者(this)来访问, 或者作为 Lambda 表达式的参数(it)来访问. 两种方法的功能都是一样的, 因此我们分别介绍这两种方法在不同使用场景下的优点和缺点, 并提供一些使用建议.

fun main() {
    val str = "Hello"
    // 使用 this
    str.run {
        println("The string's length: $length")
        //println("The string's length: ${this.length}") // 这种写法的功能与上面一样
    }

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

使用 this

run, with, 和 apply 函数将上下文对象作为 Lambda 表达式的 接受者 - 通过 this 关键字来访问. 因此, 在这些函数的 Lambda 表达式内, 可以象通常的类函数一样访问到上下文对象.

大多数情况下, 访问接受者对象的成员时, 可以省略 this 关键字, 代码可以更简短. 另一方面, 如果省略了 this, 阅读代码时会很难区分哪些是接受者的成员, 哪些是外部对象和函数. 因此, 把上下文对象作为接受者(this)的方式, 建议用于那些主要对上下文对象成员进行操作的 Lambda 表达式: 调用上下文对象的函数, 或对其属性赋值.

data class Person(var name: String, var age: Int = 0, var city: String = "")

fun main() {
//sampleStart
    val adam = Person("Adam").apply {
        age = 20                       // 等价于 this.age = 20
        city = "London"
    }
    println(adam)
//sampleEnd
}

使用 it

letalso 函数使用另一种方式, 它们将上下文对象作为 Lambda 表达式的 参数 来访问. 如果参数名称不指定, 那么上下文对象使用隐含的默认参数名称 it. itthis 更短, 而且带 it 的表达式通常也更容易阅读.

但是, 你就不能象省略 this 那样, 隐含地访问访问对象的函数和属性. 因此, 通过 it 访问上下文对象的方式, 比较适合于对象主要被用作函数参数的情况. 如果你的代码段中存在多个变量, it 也是更好的选择.

import kotlin.random.Random

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

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

    val i = getRandomInt()
    println(i)
//sampleEnd
}

下面的示例通过有名称的 Lambda 参数 value 来访问上下文对象.

import kotlin.random.Random

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

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

    val i = getRandomInt()
    println(i)
//sampleEnd
}

返回值

作用域函数的区别还包括它们的返回值:

  • applyalso 函数返回作用域对象.
  • let, run, 和 with 函数返回 Lambda 表达式的结果值.

你需要根据你的代码之后需要做什么, 来仔细考虑需要什么样的返回值. 这可以帮助你选择最适当的作用域函数.

返回上下文对象

applyalso 的返回值是作用域对象本身. 因此它们可以作为 旁路(side step) 成为链式调用的一部分: 你可以在这些函数之后对同一个对象继续调用其他函数.

fun main() {
//sampleStart
    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()
//sampleEnd
    println(numberList)
}

还可以用在函数的 return 语句中, 将上下文对象作为函数的返回值.

import kotlin.random.Random

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

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

    val i = getRandomInt()
//sampleEnd
}

返回 Lambda 表达式的结果值

let, run, 和 with 函数返回 Lambda 表达式的结果值. 因此, 如果需要将 Lambda 表达式结果赋值给一个变量, 或者对 Lambda 表达式结果进行链式操作, 等等, 你可以使用这些函数.

fun main() {
//sampleStart
    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.")
//sampleEnd
}

此外, 你也可以忽略返回值, 只使用作用域函数来为局部变量创建一个临时的作用域.

fun main() {
//sampleStart
    val numbers = mutableListOf("one", "two", "three")
    with(numbers) {
        val firstItem = first()
        val lastItem = last()        
        println("First item: $firstItem, last item: $lastItem")
    }
//sampleEnd
}

函数

为了帮助你选择适当的作用域函数, 我们对各个函数进行详细介绍, 并提供一些使用建议. 技术上来讲, 很多情况下各个作用域函数是可以互换的, 因此这里的示例只演示常见的使用惯例.

let 函数

  • 上下文对象 通过参数 (it) 访问.
  • 返回值 是 Lambda 表达式的结果值.

let 函数可以用来在链式调用的结果值上调用一个或多个函数. 比如, 下面的代码对一个集合执行两次操作, 然后打印结果:

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

使用 let 函数, 可以改写上面的示例, 使得不必将 List 操作的结果赋值给一个变量:

fun main() {
//sampleStart
    val numbers = mutableListOf("one", "two", "three", "four", "five")
    numbers.map { it.length }.filter { it > 3 }.let {
        println(it)
        // 如果需要, 还可以调用更多函数
    }
//sampleEnd
}

如果传递给 let 的 Lambda 表达式的代码段只包含唯一的一个函数调用, 而且使用 it 作为这个函数的参数, 那么可以使用方法引用 (::) 来代替 Lambda 表达式:

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

let 经常用来对非 null 值执行一段代码. 如果要对可为 null 的对象进行操作, 请使用 null 值安全的调用操作符 ?., 然后再通过 let 函数, 在 Lambda 表达式内执行这段操作.

fun processNonNullString(str: String) {}

fun main() {
//sampleStart
    val str: String? = "Hello"   
    //processNonNullString(str)       // 编译错误: str 可能为 null
    val length = str?.let {
        println("let() called on $it")        
        processNonNullString(it)      // OK: 在 '?.let { }' 之内可以保证 'it' 不为 null
        it.length
    }
//sampleEnd
}

你也可以使用 let 函数, 在一个比较小的作用域内引入局部变量, 让你的代码更加易读. 为了对上下文对象定义一个新的变量, 请将变量名作为 Lambda 表达式的参数, 然后就可以在 Lambda 表达式使用这个参数名, 而不是默认名称 it.

fun main() {
//sampleStart
    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'")
//sampleEnd
}

with 函数

  • 上下文对象 通过接受者 (this) 访问.
  • 返回值 是 Lambda 表达式的结果值.

由于 with 不是一个扩展函数: 上下文对象通过参数传递, 但在 Lambda 表达式内部, 可以作为接受者 (this) 访问.

我们推荐使用 with 函数的情况是, 你可以用它在上下文对象上调用函数, 但不需要使用返回值. 在代码中, with 可以被理解为 "使用这个对象, 进行以下操作."

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

你也可以使用 with 函数, 引入一个辅助对象, 使用它的属性或函数来计算得到一个结果值.

fun main() {
//sampleStart
    val numbers = mutableListOf("one", "two", "three")
    val firstAndLast = with(numbers) {
        "The first element is ${first()}," +
        " the last element is ${last()}"
    }
    println(firstAndLast)
//sampleEnd
}

run 函数

  • 上下文对象 是接受者 (this).
  • 返回值 是 Lambda 表达式的结果值.

run 的功能与 with 一样, 但它作为扩展函数来实现. 因此和 let 一样, 你可以对上下文对象使用点号来调用它.

如果你的 Lambda 表达式既初始化对象, 也计算结果值, 那么就很适合使用 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() {
//sampleStart
    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}")
    }
//sampleEnd
    println(result)
    println(letResult)
}

你也可以把 run 作为非扩展函数来使用. 非扩展函数版本的 run 函数没有上下文对象, 但它仍然返回 Lambda 表达式的结果. 通过使用非扩展函数方式的 run 函数, 你可以在需要表达式的地方执行多条语句的代码段. 在代码中, 非扩展函数方式的 run 函数可以看作是 "执行这个代码段, 并计算结果".

fun main() {
//sampleStart
    val hexNumberRegex = run {
        val digits = "0-9"
        val hexDigits = "A-Fa-f"
        val sign = "+-"

        Regex("[$sign]?[$digits$hexDigits]+")
    }

    for (match in hexNumberRegex.findAll("+123 -FFFF !%*& 88 XYZ")) {
        println(match.value)
    }
//sampleEnd
}

apply 函数

  • 上下文对象 是接受者(this).
  • 返回值 是对象本身.

由于 apply 会返回上下文对象本身, 因此我们推荐的使用场景是, 代码段没有返回值, 并且主要对接受者对象的成员进行操作. apply 函数最常见的使用场景是对象配置. 这样的代码调用可以理解为 "将以下赋值操作应用于这个对象."

data class Person(var name: String, var age: Int = 0, var city: String = "")

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

apply 的另一种使用场景是, 将 apply 函数用作链式调用的一部分, 用来实现复杂的处理.

also 函数

  • 上下文对象 是 Lambda 表达式的参数 (it).
  • 返回值 是对象本身.

also 函数适合于执行一些将上下文对象作为参数的操作. 如果需要执行一些操作, 其中需要引用对象本身, 而不是它的属性或函数, 或者如果你不希望覆盖更外层作用域(scope)中的 this 引用, 那么就可以使用 also 函数.

如果在代码中看到 also 函数, 可以理解为 "对这个对象还执行以下操作".

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

takeIf 函数和 takeUnless 函数

除作用域函数外, 标准库还提供了 takeIf 函数和 takeUnless 函数. 这些函数允许你在链式调用中加入对象的状态检查.

如果对一个对象使用一个检查条件调用 takeIf 函数, 在对象满足检查条件时 takeIf 会返回这个对象, 否则返回 null. 因此, takeIf 函数可以作为单个对象的过滤函数.

takeUnless 的逻辑与 takeIf 相反. 如果对一个对象使用一个检查条件调用 takeUnless 函数, 在对象满足检查条件时 takeUnless 会返回 null, 否则返回这个对象.

使用 takeIftakeUnless 时, 在 Lambda 表达式内部, 可以通过参数 (it) 访问到对象.

import kotlin.random.*

fun main() {
//sampleStart
    val number = Random.nextInt(100)

    val evenOrNull = number.takeIf { it % 2 == 0 }
    val oddOrNull = number.takeUnless { it % 2 == 0 }
    println("even: $evenOrNull, odd: $oddOrNull")
//sampleEnd
}

如果在 takeIf 函数和 takeUnless 函数之后链式调用其他函数, 别忘了进行 null 值检查, 或者使用 null 值安全的成员调用(?.), 因为它们的返回值是可以为 null 的.

fun main() {
//sampleStart
    val str = "Hello"
    val caps = str.takeIf { it.isNotEmpty() }?.uppercase()
   //val caps = str.takeIf { it.isNotEmpty() }.uppercase() // 这里会出现编译错误
    println(caps)
//sampleEnd
}

takeIf 函数和 takeUnless 函数在与作用域函数组合使用时特别有用. 例如, 你可以将 takeIftakeUnless 函数与 let 函数组合起来, 可以对满足某个条件的对象运行一段代码. 为了实现这个目的, 可以先对这个对象调用 takeIf 函数, 然后使用 null 值安全方式(?.)来调用 let 函数. 对于不满足检查条件的对象, takeIf 函数会返回 null, 然后 let 函数不会被调用.

fun main() {
//sampleStart
    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")
//sampleEnd
}

如果不使用 takeIf 和作用域函数, 同样的功能会写成下面这样, 你可以比较一下两种方式的差别:

fun main() {
//sampleStart
    fun displaySubstringPosition(input: String, sub: String) {
        val index = input.indexOf(sub)
        if (index >= 0) {
            println("The substring $sub is found in $input.")
            println("Its start position is $index.")
        }
    }

    displaySubstringPosition("010000011", "11")
    displaySubstringPosition("010000011", "12")
//sampleEnd
}