在上一章中,我们学习了如何创建扩展函数,它可以使用点号表示法来调用。在本章中,我们将学习五个作用域函数,它们是 Kotlin 自带的特殊函数(其中四个是扩展函数)。然而,要理解作用域函数,首先需要理解作用域。
作用域简介
在 Kotlin 中,作用域是代码中的一个区域,你可以在其中声明新的变量、函数、类等。事实上,每当声明一个新变量时,都是在某个作用域内声明的。例如,当我们创建一个新的 Kotlin 文件(以.kt结尾)并向其中添加变量时,该变量是在文件的顶级作用域中声明的。
val pi = 3.14在这个例子中,变量pi是在该文件的顶级作用域中声明的。
当我们在同一文件中声明一个类时,该类的主体创建了另一个作用域——一个包含在文件顶级作用域内部的作用域。让我们在该文件中创建一个Circle类,并添加一个diameter属性。
val pi = 3.14
class Circle(var radius: Double) {
val diameter = radius * 2
}现在我们可以在这个文件中识别出两个作用域:
- 顶级文件作用域,
pi变量和Circle类都在这里声明。 Circle类的类体作用域,diameter在这里声明。
我们可以在这些作用域中的任何一个中添加新的变量、函数或类。
当一个作用域包含在另一个作用域中时,我们称之为嵌套作用域。在上面的例子中,Circle类的类体是一个嵌套在文件作用域内的作用域。
我们可以更进一步。如果我们在那个类中添加一个函数,该函数的主体会创建又一个另一个作用域,它嵌套在类作用域内,而类作用域又嵌套在原始作用域内!
这第三个作用域是另一个可以添加新变量、函数和类的地方。
当你写一个lambda时,也会创建一个新的作用域。让我们添加一个使用 lambda 的新函数。这将为函数体添加一个作用域,为 lambda 添加另一个作用域。
在上面的代码中,我们现在可以识别出五个作用域。我们可以在其中任何一个作用域中添加新的变量、函数或类!1
你可能已经注意到,除了最外层的作用域,这里的每个作用域都以左花括号{开头,以右花括号}结尾。这不是一个硬性规则(例如,有些函数有表达式函数体,因此没有花括号),但可以作为识别作用域的有用方法。这也意味着,如果我们一致地缩进代码,就更容易判断每个新作用域是在哪里引入的。
关于作用域最重要的事情之一是,它们影响在哪里可以使用在其中声明的内容。让我们接下来看看!
作用域和可见性
当你在成长过程中以母语交流时,你会逐渐对语言规则产生一种感觉。当你还是个孩子的时候,你的父母可能会时不时地纠正你的语法,随着时间的推移,你最终能够直觉地理解这些规则,即使你并不总是能够向别人解释它们。
同样,当你一直在编写 Kotlin 代码时,你已经对何时可以使用某个特定的变量、函数或类产生了一种感觉。可见性是用来描述代码中你可以或不可以使用某个东西(如变量、函数或类)的术语。与你的母语一样,你也可以直觉地理解这些可见性规则。然而,如果你真正了解这些规则,你就会成为一个更高效的 Kotlin 开发者。
稍后,我们将看看两种类型的作用域,以及它们如何影响可见性。当你阅读下一节时,请记住声明某物和使用它之间的区别。变量、函数或类在你用val、var、fun或class关键字引入它的地方被声明。当你求值变量、调用函数等时,你是在使用它。
可见性描述了代码中在哪里可以使用某物,而这取决于该事物在代码中的声明位置。我们将在本章的接下来的几节中看到很多这样的例子。
语句作用域
Kotlin 中有两种不同类型的作用域,作用域的类型会影响其中声明的内容的可见性。让我们从语句作用域开始,它最容易理解。语句作用域的可见性规则很简单:
你只能在该事物被声明之后的某一点使用在语句作用域中声明的内容。
例如,函数体有语句作用域。在该函数体内,如果你试图使用一个尚未声明的变量,你会得到一个编译器错误。在下面的代码中,我们在circumference()函数内声明了一个diameter()函数,但试图在它被声明之前使用它。
class Circle(val radius: Double) {
fun circumference(): Double {
val result = pi * diameter()
fun diameter() = radius * 2
return result
}
}我们不能在代码中的这个位置调用 diameter() 函数,因为该函数是在这个语句作用域中的后面一行才声明的。
要纠正这个错误,我们只需要把声明 diameter 的那一行移到使用它的代码之前。
class Circle(val radius: Double) {
fun circumference(): Double {
fun diameter() = radius * 2
val result = pi * diameter()
return result
}
}所以,在语句作用域中声明的内容只能在声明之后使用。很简单!
正如我们在这里看到的,函数体是语句作用域的一个例子。其他例子包括构造函数体、lambda 和 Kotlin Script 文件(当你的文件以 .kts 结尾时)。
声明作用域
Kotlin 中第二种作用域叫做声明作用域。与语句作用域不同,在声明作用域中声明的内容可以在代码中声明之前或之后的任意位置使用。
类体是声明作用域的一个例子。我们可以更新前面列表中的 Circle 类,使得 diameter() 函数在类体(声明作用域)中声明,而不是在 circumference() 函数体(语句作用域)中声明。我们还会把 circumference 改为属性,使其与清单 11.3中的 result 赋值类似。
class Circle(val radius: Double) {
val circumference = pi * diameter()
fun diameter() = radius * 2
}现在,即使 diameter() 在 circumference() 之后声明,一切都能正常编译和运行。
所以,在声明作用域中,声明的内容可以在声明点之前或之后使用。
一个值得注意的例外是,在同一声明作用域中声明并使用的变量和属性,必须在使用之前声明。例如,如果我们简单地把 circumference() 和 diameter() 都改成属性而不改变它们的顺序,编译器就会报错。
class Circle(val radius: Double) {
val circumference = pi * diameter
val diameter = radius * 2
}如我们所见,类体是声明作用域的一个例子。常规 Kotlin 文件的顶层(以 .kt 结尾)是另一个例子。
嵌套作用域和可见性
一般来说,如果你想了解特定作用域内有哪些变量、函数和类可供使用,可以”从该作用域向外爬”,直到最外层的作用域——即文件级作用域。当你爬的时候:
- 如果你爬进一个语句作用域,你只能使用在该作用域中较早声明的内容。
- 但是,如果你爬进一个声明作用域,你可以使用在该作用域中较早或较晚声明的内容。
让我们演示一下这是如何工作的。我们从一个包含以下代码的文件开始。
val pi = 3.14
fun main() {
val radii = listOf(1.0, 2.0, 3.0)
class Circle(
val radius: Double
) {
fun circumference(): Double {
val multiplier = 2.0
// Which variables are visible here?
val diameter = radius * multiplier
return multiplier * pi * radius
}
val area = pi * radius * radius
}
val areas = radii.map {
Circle(it).area
}
}在注释处哪些变量是可见的?
要回答这个问题,我们可以首先确定此列表中的哪些作用域是语句作用域,哪些是声明作用域。请记住……
- 函数体和 lambda 具有语句作用域。
- 类体和文件本身具有声明作用域。
下图说明了代码中不同的声明作用域和语句作用域。我们从黄点开始,我们将其称为起始点,我们的目标是确定在该点处哪些变量是可见的。
起始点位于语句作用域内部。因为在语句作用域中声明的内容只能在声明之后使用,所以我们从只向上扫描开始——不向下扫描。
当我们向上扫描时,我们遇到了 multiplier 变量。因此,multiplier 变量在起始点处可见。但是,diameter 变量不可见,因为它在起始点之后声明。
扫描完这个语句作用域后,我们现在准备向外爬到下一个作用域——也就是说,我们爬到包含 circumference() 函数的作用域。这是一个类体,它具有声明作用域。对于声明作用域,我们向上和向下两个方向扫描。
向两个方向扫描时,我们遇到了在函数之前声明的 radius 参数,以及在函数之后声明的 area。因此,这两个变量在起始点处也都是可见的。
接下来,我们向外爬到包含的作用域——即 main() 函数的作用域。因为这是函数体(因此具有语句作用域),我们只向上爬。
向上扫描时,我们遇到了 radii 变量,它在起始点处也是可见的。然而,areas 变量不可见,因为它在下面。
最后,我们向外爬到顶层文件作用域。文件具有声明作用域,所以我们双向扫描。
向上扫描时找到了 pi 变量,向下扫描时在该作用域中没有找到任何变量。
所以,我们的问题”这里哪些变量是可见的?”答案是:
piradiiradiusmultiplierarea
虽然我们这里只关注变量,但请注意,相同的扫描方法也适用于其他内容,如函数和类。因此,在起始点处,你还可以:
- ……调用
main()函数。 - ……实例化一个新的
Circle对象。 - ……调用
circumference()函数(从其内部)。
同样,你可能已经对大多数这些规则形成了直觉。当你在日常生活中编写 Kotlin 代码时,你通常会依赖编译器和 IDE(如 IntelliJ IDEA 或 Android Studio)来告诉你某物在代码的特定位置是否对你可见。不过,了解这些规则仍然很有帮助,这样你才能以正确的方式组织代码,确保每个内容都具有你想要的可见性!
既然我们已经对作用域和可见性有了扎实的理解,就可以开始深入研究作用域函数了!
作用域函数简介
Kotlin 标准库中有五个被指定为作用域函数的函数。它们每一个都是高阶函数,通常用 lambda 调用,会引入一个新的语句作用域。作用域函数的目的是获取一个现有对象——称为上下文对象2——并以特定方式在该新作用域中表示它。
让我们从一个简单的例子开始——一个叫做 with() 的作用域函数。
with()
当你需要反复使用同一个变量时,最终可能会产生大量重复。例如,假设我们需要更新一个地址对象。
address.street1 = "9801 Maple Ave"
address.street2 = "Apartment 255"
address.city = "Rocksteady"
address.state = "IN"
address.postalCode = "12345"在编写此代码时,在每一行都输入 address 是很繁琐的,而在阅读此代码时,在每一行都看到 address 实际上也无助于理解。这种重复反而分散了人们对每一行真正重要的内容的注意力——即正在被更新的属性。
我们可以使用 with() 作用域函数来引入一个新的作用域,在其中 address 成为隐式接收者。下面是它的样子:
with(address) {
street1 = "9801 Maple Ave"
street2 = "Apartment 255"
city = "Rocksteady"
state = "IN"
postalCode = "12345"
}with() 函数有两个参数:
- 要成为隐式接收者的对象。这是上下文对象。这里是
address。 - 一个 lambda,其中上下文对象将成为隐式接收者。
你可能还记得上一章中的内容,隐式接收者可以完全不用变量名来使用,所以在上面的 lambda 中,我们可以为 street1、street2 等赋值,而不必像在清单 11.8中那样给变量名加前缀。
同样,with() 作用域函数引入了一个新作用域(lambda),其中上下文对象被表示为隐式接收者。
其余四个作用域函数都是扩展函数。接下来,我们来看一个叫做 run() 的函数,因为它与 with() 非常相似。
run()
run() 函数的工作方式与 with() 相同,但它是一个扩展函数而不是普通的顶层函数。3 这意味着我们需要使用点号表示法来调用它。让我们重写清单 11.9,使其使用 run() 而不是 with()。
address.run {
street1 = "9801 Maple Ave"
street2 = "Apartment 255"
city = "Rocksteady"
state = "IN"
postalCode = "12345"
}除了第一行之外,此代码清单与前面的完全相同。上下文对象作为接收者而不是常规函数参数传递,但 lambda 是相同的。
尽管 run() 和 with() 非常相似,但 run() 确实有一些不同的特性,因为它是一个扩展函数。例如,我们在上一章中看到了扩展函数如何插入到调用链中。
实际上,你不必定义自己的扩展函数用于调用链,通常可以使用像 run() 这样的作用域函数。例如,在上一章中,我们创建了一个非常简单的扩展函数叫做 singleQuoted()(在清单 10.12中),并在调用链中间调用了它(在清单 10.14中),像这样:
fun String.singleQuoted() = "'$this'"
val title = "The Robots from Planet X3"
val newTitle = title
.removePrefix("The ")
.singleQuoted()
.uppercase()
// 'ROBOTS FROM PLANET X3'因为 singleQuoted() 非常简单(只是一个表达式!),我们可以完全移除 singleQuoted() 函数,用一个简单的 run() 调用来替换它,如下所示:
val title = "The Robots from Planet X3"
val newTitle = title
.removePrefix("The ")
.run { "'$this'" }
.uppercase()
// 'ROBOTS FROM PLANET X3'run() 函数返回其 lambda 的结果,所以此清单中的代码与上面的清单 11.11工作方式完全相同。当然,如果你需要在代码的很多地方将字符串转换为单引号字符串,最好还是使用 singleQuoted() 扩展函数。这样,如果需要更改其工作方式,你可以在一个地方修复,而不是在很多地方修复。不过,如果你只有一个调用点,作用域函数可能是一个不错的选择!
使用 run() 而不是 with() 的另一个优点是,你可以使用安全调用运算符来处理上下文对象可能为 null 的情况。我们将在本章末尾更详细地探讨这一点。
目前,关于 run() 需要记住的重要事项是:
- 在 lambda 内部,上下文对象被表示为隐式接收者。
run()函数返回 lambda 的结果。
接下来,让我们看看另一个作用域函数,叫做 let()。
let()
let() 可能是最常用的作用域函数。它与 run() 非常相似,但不是将上下文对象表示为隐式接收者,而是表示为其 lambda 的参数。让我们重写前面的清单,使用 let() 而不是 run()。
val title = "The Robots from Planet X3"
val newTitle = title
.removePrefix("The ")
.let { titleWithoutPrefix -> "'$titleWithoutPrefix'" }
.uppercase()
// 'ROBOTS FROM PLANET X3'这与前面的清单非常相似,但没有使用 this,而是使用了一个名为 titleWithoutPrefix 的 lambda 参数。这个参数名很长。让我们改用隐式 it,这样会更简洁。
val title = "The Robots from Planet X3"
val newTitle = title
.removePrefix("The ")
.let { "'$it'" }
.uppercase()
// 'ROBOTS FROM PLANET X3'与 run() 和 with() 一样,let() 函数返回 lambda 的结果。
一个与 let() 相似的作用域函数叫做 also()。接下来让我们看看它。
also()
与 let() 一样,also() 函数也将上下文对象表示为 lambda 参数。但是,与 let() 返回 lambda 的结果不同,also() 函数返回上下文对象。这使其成为插入调用链的理想选择,当你想要”顺便”做某件事时——即在不更改链中该点值的情况下。
例如,我们可能希望在调用链的某个点打印出值。下面是清单 11.11中的代码,其中在移除前缀的调用后插入了 also()。
val title = "The Robots from Planet X3"
val newTitle = title
.removePrefix("The ")
.also { println(it) } // Robots from Planet X3
.singleQuoted()
.uppercase()
// 'ROBOTS FROM PLANET X3'这里的 also() 调用打印出 title.removePrefix("The ") 的结果,而不会干扰调用链的其余部分。无论我们是否包含带有 also() 调用的那一行,singleQuoted() 调用都会基于相同的值——"Robots from Planet X3"——被调用。
顺便说一句,你可能还记得第 7 章中的内容,你可以使用函数引用而不是 lambda,所以我们也可以选择像这样写前面的代码清单:
val title = "The Robots from Planet X3"
val newTitle = title
.removePrefix("The ")
.also(::println) // Robots from Planet X3
.singleQuoted()
.uppercase()
// 'ROBOTS FROM PLANET X3'下面是 also() 在 run() 和 let() 中的位置:
正如你所见,我们大致创建了一个图表,但有一个位置是空的。当我们看最后一个作用域函数 apply() 时,让我们填补那最后一个空位。
apply()
与 also() 一样,apply() 函数返回上下文对象而不是 lambda 的结果。但是与 run() 一样,apply() 函数将上下文对象表示为隐式接收者。我们可以更新清单 11.15使用 apply() 而不是 also(),它会产生相同的结果。
val title = "The Robots from Planet X3"
val newTitle = title
.removePrefix("The ")
.apply { println(this) } // Robots from Planet X3
.singleQuoted()
.uppercase()
// 'ROBOTS FROM PLANET X3'然而,在实践中,Kotlin 开发者通常在这种情况下更倾向于使用 also()。apply() 函数真正擅长的是在你构造对象后对其进行自定义。例如,在你调用构造函数之后,你可能想要在该对象上设置其他属性,或调用其某个函数来初始化它——也就是说,使对象准备好使用。
val dropTarget = DropTarget().apply {
addDropTargetListener(myListener)
}有了这个,我们可以填写图表上剩余的位置:
如你所见,作用域函数都很相似,但它们在两个方面有所不同:
- 它们如何引用上下文对象。
- 它们返回什么。
我在此图表中省略了 with(),因为它与 run() 相同,只是它是一个传统函数而不是扩展函数。
有这么多的作用域函数可供选择,你如何知道该使用哪一个?
选择最合适的作用域函数
在决定使用哪个作用域函数时,先问自己:”我需要作用域函数返回什么?”
- 如果你需要 lambda 结果,将选择范围缩小到
let()或run()。 - 如果你需要上下文对象,将选择范围缩小到
also()或apply()。
之后,根据你偏好如何在 lambda 内部表示上下文对象,在剩余的两个选项之间进行选择。如果你需要使用对象上的函数或属性但不是对象本身,那么 run() 或 apply() 可能是不错的选择。否则,let() 或 also() 通常是不错的选择。
另一个注意事项——创建一个与外层作用域变量同名的变量或 lambda 参数是可能的,但通常最好避免。让我们接下来看看这个。
遮蔽名称
当嵌套作用域为变量、函数或类声明的名称与外层作用域中声明的名称相同时,我们说外层作用域中的名称被内层作用域中的名称遮蔽了。这是一个非常简单的例子,其中一本书和一章都有一个 title。
像这样遮蔽名称是完全有效的,但有一些事情需要记住。
- 当你阅读这样的代码时,可能会产生困惑,认为你引用的是外层作用域中的名称。
- 从一个遮蔽了该变量名称的内层作用域引用外层作用域中声明的变量会更加困难——有时甚至不可能。有时是有解决方案的——在上面的例子中,你仍然可以用
this.title来引用书的标题。当被遮蔽的变量在顶层时,你可以通过在其前面加上包名来引用它。但在某些情况下,你唯一的选择可能是重命名两个名称中的一个。
所以总的来说,最好避免遮蔽。
遮蔽和隐式接收者
当隐式接收者被嵌套作用域的隐式接收者遮蔽时,就会发生一种有趣形式的遮蔽!而且它的工作方式取决于你是否包含或省略 this 前缀!例如,假设我们有一些类和对象用于 Person 和 Dog。
class Person(val name: String) {
fun sayHello() = println("Hello!")
}
class Dog(val name: String) {
fun bark() = println("Ruff!")
}
val person = Person("Julia")
val dog = Dog("Sparky")现在,如果我们将一个 with() 调用嵌套在另一个 with() 调用中,就可以遮蔽隐式接收者,如下所示:
with(person) {
with(dog) {
println(name)
}
}在最外层作用域中,隐式接收者是 person,但在内部作用域中,隐式接收者是 dog:
你可能已经猜到了,最内层作用域内的 name 指的是 dog 对象的 name,所以它是 Sparky。你也可以在该对象上调用 bark()。
with(person) {
with(dog) {
println(name) // Prints Sparky from the dog object
bark() // Calls bark() on the dog object
}
}但有趣的部分是——在同一个作用域中,你也可以直接对 person 对象调用 sayHello(),而无需显式地加上 person 前缀!
with(person) {
with(dog) {
println(name) // Prints Sparky from the dog object
bark() // Calls bark() on the dog object
sayHello() // Calls sayHello() on the person object
}
}所以,在这个例子中,person 和 dog都对最内层作用域中的隐式接收者有贡献。你可以这样可视化它:
换句话说,有效的隐式接收者成为从最内层作用域到最外层作用域的所有隐式接收者的组合。当存在名称冲突时(例如,Person 和 Dog 都有一个 name 属性),优先权给予内层作用域。
遮蔽、隐式接收者和 this
以上所有内容在你使用隐式接收者不带 this 关键字时都成立。但是,如果你带 this 前缀来引用隐式接收者,它将只具有最内层作用域的隐式接收者的函数和属性。在上面的例子中,this 将引用 dog,不会包含来自 person 对象的任何贡献。
为了演示这一点,让我们尝试在 name、bark() 和 sayHello() 前面添加 this.:
with(person) {
with(dog) {
println(this.name) // Prints Sparky
this.bark() // Calls bark() on the dog object
this.sayHello() // Compiler error - Unresolved reference: sayHello
}
}正如你所见,this.name 和 this.bark() 工作正常,但 this.sayHello() 会给我们一个错误,因为 this 只引用 dog。
所以,请记住:
- 当使用
this时,它只引用该作用域中的确切隐式接收者。 - 当省略
this时,有效接收者是隐式接收者的组合,从最内层到最外层作用域。
在我们结束本章之前,让我们看看作用域函数如何与 Kotlin 的空安全特性一起使用。
作用域函数和空检查
除 with() 外,所有作用域函数都是扩展函数。与所有扩展函数一样,你在调用它们时可以使用安全调用运算符,这样它们只会在接收者不为 null 时才实际调用,正如我们在上一章中看到的那样。
安全调用运算符经常与作用域函数一起使用。事实上,许多 Kotlin 开发者将 let() 与安全调用运算符结合使用,以便在对象不为 null 时运行一小段代码。例如,当我们第一次在第 6 章>中学习 null 时,我们需要确保代码只会在客户有付款时才订购咖啡。这是一个受该章清单 6.19启发的代码片段。
if (payment != null) {
orderCoffee(payment)
}以这种方式编写代码绝对没有错。但是,Kotlin 开发者也常用作用域函数和安全调用运算符来编写这样的代码:
payment?.let { orderCoffee(it) }能够识别这两种表达方式是很好的。当然,第二种方式在你需要将其插入调用链时特别有帮助。
在某些情况下,你的条件语句可能也有 else,像这样:
if (payment != null) {
orderCoffee(payment)
} else {
println("I can't order coffee today")
}要使用作用域函数获得相同的效果,你可以使用Elvis 运算符来表达 else情况,像这样:
payment?.let { orderCoffee(it) } ?: println("I can't order coffee today")不过,传统的 if/else 条件语句对大多数开发者来说很容易理解,所以考虑从这里开始,只在作用域函数/安全调用/Elvis 方法更适合周围上下文(如在调用链内部)时才将其用于空检查。
总结
本章涵盖了很多内容,包括:
- 作用域是什么,以及它如何影响变量、函数和类等事物的可见性。
- 语句作用域和声明作用域之间的区别。
- 五个作用域函数——with、run、let、also和apply。
- 如何选择最合适的作用域函数>的指导。
- 遮蔽如何影响名称和接收者。
- 如何使用作用域函数进行空检查。
使用作用域函数编写的代码可能更容易阅读,但不要过度使用!如果你到处使用作用域函数,或者开始在一个作用域函数的 lambda 内部使用另一个作用域函数,实际上可能会让你的代码更难理解。不过,如果使用得当,作用域函数会非常有用。
在下一章>中,我们将开始研究抽象,包括接口、子类型和超类型。到时候见!
感谢 James Lorenzen 和 Jayant Varma 审阅本章!
-
这里实际上有超过五个作用域。如上所述,参数列表有自己的作用域,但你通常只在那里声明参数。另外,根据类是否包含其他内容(如辅助构造函数、枚举类和伴生对象),可能会有一个”静态”作用域。为了保持在作用域主要概念的重点上,我们将在本章中忽略这些。如果你好奇,可以在 Kotlin 语言规范的声明章节中阅读有关它们的所有内容。 ↩︎
-
上下文对象这个术语在官方 Kotlin 作用域函数文档中使用,所以我也在这里使用它。如果你是一名 Android 开发者,这可能会造成混淆,因为 Android 有一个特定的
Context类。请记住,这是两个完全不同的概念。你可以将作用域函数与任何对象一起使用。 ↩︎ -
实际上还有一个名为
run()的顶层函数,当你需要将多个语句压缩到 Kotlin 期望单个表达式的地方时,它可能会有所帮助。不过,我们不打算在本章中涵盖该版本的函数。 ↩︎