泛型、对象和扩展

1. 简介

几十年来,程序员设计了几种编程语言功能来帮助您编写更好的代码——用更少的代码表达相同的想法,用抽象来表达复杂的想法,以及编写能够防止其他开发者无意中犯错的代码都只是其中的几个例子。Kotlin 语言也不例外,有许多功能旨在帮助开发者编写更具表达力的代码。

不幸的是,如果您是初次接触编程,这些功能可能会让事情变得棘手。虽然它们听起来可能很有用,但它们的用处有多大以及它们解决了哪些问题可能并非总是显而易见。您很有可能已经在 Compose 和其他库中看到过一些功能的使用。

虽然经验是不可替代的,但本 Codelab 会向您介绍一些 Kotlin 概念,这些概念有助于您构建更大的应用。

  • 泛型
  • 不同类型的类(枚举类和数据类)
  • 单例和伴生对象
  • 扩展属性和函数
  • 作用域函数

学完本 Codelab 后,您应该对本课程中已经看到的代码有更深入的了解,并学习一些示例,了解何时会在您自己的应用中遇到或使用这些概念。

前提条件

  • 熟悉面向对象编程概念,包括继承。
  • 如何定义和实现接口。

您将学到的知识

  • 如何为类定义泛型类型参数。
  • 如何实例化泛型类。
  • 何时使用枚举类和数据类。
  • 如何定义必须实现接口的泛型类型参数。
  • 如何使用作用域函数访问类属性和方法。
  • 如何为类定义单例对象和伴生对象。
  • 如何使用新属性和方法扩展现有类。

您需要准备的工具

  • 可访问 Kotlin Playground 的网络浏览器。

2. 使用泛型创建可重用类

假设您正在编写一个用于在线测验的应用,类似于您在本课程中看到的测验。测验问题通常有多种类型,例如填空题或判断题。单个测验问题可以通过一个类来表示,该类具有多个属性。

测验中的问题文本可以用字符串表示。测验问题还需要表示答案。但是,不同类型的问题(例如判断题)可能需要使用不同的数据类型来表示答案。我们定义三种不同类型的问题。

  • 填空题:答案是一个由 String 表示的单词。
  • 判断题:答案由一个 Boolean 表示。
  • 数学题:答案是一个数值。简单算术题的答案由一个 Int 表示。

此外,在我们示例中的测验问题,无论问题类型如何,也都将有一个难度评分。难度评分由一个包含三个可能值的字符串表示:"easy""medium""hard"

定义类以表示每种类型的测验问题

  1. 导航到 Kotlin playground
  2. main() 函数上方,定义一个名为 FillInTheBlankQuestion 的填空题类,该类包含一个用于 questionTextString 属性、一个用于 answerString 属性和一个用于 difficultyString 属性。
class FillInTheBlankQuestion(
    val questionText: String,
    val answer: String,
    val difficulty: String
)
  1. FillInTheBlankQuestion 类下方,定义另一个名为 TrueOrFalseQuestion 的类用于判断题,该类包含一个用于 questionTextString 属性、一个用于 answerBoolean 属性和一个用于 difficultyString 属性。
class TrueOrFalseQuestion(
    val questionText: String,
    val answer: Boolean,
    val difficulty: String
)
  1. 最后,在另外两个类下方,定义一个 NumericQuestion 类,该类包含一个用于 questionTextString 属性、一个用于 answerInt 属性和一个用于 difficultyString 属性。
class NumericQuestion(
    val questionText: String,
    val answer: Int,
    val difficulty: String
)
  1. 看看您编写的代码。您有没有注意到重复之处?
class FillInTheBlankQuestion(
    val questionText: String,
    val answer: String,
    val difficulty: String
)

class TrueOrFalseQuestion(
    val questionText: String,
    val answer: Boolean,
    val difficulty: String
)
class NumericQuestion(
    val questionText: String,
    val answer: Int,
    val difficulty: String
)

所有三个类都有完全相同的属性:questionTextanswerdifficulty。唯一的区别是 answer 属性的数据类型。您可能会认为最明显的解决方案是创建一个包含 questionTextdifficulty 的父类,然后每个子类定义 answer 属性。

然而,使用继承存在与上述相同的问题。每次添加新类型的问题时,都必须添加一个 answer 属性。唯一的区别是数据类型。而且,一个没有 answer 属性的父类 Question 看起来也很奇怪。

