作用域函数

作用域函数 创建了一个临时作用域,您可以在其中访问对象而无需使用其名称。

作用域函数存在的目的仅在于使您的代码更简洁和可读。它们不提供额外的功能。

有五个作用域函数:let()、run()、with()、apply() 和 also()。它们设计用于与 lambda 一起工作,不需要 import。它们在访问 上下文对象 方面存在差异,使用 it 或 this,以及它们返回的内容。with() 使用与其他作用域函数不同的调用语法。下面您可以看到它们之间的区别:

// ScopeFunctions/Differences.kt
package scopefunctions
import atomictest.eq

data class Tag(var n: Int = 0) {
  var s: String = ""
  fun increment() = ++n
}

fun main() {
  // let(): 使用 'it' 访问对象
  // 返回 lambda 中的最后一个表达式的结果
  Tag(1).let {
    it.s = "let: ${it.n}"
    it.increment()
  } eq 2

  // 带有命名 lambda 参数的 let():
  Tag(2).let { tag ->
    tag.s = "let: ${tag.n}"
    tag.increment()
  } eq 3

  // run(): 使用 'this' 访问对象
  // 返回 lambda 中的最后一个表达式的结果
  Tag(3).run {
    s = "run: $n" // 隐式 'this'
    increment()   // 隐式 'this'
  } eq 4

  // with(): 使用 'this' 访问对象
  // 返回 lambda 中的最后一个表达式的结果
  with(Tag(4)) {
    s = "with: $n"
    increment()
  } eq 5

  // apply(): 使用 'this' 访问对象
  // 返回修改后的对象
  Tag(5).apply {
    s = "apply: $n"
    increment()
  } eq "Tag(n=6)"

  // also(): 使用 'it' 访问对象
  // 返回修改后的对象
  Tag(6).also {
    it.s = "also: ${it.n}"
    it.increment()
  } eq "Tag(n=7)"

  // 带有命名 lambda 参数的 also():
  Tag(7).also { tag ->
    tag.s = "also: ${tag.n}"
    tag.increment()
  } eq "Tag(n=8)"
}

有多个作用域函数是因为它们满足不同的需求组合:

  • 使用 this 访问上下文对象的作用域函数(run()、with() 和 apply())在其作用域块中产生最干净的语法。
  • 使用 it 访问上下文对象的作用域函数(let() 和 also())允许您提供命名的 lambda 参数。
  • 返回其 lambda 中的最后一个表达式的作用域函数(let()、run() 和 with())用于创建结果。
  • 返回修改后的上下文对象的作用域函数(apply() 和 also())用于将表达式链接在一起。

run() 是一个常规函数,而 with() 是一个扩展函数;除此之外,它们是相同的。对于调用链和接收者可为空的情况,优先选择 run()。

下面是作用域函数特性的总结:

this 上下文it 上下文
生成最后一个表达式的结果with、runlet
生成接收者applyalso

您可以使用 安全访问运算符 ?. 将作用域函数应用于可空接收者,只有在接收者不为 null 时才会调用作用域函数:

// ScopeFunctions/AndNullability.kt
package scopefunctions
import atomictest.eq
import kotlin.random.Random

fun gets(): String? =
  if (Random.nextBoolean()) "str!" else null

fun main() {
  gets()?.let {
    it.removeSuffix("!") + it.length
  }?.eq("str4")
}

在 main() 中,如果 gets() 产生非空结果,则会调用 let。let 的非空接收者变为 lambda 内部的非空 it。

将安全访问运算符应用于上下文对象会对整个作用域进行 null 检查,如下所示的 [1]-[4]。否则,在作用域内的每个调用都必须单独进行 null 检查:

// ScopeFunctions/Gnome.kt
package scopefunctions

class Gnome(val name: String) {
  fun who() = "Gnome: $name"
}

fun whatGnome(gnome: Gnome?) {
  gnome?.let { it.who() }     // [1]
  gnome.let { it?.who() }
  gnome?.run { who() }        // [2]
  gnome.run { this?.who() }
  gnome?.apply { who() }      // [3]
  gnome.apply { this?.who() }
  gnome?.also { it.who() }    // [4]
  gnome.also { it?.who() }
  // 对于 nullability 没有帮助:
  with(gnome) { this?.who() }
}

