泛型、对象和扩展

1. 简介

数十年来,编程人员设计了多种编程语言功能来帮助您编写更好的代码,例如使用更少的代码表达相同概念、通过抽象化表达复杂的想法、编写代码防止其他开发者不小心犯错等。Kotlin 语言也不例外,其中许多功能都旨在帮助开发者编写更具表现力的代码。

遗憾的是,如果您是第一次编程,这些功能可能会让工作复杂化。虽然这些功能听起来可能很实用,但可能不是人人都了解这些功能到底有多实用以及它们能解决哪些问题。您可能已经在 Compose 和其他库中见到过对某些功能的运用了。

虽然经验是无可替代的,但此 Codelab 将带您了解 Kotlin 的一些概念,以帮助您构建更大的应用:

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

在此 Codelab 结束时,您应该会更深入地掌握在本课程中学过的代码,并通过一些示例了解您何时会在自己的应用中遇到或使用这些概念。

前提条件

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

学习内容

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

所需条件

  • 一个能够访问 Kotlin Playground 的网络浏览器。

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

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

测验中的问题文本可由字符串表示。测验问题还需要将答案表现出来。不过,不同类型的问题(例如判断正误)可能需要使用不同的数据类型来表示答案。下面我们来定义三种不同类型的问题。

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

此外,示例中的测试问题还包含难度等级,无论什么类型的问题都不例外。难度等级由字符串表示,且具有三个可能的值:"easy""medium""hard"

定义用于表示各类测验问题的类:

  1. 前往 Kotlin 园地
  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 属性。唯一的区别在于数据类型。父类 Question 没有答案属性也显得很奇怪。

如果您想让某个属性具有不同的数据类型,创建子类并不是理想的解决方案。相反,Kotlin 提供了一种称为“泛型类型”的元素,可让您的单个属性根据特定用例具有不同的数据类型。

什么是泛型数据类型?

使用泛型类型(简称“泛型”)时,可以使用数据类型(例如类)指定可与其属性和方法结合使用的未知占位符数据类型。这具体意味着什么?

在上述示例中,您无需为每个可能的数据类型分别定义答案属性,只需创建单个类来表示所有问题,并为 answer 属性的数据类型使用一个占位符名称即可。系统会在实例化相应的类时指定实际数据类型(StringIntBoolean 等)。无论在何处使用占位符名称,系统都会改为使用传入类的数据类型。为类定义通用类型的语法如下所示:

67367d9308c171da.png

系统会在实例化类时提供泛型数据类型,因此需要将该类型定义为类签名的一部分。类名称后面是左尖括号 (<),后跟表示数据类型的占位符名称,再后面是右尖括号 (>)。

然后,此占位符名称即可在您使用类中的实际数据类型的任何位置使用了(例如用于某个属性)。

81170899b2ca0dc9.png

除了使用占位符名称(而非数据类型)这一点以外,这与所有其他属性声明并无区别。

类最终是如何确定要使用哪种数据类型的呢?在您实例化类时,系统会将泛型类型使用的数据类型作为用尖括号括住的形参进行传递。

9b8fce54cac8d1ea.png

类名称后面是左尖括号 (<),后跟实际数据类型(StringBooleanInt 等),再后面是右尖括号 (>)。您为泛型属性传入的值的数据类型必须与尖括号中的数据类型一致。您将对答案属性进行泛化处理,以便可以使用一个类来表示任何类型的测验问题,无论答案是 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(值为 10)和 answered(值为 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,在指定界面元素的大小时,您可能会发现一些有趣的语法。数字类型(例如 Double)似乎具有各种属性(例如指定 dpsp 的维度)。

a25c5a0d7bb92b60.png

为什么 Kotlin 语言的设计人员会在内置数据类型中添加属性和函数,特别是用于构建 Android 界面的属性和函数?难道他们能预测未来吗?难道 Kotlin 早在 Compose 存在之前就能与 Compose 结合使用?

显然不是这样!在编写类时,您通常并不知道其他开发者究竟会(或计划)怎样在其应用中使用该类。您无法预测未来的所有用例,而因为一些不可预见的用例让代码出现不必要的膨胀也并非明智之举。

Kotlin 语言的解决方案就是让其他开发者能够扩展现有数据类型,添加可通过点语法访问的属性和方法,就像这些元素本就包含在相应数据类型中一样。没有在 Kotlin 中处理过浮点类型的开发者(例如构建 Compose 库的开发者)可能会选择添加特定于界面维度的属性和方法。

由于您在前两个单元中学习 Compose 时已经见过此语法,因此接下来您将了解其后台运作方式。您需要添加一些属性和方法以扩展现有类型。

添加扩展属性

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

1e8a52e327fe3f45.png

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

  1. Quiz 类之后,定义一个名为 progressText 且类型为 StringQuiz.StudentProgress 扩展属性。
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 园地中构建进度条,因此您将使用文本来输出一个复古风的进度条!

  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 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 的过程中,您可能会遇到下面这些接口示例。

  • 手动注入依赖项。创建一个定义依赖项中所有属性和方法的接口。要求该接口作为依赖项的数据类型(活动、测试用例等),以便使用实现该接口的任何类的实例。这让您可以更换底层实现。
  • 模拟自动化测试。模拟类和真实类都符合同一接口标准。
  • Compose Multiplatform 应用中访问相同的依赖项。例如,创建一个能够为 Android 和桌面设备提供一组通用属性和方法的接口,即使每个平台的基础实现有所不同也适用。
  • Compose 中的多个数据类型(例如 Modifier)都是接口。这样一来,您无需访问或修改底层源代码即可添加新的修饰符。

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

如您所见,Kotlin 包含大量功能,可让您的代码变得更简洁。

随着您继续学习 Android 开发,您会遇到这样一个功能,那就是“作用域函数”。借助作用域函数,您可以通过简洁的方式访问类中的属性和方法,而无需重复访问变量名称。这具体意味着什么?下面我们来看一个示例。

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

作用域函数属于高阶函数,允许您在不引用对象名称的情况下访问对象的属性和方法。之所以将这些函数称为作用域函数,是因为传入的函数的正文会采用调用作用域函数时所涉及对象的作用域。例如,有些作用域函数允许您访问类中的属性和方法,就好像这些函数已被定义为相应类的方法一样。这样一来,如果包含对象名称会使代码变得冗余,您便可以省略对象名称,从而提高代码的可读性。

为了更好地说明这一点,让我们来看一下您将在本课程的后续内容中遇到的几个不同的作用域函数。

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

借助 let() 函数,您可以使用标识符 it 来引用 lambda 表达式中的对象,而无需使用对象的实际名称。这有助于避免在访问多个属性时反复使用过长、更具描述性的对象名称。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. 使用 question1question2question3 上的 let() 函数调用将访问 questionTextanswerdifficulty 属性的代码括起来。用它替换每个 lambda 表达式中的变量名称。
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. 了解更多内容