当您希望某个属性具有不同的数据类型时,子类化不是答案。相反,Kotlin 提供了名为 泛型类型 的东西,允许您根据具体用例,拥有一个可以具有不同数据类型的单一属性。

什么是泛型数据类型?

泛型类型,简称泛型,允许数据类型(例如类)指定一个未知的占位符数据类型,该数据类型可用于其属性和方法。这到底是什么意思?

在上面的例子中,您无需为每种可能的数据类型定义一个答案属性,而是可以创建一个类来表示任何问题,并使用一个占位符名称作为 answer 属性的数据类型。实际的数据类型(StringIntBoolean 等)在该类被实例化时指定。无论占位符名称在哪里使用,都将使用传递给类的数据类型代替。为类定义泛型类型的语法如下所示。

67367d9308c171da.png

在实例化类时提供泛型数据类型,因此需要在类签名中定义。在类名后面是一个左尖括号 (<),后跟数据类型的占位符名称,再后跟一个右尖括号 (>)。

然后,无论您在类中使用真实数据类型的地方,例如作为属性,都可以使用占位符名称。

81170899b2ca0dc9.png

这与任何其他属性声明相同,只是使用了占位符名称而不是数据类型。

您的类最终如何知道要使用哪种数据类型?在实例化类时,尖括号中作为参数传递的是泛型类型使用的数据类型。

9b8fce54cac8d1ea.png

在类名后面是一个左尖括号 (<),后跟实际的数据类型,StringBooleanInt 等,再后跟一个右尖括号 (>)。您为泛型属性传入的值的数据类型必须与尖括号中的数据类型匹配。您将使 answer 属性成为泛型的,以便您可以使用一个类来表示任何类型的测验问题,无论答案是 StringBooleanInt 还是任何任意数据类型。

重构代码以使用泛型

重构您的代码,使用一个名为 Question 的类,该类具有一个泛型答案属性。

  1. 移除 FillInTheBlankQuestionTrueOrFalseQuestionNumericQuestion 的类定义。
  2. 创建一个名为 Question 的新类。
class Question()
  1. 在类名后但在括号前,使用左尖括号和右尖括号添加泛型类型参数。将泛型类型命名为 T
class Question<T>()
  1. 添加 questionTextanswerdifficulty 属性。questionText 的类型应为 Stringanswer 的类型应为 T,因为其数据类型是在实例化 Question 类时指定的。difficulty 属性的类型应为 String
class Question<T>(
    val questionText: String,
    val answer: T,
    val difficulty: String
)
  1. 为了了解这如何适用于多种问题类型(填空题、判断题等),请在 main() 中创建 Question 类的三个实例,如下所示。
fun main() {
    val question1 = Question<String>("Quoth the raven ___", "nevermore", "medium")
    val question2 = Question<Boolean>("The sky is green. True or false", false, "easy")
    val question3 = Question<Int>("How many days are there between full moons?", 28, "hard")
}
  1. 运行您的代码,确保一切正常。现在您应该拥有 Question 类的三个实例——每个实例的答案数据类型不同——而不是三个不同的类,或者使用继承。如果您想处理具有不同答案类型的问题,可以重复使用同一个 Question 类。

3. 使用枚举类

在上一节中,您定义了一个难度属性,该属性有三个可能的值:“easy”、“medium”和“hard”。虽然这可行,但存在一些问题。

  1. 如果您不小心输入错误的三个可能字符串之一,可能会引入 bug。
  2. 如果这些值发生变化,例如 "medium" 重命名为 "average",则需要更新所有使用该字符串的地方。
  3. 没有什么能阻止您或其他开发者无意中使用了非三个有效值之一的不同字符串。
  4. 如果您添加更多难度级别,代码将更难维护。

Kotlin 通过一种特殊的类,称为枚举类,帮助您解决这些问题。枚举类用于创建具有有限可能值集的类型。例如,在现实世界中,四个主要方向——北、南、东、西——可以用一个枚举类表示。无需,并且代码不应允许,使用任何额外的方向。枚举类的语法如下所示。

f4bddb215eb52392.png

枚举的每个可能值称为枚举常量。枚举常量放在花括号内,用逗号分隔。惯例是将常量名称中的每个字母都大写。

