Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

作用域函数

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

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

有五个作用域函数:let()run()with()apply()also()。它们设计用于与 lambda 一起工作,不需要 import。它们在访问 上下文对象 方面存在差异,使用 itthis,以及它们返回的内容。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 上下文
生成最后一个表达式的结果withrunlet
生成接收者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() 产生非空结果,则会调用 letlet 的非空接收者变为 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(" ") 会被忽略。

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

// 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,因为最近的隐式 thisitIntapply()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 找到。