当在 let()、run()、apply() 或 also() 上使用安全访问运算符时,如果上下文对象为 null,则整个作用域都将被忽略:

// ScopeFunctions/NullGnome.kt
package scopefunctions
import atomictest.*

fun whichGnome(gnome: Gnome?) {
  trace(gnome?.name)
  gnome?.let { trace(it.who()) }
  gnome?.run { trace(who()) }
  gnome?.apply { trace(who()) }
  gnome?.also { trace(it.who()) }
}

fun main() {


  whichGnome(Gnome("Bob"))
  whichGnome(null)
  trace eq """
    Bob
    Gnome: Bob
    Gnome: Bob
    Gnome: Bob
    Gnome: Bob
    null
  """
}

trace 显示,当 whichGnome() 接收到 null 参数时,没有作用域函数会执行。

尝试从 Map 中检索对象会产生可空的结果,因为没有保证它会找到该键的条目。在下面的示例中,我们展示了不同作用域函数应用于 Map 查找结果的情况:

// ScopeFunctions/MapLookup.kt
package scopefunctions
import atomictest.*

data class Plumbus(var id: Int)

fun display(map: Map<String, Plumbus>) {
  trace("displaying $map")
  val pb1: Plumbus = map["main"]?.let {
    it.id += 10
    it
  } ?: return
  trace(pb1)

  val pb2: Plumbus? = map["main"]?.run {
    id += 9
    this
  }
  trace(pb2)

  val pb3: Plumbus? = map["main"]?.apply {
    id += 8
  }
  trace(pb3)

  val pb4: Plumbus? = map["main"]?.also {
    it.id += 7
  }
  trace(pb4)
}

fun main() {
  display(mapOf("main" to Plumbus(1)))
  display(mapOf("none" to Plumbus(2)))
  trace eq """
    displaying {main=Plumbus(id=1)}
    Plumbus(id=11)
    Plumbus(id=20)
    Plumbus(id=28)
    Plumbus(id=35)
    displaying {none=Plumbus(id=2)}
  """
}

尽管 with() 可以在此示例中强制使用,但结果太难看,难以考虑。

在 trace 中,您可以看到在第一次调用 display() 时创建了每个 Plumbus 对象,但在第二次调用中没有创建任何对象。查看 pb1 的定义并回想起 Elvis 操作符。如果 ?: 左侧的表达式不为 null,则它变为结果并赋值给 pb1。但是,如果该表达式为 null,则 ?: 的右侧变为结果,即 return,因此在完成初始化 pb1 之前,display() 返回,因此 pb1-pb4 的任何值都不会创建。

在可链式调用中,作用域函数可以与可空类型一起使用:

// ScopeFunctions/NameTag.kt
package scopefunctions
import atomictest.trace

val functions = listOf(
  fun(name: String?) {
    name
      ?.takeUnless { it.isBlank() }
      ?.let { trace("$it in let") }
  },
  fun(name: String?) {
    name
      ?.takeUnless { it.isBlank() }
      ?.run { trace("$this in run") }
  },
  fun(name: String?) {
    name
      ?.takeUnless { it.isBlank() }
      ?.apply { trace("$this in apply") }
  },
  fun(name: String?) {
    name
      ?.takeUnless { it.isBlank() }
      ?.also { trace("$it in also") }
  },
)

fun main() {
  functions.forEach { it(null) }
  functions.forEach { it("  ") }
  functions.forEach { it("Yumyulack") }
  trace eq """
    Yumyulack in let
    Yumyulack in run
    Yumyulack in apply
    Yumyulack in also
  """
}

functions 是一个函数引用的 List,由 main() 中的 forEach 调用应用,使用 it 与函数调用语法。functions 中的每个函数都使用不同的作用域函数。对于 null 或空白输入,forEach 调用 it(null) 和 it(" ") 会被忽略。