您可以使用点运算符引用枚举常量。

f3cfa84c3f34392b.png

使用枚举常量

修改您的代码,使用枚举常量代替 String 来表示难度。

  1. Question 类下方,定义一个名为 Difficultyenum 类。
enum class Difficulty {
    EASY, MEDIUM, HARD
}
  1. Question 类中,将 difficulty 属性的数据类型从 String 更改为 Difficulty
class Question<T>(
    val questionText: String,
    val answer: T,
    val difficulty: Difficulty
)
  1. 初始化三个问题时,传入难度的枚举常量。
val question1 = Question<String>("Quoth the raven ___", "nevermore", Difficulty.MEDIUM)
val question2 = Question<Boolean>("The sky is green. True or false", false, Difficulty.EASY)
val question3 = Question<Int>("How many days are there between full moons?", 28, Difficulty.HARD)

4. 使用数据类

您目前使用过的许多类,例如 Activity 的子类,都有几个方法来执行不同的操作。这些类不仅表示数据,还包含很多功能。

另一方面,像 Question 类这样的类只包含数据。它们没有任何执行操作的方法。这些类可以定义为数据类。将一个类定义为数据类,允许 Kotlin 编译器做出某些假设,并自动实现一些方法。例如,println() 函数在底层调用 toString()。当您使用数据类时,toString() 和其他方法会根据类的属性自动实现。

要定义数据类,只需在 class 关键字之前添加 data 关键字即可。

e7cd946b4ad216f4.png

Question 转换为数据类

首先,您将看到尝试在非数据类上调用 toString() 之类的方法时会发生什么。然后,您将把 Question 转换为数据类,这样该方法和其他方法将默认实现。

  1. main() 中,打印调用 question1 上的 toString() 的结果。
fun main() {
    val question1 = Question<String>("Quoth the raven ___", "nevermore", Difficulty.MEDIUM)
    val question2 = Question<Boolean>("The sky is green. True or false", false, Difficulty.EASY)
    val question3 = Question<Int>("How many days are there between full moons?", 28, Difficulty.HARD)
    println(question1.toString())
}
  1. 运行你的代码。输出只显示类名和对象的唯一标识符。
Question@37f8bb67
  1. 使用 data 关键字将 Question 设为数据类。
data class Question<T>(
    val questionText: String,
    val answer: T,
    val difficulty: Difficulty
)
  1. 再次运行您的代码。通过将其标记为数据类,Kotlin 能够确定在调用 toString() 时如何显示类的属性。
Question(questionText=Quoth the raven ___, answer=nevermore, difficulty=MEDIUM)

当类定义为数据类时,将实现以下方法。

  • equals()
  • hashCode():在使用某些集合类型时您会看到此方法。
  • toString()
  • componentN()component1()component2() 等。
  • copy()

5. 使用单例对象

在许多场景下,您会希望一个类只有一个实例。例如

  1. 当前用户在手机游戏中的玩家统计数据。
  2. 与单个硬件设备交互,例如通过扬声器发送音频。
  3. 访问远程数据源(例如 Firebase 数据库)的对象。
  4. 身份验证,一次只能有一个用户登录。

在上述场景中,您可能需要使用一个类。但是,您只需要实例化该类的一个实例。如果只有一个硬件设备,或者一次只有一个用户登录,则没有理由创建多个实例。同时访问同一硬件设备的两个对象可能会导致一些非常奇怪和有 bug 的行为。

您可以在代码中明确表示一个对象只能有一个实例,方法是将其定义为单例。单例是一个只能有一个实例的类。Kotlin 提供了一种特殊的构造,称为对象,可用于创建单例类。

定义单例对象

645e8e8bbffbb5f9.png

对象的语法类似于类的语法。只需使用 object 关键字代替 class 关键字。单例对象不能有构造函数,因为您不能直接创建实例。相反,所有属性都在花括号内定义并赋予初始值。

前面给出的一些例子可能看起来不太明显,特别是如果您还没有在您的应用中处理过特定的硬件设备或进行过身份验证。但是,随着您继续学习 Android 开发,您会看到单例对象出现。让我们通过一个使用对象表示用户状态的简单例子来看看它的实际应用,在这种情况下只需要一个实例。

对于一个测验来说,能够跟踪总问题数以及学生目前回答的问题数将是件好事。您只需要这个类的一个实例存在,因此,不是将其声明为一个类,而是将其声明为一个单例对象。

  1. 创建一个名为 StudentProgress 的对象。
object StudentProgress {
}
  1. 在这个例子中,我们假设总共有十个问题,目前已回答了三个。添加两个 Int 属性:total 值为 10answered 值为 3
object StudentProgress {
    var total: Int = 10
    var answered: Int = 3
}

访问单例对象

还记得您不能直接创建单例对象的实例吗?那么您如何才能访问它的属性呢?

因为 StudentProgress 在同一时间只有一个实例存在,所以您通过引用对象本身的名称来访问其属性,后跟点运算符 (.),再后跟属性名称。

1b610fd87e99fe25.png

更新您的 main() 函数,以访问单例对象的属性。

  1. main() 中,添加对 println() 的调用,该调用输出 StudentProgress 对象中的 answeredtotal 问题。
fun main() {
    ...
    println("${StudentProgress.answered} of ${StudentProgress.total} answered.")
}
  1. 运行您的代码,以验证一切正常。
...
3 of 10 answered.

将对象声明为伴生对象

Kotlin 中的类和对象可以在其他类型内部定义,这是一种组织代码的好方法。您可以使用伴生对象在另一个类内部定义一个单例对象。伴生对象允许您从类内部访问其属性和方法,如果对象的属性和方法属于该类,这样可以使语法更简洁。

要声明伴生对象,只需在 object 关键字之前添加 companion 关键字即可。

68b263904ec55f29.png

您将创建一个名为 Quiz 的新类来存储测验问题,并将 StudentProgress 作为 Quiz 类的伴生对象。

  1. Difficulty 枚举下方,定义一个名为 Quiz 的新类。
class Quiz {
}
  1. question1question2question3main() 移动到 Quiz 类中。如果您还没有移除 println(question1.toString()),现在也需要移除。
class Quiz {
    val question1 = Question<String>("Quoth the raven ___", "nevermore", Difficulty.MEDIUM)
    val question2 = Question<Boolean>("The sky is green. True or false", false, Difficulty.EASY)
    val question3 = Question<Int>("How many days are there between full moons?", 28, Difficulty.HARD)

}
  1. StudentProgress 对象移到 Quiz 类中。
class Quiz {
    val question1 = Question<String>("Quoth the raven ___", "nevermore", Difficulty.MEDIUM)
    val question2 = Question<Boolean>("The sky is green. True or false", false, Difficulty.EASY)
    val question3 = Question<Int>("How many days are there between full moons?", 28, Difficulty.HARD)

    object StudentProgress {
        var total: Int = 10
        var answered: Int = 3
    }
}
  1. 使用 companion 关键字标记 StudentProgress 对象。
companion object StudentProgress {
    var total: Int = 10
    var answered: Int = 3
}
  1. 更新 println() 的调用以引用 Quiz.answeredQuiz.total 属性。尽管这些属性在 StudentProgress 对象中声明,但可以使用仅包含 Quiz 类名的点符号来访问它们。
fun main() {
    println("${Quiz.answered} of ${Quiz.total} answered.")
}
  1. 运行您的代码以验证输出。
3 of 10 answered.

6. 使用新属性和方法扩展类

在使用 Compose 时,您可能注意到在指定 UI 元素大小时有一些有趣的语法。数值类型(如 Double)似乎具有 dpsp 等指定尺寸的属性。

a25c5a0d7bb92b60.png

为什么 Kotlin 语言的设计者会将属性和函数包含在内置数据类型上,特别是用于构建 Android UI?他们能预测未来吗?Kotlin 是在 Compose 存在之前就设计好用于 Compose 的吗?

当然不是!编写类时,您通常不确定其他开发者将如何在他们的应用中使用它或计划如何使用它。预测所有未来用例是不可能的,也没有必要为一些不可预见的用例给代码增加不必要的冗余。

Kotlin 语言的功能在于,它赋予其他开发者扩展现有数据类型的能力,添加可以通过点语法访问的属性和方法,就好像它们是该数据类型的一部分一样。例如,一个没有参与 Kotlin 浮点类型开发的开发者(例如构建 Compose 库的人)可能会选择添加特定于 UI 尺寸的属性和方法。

既然您在学习前两个单元的 Compose 时已经见过这种语法,那么是时候了解它是如何工作的了。您将添加一些属性和方法来扩展现有类型。