在嵌套作用域函数时,在给定上下文中可能会有多个 this 或 it 对象可用。有时很难知道选择了哪个对象:

// ScopeFunctions/Nesting.kt
package scopefunctions
import atomictest.eq

fun nesting(s: String, i: Int): String =
  with(s) {
    with(i) {
      toString()
    }
  } +
  s.let {
    i.let {
      it.toString()
    }
  } +
  s.run {
    i.run {
      toString()
    }
  } +
  s.apply {
    i.apply {
      toString()
    }
  } +
  s.also {
    i.also {
      it.toString()
    }
  }

fun main() {
  nesting("X", 7) eq "777XX"
}

在所有情况下,对 toString() 的调用都是应用于 Int,因为最近的隐式 this 或 it 是 Int。apply() 和 also() 返回修改后的对象 s,而不是计算结果。由于作用域函数旨在提高可读性,因此嵌套作用域函数是一个值得怀疑的做法。

没有作用域函数提供类似于 use() 的 资源清理 功能:

// ScopeFunctions/Blob.kt
package scopefunctions
import atomictest.*

data class Blob(val id: Int) : AutoCloseable {
  override fun toString() = "Blob($id)"
  fun show() { trace("$this")}
  override fun close() = trace("Close $this")
}

fun main() {
  Blob(1).let { it.show() }
  Blob(2).run { show() }
  with(Blob(3)) { show() }
  Blob(4).apply { show() }
  Blob(5).also { it.show() }
  Blob(6).use { it.show() }
  Blob(7).use { it.run { show() } }
  Blob(8).apply { show() }.also { it.close() }
  Blob(9).also { it.show() }.apply { close() }
  Blob(10).apply { show() }.use {  }
  trace eq """
    Blob(1)
    Blob(

2)
    Blob(3)
    Blob(4)
    Blob(5)
    Blob(6)
    Close Blob(6)
    Blob(7)
    Close Blob(7)
    Blob(8)
    Close Blob(8)
    Blob(9)
    Close Blob(9)
    Blob(10)
    Close Blob(10)
  """
}

尽管 use() 在视觉上与 let() 和 also() 相似,但 use() 不允许从其 lambda 中返回任何内容。这防止了表达式链接或生成结果。

没有 use(),对于任何作用域函数,close() 都不会被调用。要使用作用域函数并保证清理,将作用域函数置于 use() lambda 内部,如 Blob(7) 所示。Blob(8) 和 Blob(9) 展示了如何显式调用 close(),以及如何交替使用 apply() 和 also()。

Blob(10) 使用 apply(),结果传递给 use(),在其 lambda 结束时调用 close()。

作用域函数是内联的

通常,将 lambda 作为参数传递会将 lambda 代码存储在辅助对象中,与常规函数调用相比,会增加一些运行时开销,但考虑到 lambda 的好处(可读性和代码结构),这种开销通常不是一个问题。此外,JVM 中包含许多优化,通常可以弥补开销。

不管开销有多小,只要有运行时开销,无论多么小,都会产生“谨慎使用某个功能”的建议。通过将作用域函数定义为 inline,可以消除所有的运行时开销。这样,就可以毫不犹豫地使用作用域函数。

当编译器看到 inline 函数调用时,它将函数体替换为函数调用,将所有参数替换为实际参数。

内联对于小型函数效果很好,其中函数调用的开销可能是整个调用的重要部分。随着函数变得越来越大,与整个调用所需的时间相比,调用的成本会缩小,从而降低了内联的价值。与此同时,生成的字节码会增加,因为在每个调用点都插入了整个函数体。

当内联函数接受一个 lambda 参数时,编译器将 lambda 体与函数体一起内联。因此,在将 lambda 直接调用或传递给另一个 inline 函数时,不会创建其他类或对象来传递 lambda。 (这仅在直接调用 lambda 或传递给另一个 inline 函数时才有效)。

尽管可以将其应用于任何函数,但 inline 用于内联 lambda 体或创建 具体化泛型 是有意义的。您可以在 这里 找到有关内联函数的更多信息。

练习和解答可在 www.AtomicKotlin.com 找到。