添加扩展属性

要定义扩展属性,请在变量名前面添加类型名称和点运算符 (.)。

1e8a52e327fe3f45.png

您将重构 main() 函数中的代码,以使用扩展属性打印测验进度。

  1. Quiz 类下方,定义一个 Quiz.StudentProgress 的扩展属性,命名为 progressText,类型为 String
val Quiz.StudentProgress.progressText: String
  1. 为扩展属性定义一个 getter,使其返回之前在 main() 中使用的相同字符串。
val Quiz.StudentProgress.progressText: String
    get() = "${answered} of ${total} answered"
  1. main() 函数中的代码替换为打印 progressText 的代码。由于这是一个伴生对象的扩展属性,您可以使用类名 Quiz 通过点符号访问它。
fun main() {
    println(Quiz.progressText)
}
  1. 运行您的代码以验证其工作正常。
3 of 10 answered.

添加扩展函数

要定义扩展函数,请在函数名称前加上类型名称和点运算符 (.)。

879ff2761e04edd9.png

您将添加一个扩展函数,将测验进度输出为进度条。由于无法在 Kotlin playground 中实际制作进度条,您将使用文本打印出一个复古风格的进度条!

  1. StudentProgress 对象添加一个名为 printProgressBar() 的扩展函数。该函数不应接受参数,也没有返回值。
fun Quiz.StudentProgress.printProgressBar() {
}
  1. 使用 repeat() 打印 字符 answered 次。进度条的这一部分代表已回答的问题数。使用 print(),因为您不希望每个字符后都换行。
fun Quiz.StudentProgress.printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
}
  1. 使用 repeat() 打印 字符,次数等于 totalanswered 之间的差值。这部分代表进度条中剩余的问题。
fun Quiz.StudentProgress.printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
    repeat(Quiz.total - Quiz.answered) { print("▒") }
}
  1. 使用不带参数的 println() 打印一个新行,然后打印 progressText
fun Quiz.StudentProgress.printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
    repeat(Quiz.total - Quiz.answered) { print("▒") }
    println()
    println(Quiz.progressText)
}
  1. 更新 main() 中的代码,调用 printProgressBar()
fun main() {
    Quiz.printProgressBar()
}
  1. 运行您的代码以验证输出。
▓▓▓▒▒▒▒▒▒▒
3 of 10 answered.

是否强制执行这些操作?当然不是。然而,拥有扩展属性和方法的选项为您向其他开发者展示代码提供了更多选择。在其他类型上使用点语法可以使您的代码更容易阅读,无论是对自己还是对其他开发者而言。

7. 使用接口重写扩展函数

在前一页,您看到了如何在不直接向 StudentProgress 对象添加代码的情况下,使用扩展属性和扩展函数向其添加功能。虽然这是向已定义的类添加功能的好方法,但如果您可以访问源代码,并不总是需要扩展类。在某些情况下,您不知道实现应该是什么,只知道某个方法或属性应该存在。如果您需要多个类具有相同的附加属性和方法,可能行为不同,您可以使用接口定义这些属性和方法。

例如,除了测验之外,假设您还有调查、食谱步骤或任何其他可以使用进度条的有序数据的类。您可以定义一个称为接口的东西,它指定了每个类必须包含的方法和/或属性。

eeed58ed687897be.png

接口使用 interface 关键字定义,后跟 UpperCamelCase 的名称,再后跟开花括号和闭花括号。在花括号内,您可以定义任何方法签名或 get-only 属性,任何符合该接口的类都必须实现它们。

6b04a8f50b11f2eb.png

接口是一种契约。符合接口的类被称为扩展了该接口。类可以使用冒号 (:),后跟一个空格,再后跟接口名称来声明它希望扩展一个接口。

78af59840c74fa08.png

作为回报,类必须实现接口中指定的所有属性和方法。这使得您可以轻松确保任何需要扩展接口的类都以完全相同的方法签名实现完全相同的方法。如果您以任何方式修改接口,例如添加或删除属性或方法,或更改方法签名,编译器会要求您更新所有扩展该接口的类,从而保持代码一致且更易于维护。

接口允许扩展它们的类的行为存在差异。具体的实现由每个类自己提供。

让我们看看如何使用接口重写进度条,并让 Quiz 类扩展该接口。

  1. Quiz 类上方,定义一个名为 ProgressPrintable 的接口。我们将名称选为 ProgressPrintable,因为它表示任何扩展它的类都可以打印进度条。
interface ProgressPrintable {
}
  1. ProgressPrintable 接口中,定义一个名为 progressText 的属性。
interface ProgressPrintable {
    val progressText: String
}
  1. 修改 Quiz 类的声明,使其扩展 ProgressPrintable 接口。
class Quiz : ProgressPrintable {
    ... 
}
  1. Quiz 类中,添加一个名为 progressText 的属性,类型为 String,正如 ProgressPrintable 接口中指定的那样。因为该属性来自 ProgressPrintable,所以在 val 前面加上 override 关键字。
override val progressText: String
  1. 从旧的 progressText 扩展属性中复制属性 getter。
override val progressText: String
        get() = "${answered} of ${total} answered"
  1. 移除旧的 progressText 扩展属性。

要删除的代码

val Quiz.StudentProgress.progressText: String
    get() = "${answered} of ${total} answered"
  1. ProgressPrintable 接口中,添加一个名为 printProgressBar 的方法,该方法不接受参数且没有返回值。
interface ProgressPrintable {
    val progressText: String
    fun printProgressBar()
}
  1. Quiz 类中,使用 override 关键字添加 printProgressBar() 方法。
override fun printProgressBar() {
}
  1. 将旧的 printProgressBar() 扩展函数中的代码移动到接口中新的 printProgressBar() 中。修改最后一行,通过移除对 Quiz 的引用来引用接口中新的 progressText 变量。
override fun printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
    repeat(Quiz.total - Quiz.answered) { print("▒") }
    println()
    println(progressText)
}
  1. 移除扩展函数 printProgressBar()。此功能现在属于扩展 ProgressPrintableQuiz 类。

要删除的代码

fun Quiz.StudentProgress.printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
    repeat(Quiz.total - Quiz.answered) { print("▒") }
    println()
    println(Quiz.progressText)
}
  1. 更新 main() 中的代码。由于 printProgressBar() 函数现在是 Quiz 类的方法,您需要首先实例化一个 Quiz 对象,然后调用 printProgressBar()
fun main() {
    Quiz().printProgressBar()
}
  1. 运行您的代码。输出没有变化,但您的代码现在更加模块化。随着您的代码库增长,您可以轻松添加符合相同接口的类来重用代码,而无需继承超类。
▓▓▓▒▒▒▒▒▒▒
3 of 10 answered.

接口有许多用例可帮助您构建代码,并且您将在后续的通用单元中频繁地看到它们的应用。以下是一些您在继续使用 Kotlin 时可能会遇到的接口示例。

  • 手动依赖注入。创建一个接口,定义依赖项的所有属性和方法。要求依赖项(Activity、测试用例等)的数据类型是该接口,以便可以使用实现该接口的任何类的实例。这允许您替换底层实现。
  • 用于自动化测试的模拟(Mocking)。模拟类和真实类都符合同一个接口。
  • Compose Multiplatform 应用中访问相同的依赖项。例如,创建一个接口,为 Android 和桌面提供一组通用的属性和方法,即使底层实现因平台而异。
  • Compose 中的多种数据类型,例如 Modifier,都是接口。这允许您添加新的修饰符,而无需访问或修改底层源代码。

8. 使用作用域函数访问类属性和方法

正如您已经看到的,Kotlin 包含了许多功能来使您的代码更简洁。

当您继续学习 Android 开发时,会遇到一个这样的功能,即作用域函数。作用域函数允许您简洁地访问类的属性和方法,而无需重复访问变量名。这到底是什么意思?我们来看一个例子。

使用作用域函数消除重复的对象引用

作用域函数是高阶函数,允许您在不引用对象名称的情况下访问对象的属性和方法。这些函数被称为作用域函数,因为传入的函数体采用了调用作用域函数的对象的作用域。例如,一些作用域函数允许您访问类中的属性和方法,就好像函数被定义为该类的方法一样。这可以通过允许您在包含对象名称是冗余时省略对象名称来提高代码的可读性。

为了更好地说明这一点,让我们看看您在本课程后面将遇到的一些不同的作用域函数。

使用 let() 替换长的对象名称

let() 函数允许您在 lambda 表达式中使用标识符 it 来引用对象,而不是对象的实际名称。当访问多个属性时,这有助于避免重复使用冗长的、更具描述性的对象名称。let() 函数是一个扩展函数,可以使用点符号在任何 Kotlin 对象上调用。

尝试使用 let() 访问 question1question2question3 的属性

  1. Quiz 类添加一个名为 printQuiz() 的函数。
fun printQuiz() {
    
}
  1. 添加以下代码,用于打印问题的 questionTextanswerdifficulty。虽然访问了 question1question2question3 的多个属性,但每次都使用了完整的变量名。如果变量名发生更改,您需要更新所有使用它的地方。
fun printQuiz() {
    println(question1.questionText)
    println(question1.answer)
    println(question1.difficulty)
    println()
    println(question2.questionText)
    println(question2.answer)
    println(question2.difficulty)
    println()
    println(question3.questionText)
    println(question3.answer)
    println(question3.difficulty)
    println()
}
  1. 使用对 question1question2question3let() 函数调用,将访问 questionTextanswerdifficulty 属性的代码包围起来。将每个 lambda 表达式中的变量名替换为 it。
fun printQuiz() {
    question1.let {
        println(it.questionText)
        println(it.answer)
        println(it.difficulty)
    }
    println()
    question2.let {
        println(it.questionText)
        println(it.answer)
        println(it.difficulty)
    }
    println()
    question3.let {
        println(it.questionText)
        println(it.answer)
        println(it.difficulty)
    }
    println()
}
  1. 更新 main() 中的代码以创建名为 quizQuiz 类实例。
fun main() {
    val quiz = Quiz()
}
  1. 调用 printQuiz()
fun main() {
    val quiz = Quiz()
    quiz.printQuiz()
}
  1. 运行您的代码,以验证一切正常。
Quoth the raven ___
nevermore
MEDIUM

The sky is green. True or false
false
EASY

How many days are there between full moons?
28
HARD

使用 apply() 在没有变量的情况下调用对象的方法

作用域函数的一个很酷的特性是,您可以在对象尚未分配给变量时对其进行调用。例如,apply() 函数是一个扩展函数,可以使用点符号在对象上调用。apply() 函数还会返回该对象的引用,以便可以将其存储在变量中。

更新 main() 中的代码以调用 apply() 函数。

  1. 创建 Quiz 类的实例时,在右括号后调用 apply()。调用 apply() 时可以省略括号,并使用尾随 lambda 语法。
val quiz = Quiz().apply {
}
  1. 将对 printQuiz() 的调用移到 lambda 表达式内部。您不再需要引用 quiz 变量或使用点符号。
val quiz = Quiz().apply {
    printQuiz()
}
  1. apply() 函数返回 Quiz 类的实例,但由于您不再在任何地方使用它,请移除 quiz 变量。使用 apply() 函数,您甚至不需要变量即可在 Quiz 的实例上调用方法。
Quiz().apply {
    printQuiz()
}
  1. 运行你的代码。请注意,你可以在没有对 Quiz 实例的引用的情况下调用此方法。apply() 函数返回了存储在 quiz 中的对象。
Quoth the raven ___
nevermore
MEDIUM

The sky is green. True or false
false
EASY

How many days are there between full moons?
28
HARD

虽然使用作用域函数并不是实现所需输出的强制要求,但上述示例说明了它们如何使您的代码更简洁,并避免重复使用相同的变量名。

上面的代码只展示了两个示例,但鼓励您收藏并参考作用域函数文档,因为您将在本课程后面遇到它们的使用。

9. 总结

您刚刚有机会看到了几个新的 Kotlin 功能的实际应用。泛型允许将数据类型作为参数传递给类,枚举类定义了一组有限的可能值,而数据类有助于自动为类生成一些有用的方法。

您还看到了如何创建单例对象(仅限于一个实例),如何使其成为另一个类的伴生对象,以及如何使用新的 get-only 属性和新方法扩展现有类。最后,您看到了一些示例,说明了作用域函数在访问属性和方法时如何提供更简单的语法。

随着您学习更多关于 Kotlin、Android 开发和 Compose 的知识,您将在后续单元中看到这些概念。您现在对它们的工作原理有了更好的理解,以及它们如何提高代码的可重用性和可读性。

10. 了解更多