Kotlin 编程人员训练营 5.2:泛型

此 Codelab 是面向编程人员的 Kotlin 训练营课程的一部分。如果您按顺序学习这些 Codelab,您将会充分发掘此课程的价值。根据您的知识水平,您也许可以浏览某些部分。本课程专为了解面向对象的语言并希望学习 Kotlin 的编程人员而设计。

简介

在此 Codelab 中,您将了解通用类、函数和方法,以及它们在 Kotlin 中的工作方式。

本课程不是构建单个示例应用,而是用来学习知识,但它们是半独立的,以便您能够浏览已熟悉的部分。为了将两者联系起来,许多示例使用了水族馆主题。如果您想看完整的水族馆故事,请查看面向编程人员的 Kotlin 训练营 Udacity 课程。

您应当已掌握的内容

  • Kotlin 函数、类和方法的语法
  • 如何在 IntelliJ IDEA 中创建新类并运行程序

学习内容

  • 如何使用通用类、方法和函数

您将执行的操作

  • 创建通用类并添加约束条件
  • 创建 inout 类型
  • 创建通用函数、方法和扩展函数

泛型简介

与许多编程语言一样,Kotlin 也有通用类型。泛型类型可以让您将类变为泛型,从而使得类更加灵活。

假设您要实现一个 MyList 类,用于存储项目列表。如果没有泛型,您需要为每种类型实现新版 MyList:一个用于 Double,一个用于 String,一个用于 Fish。使用泛型,您可以将列表设为通用,因此它可以包含任何类型的对象。这就类似于将通配符设为适合多种类型的通配符。

要定义泛型类型,请将 T 放置在类名称后加上尖括号 <T>。(您可以使用其他字母或更长的名称,但通用类型的惯例为 T。)

class MyList<T> {
    fun get(pos: Int): T {
        TODO("implement")
    }
    fun addItem(item: T) {}
}

您可以引用 T 作为正常类型。get() 的返回值类型为 TaddItem() 的参数类型为 T。当然,通用列表非常有用,因此 List 类内置在 Kotlin 中。

第 1 步:创建类型层次结构

在此步骤中,您将创建一些类,以在下一步中使用。子类化在之前的 Codelab 中有所介绍,下面是简要回顾。

  1. 为了使示例保持整洁有序,请在 src 下创建一个新软件包,并将其命名为 generics
  2. generics 软件包中,创建一个新的 Aquarium.kt 文件。这样,您就可以使用相同的名称重新定义无冲突的内容,因此此 Codelab 的剩余代码将放入此文件中。
  3. 为供水类型创建类型层次结构。首先,将 WaterSupply 设为 open 类,以便它可以创建子类。
  4. 添加一个布尔值 var 参数 needsProcessing。这会自动创建一个可变属性以及一个 getter 和 setter。
  5. 创建一个子类 TapWater,以便扩展 WaterSupply,并为 needsProcessing 传递 true,因为自来水包含对鱼类不良的添加剂。
  6. TapWater 中,定义一个名为 addChemicalCleaners() 的函数,该函数会在净水后将 needsProcessing 设置为 false。您可以通过 TapWater 设置 needsProcessing 属性,因为它默认为 public,并且可供子类访问。以下是完成后的代码。
package generics

open class WaterSupply(var needsProcessing: Boolean)

class TapWater : WaterSupply(true) {
   fun addChemicalCleaners() {
       needsProcessing = false
   }
}
  1. WaterSupply 创建另外两个子类,分别名为 FishStoreWaterLakeWaterFishStoreWater 不需要处理,但 LakeWater 必须使用 filter() 方法进行过滤。过滤后,无需再次处理,因此请在 filter() 中设置 needsProcessing = false
class FishStoreWater : WaterSupply(false)

class LakeWater : WaterSupply(true) {
   fun filter() {
       needsProcessing = false
   }
}

如果您需要更多信息,请参阅有关 Kotlin 继承的上一课。

第 2 步:创建泛型类

在此步骤中,您将修改 Aquarium 类以支持不同类型的供水。

  1. Aquarium.kt 中,定义 Aquarium 类,类名称后在方括号中 <T>
  2. 将类型为 T 的不可变属性 waterSupply 添加到 Aquarium
class Aquarium<T>(val waterSupply: T)
  1. 编写一个名为 genericsExample() 的函数。它不是类的一部分,因此它可以位于文件的顶级位置,例如 main() 函数或类定义。在该函数中,创建 Aquarium 并向其传递 WaterSupply。由于 waterSupply 参数是通用参数,因此您必须用尖括号 <> 指定类型。
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
}
  1. genericsExample() 中,您的代码将可以访问水族馆的waterSupply。由于它是 TapWater 类型,因此您可以在没有任何类型转换的情况下调用 addChemicalCleaners()
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. 创建 Aquarium 对象时,您可以移除尖括号以及它们之间的相互作用,因为 Kotlin 具有类型推断。因此,您在创建实例时无需说两次 TapWater。该类型可以通过 Aquarium 的参数推断出来;它仍然会将 Aquarium 的类型设置为 TapWater
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. 如需查看发生的情况,请在调用 addChemicalCleaners() 前后输出 needsProcessing。以下是完成后的函数。
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
    println("water needs processing: ${aquarium.waterSupply.needsProcessing}")
    aquarium.waterSupply.addChemicalCleaners()
    println("water needs processing: ${aquarium.waterSupply.needsProcessing}")
}
  1. 添加一个 main() 函数以调用 genericsExample(),然后运行程序并观察结果。
fun main() {
    genericsExample()
}
⇒ water needs processing: true
water needs processing: false

第 3 步:使其更具体

通用意味着您几乎可以传递任何内容,但有时这又是一个问题。在此步骤中,您将使 Aquarium 类更具体于能够包含哪些内容中。

  1. genericsExample() 中,创建一个 Aquarium,为 waterSupply 传递一个字符串,然后输出水族馆的 waterSupply 属性。
fun genericsExample() {
    val aquarium2 = Aquarium("string")
    println(aquarium2.waterSupply)
}
  1. 运行程序并观察结果。
⇒ string

得到的结果就是您传递的字符串,因为 Aquarium 不会对T.(包括 String 等任何类型)进行任何限制。

  1. genericsExample() 中,创建另一个 Aquarium,为 waterSupply 传递 null。如果 waterSupply 为 null,就输出 "waterSupply is null"
fun genericsExample() {
    val aquarium3 = Aquarium(null)
    if (aquarium3.waterSupply == null) {
        println("waterSupply is null")
    }
}
  1. 运行程序并观察结果。
⇒ waterSupply is null

为什么您可以在创建 Aquarium 时传递 null?之所以能够实现这一点,是因为默认情况下 T 表示可为 null Any? 类型,即类型层次结构顶部的类型。下文内容与您之前输入的内容相同。

class Aquarium<T: Any?>(val waterSupply: T)
  1. 如需不允许传递 null,请通过移除 Any 之后的 ? 来明确提供类型为 AnyT
class Aquarium<T: Any>(val waterSupply: T)

在这种情况下,Any 称为通用约束条件。这意味着可以为 T 传递任意类型,只要它不是 null 即可。

  1. 您真正需要的是确保只能为 T 传递 WaterSupply(或其子类之一)。将 Any 替换为 WaterSupply 可定义更具体的通用限制条件。
class Aquarium<T: WaterSupply>(val waterSupply: T)

第 4 步:添加更多检查

在此步骤中,您将了解 check() 函数,以帮助确保代码的行为符合预期。check() 函数是 Kotlin 中的标准库函数。它充当断言,并在其参数评估结果为 false 时抛出 IllegalStateException

  1. Aquarium 类添加 addWater() 方法以添加用水,并使用 check() 来确保您不需要先处理水。
class Aquarium<T: WaterSupply>(val waterSupply: T) {
    fun addWater() {
        check(!waterSupply.needsProcessing) { "water supply needs processing first" }
        println("adding water from $waterSupply")
    }    
}

在这种情况下,如果 needsProcessing 为 true,check() 会抛出异常。

  1. genericsExample() 中,添加代码以使用 LakeWater 创建 Aquarium,然后在其中添加一些水。
fun genericsExample() {
    val aquarium4 = Aquarium(LakeWater())
    aquarium4.addWater()
}
  1. 运行程序,会出现异常情况,因为需要先过滤水。
⇒ Exception in thread "main" java.lang.IllegalStateException: water supply needs processing first
        at Aquarium.generics.Aquarium.addWater(Aquarium.kt:21)
  1. 添加用于过滤水的调用,然后将其添加到 Aquarium。现在,当您运行程序时,不会抛出异常。
fun genericsExample() {
    val aquarium4 = Aquarium(LakeWater())
    aquarium4.waterSupply.filter()
    aquarium4.addWater()
}
⇒ adding water from generics.LakeWater@880ec60

上文介绍了泛型的基础知识。以下任务涵盖更多内容,但重要的概念是声明和使用具有通用约束的泛型类。

在此任务中,您将了解如何使用泛型实现输入和输出类型in 类型是一种只能传入类而不返回的类型。out 类型是一种只能从类返回的类型。

查看 Aquarium 类,您会发现只有获取 waterSupply 属性时才会返回通用类型。没有任何方法会将 T 类型的值作为参数(构造函数中的定义除外)。Kotlin 可让您为这种情况精确定义 out 类型,并且可以推断出有关这些类型安全使用位置的额外信息。同样,您可以为仅传入方法而不返回方法的通用类型定义 in 类型。这使 Kotlin 能够对代码进行额外的检查以确保代码安全。

inout 类型是 Kotlin 类型系统的指令。对整个类型系统的说明不在此训练营的讨论范围之内(其中涉及的操作非常复杂);不过,编译器会标记未适当标记为 inout 的类型,因此您需要了解这些类型。

第 1 步:定义输出类型

  1. Aquarium 类中,将 T: WaterSupply 更改为 out 类型。
class Aquarium<out T: WaterSupply>(val waterSupply: T) {
    ...
}
  1. 在同一文件内的类之外,声明一个函数 addItemTo() 期望 AquariumWaterSupply
fun addItemTo(aquarium: Aquarium<WaterSupply>) = println("item added")
  1. genericsExample() 调用 addItemTo() 并运行您的程序。
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    addItemTo(aquarium)
}
⇒ item added

Kotlin 可以确保 addItemTo() 不会使用泛型 WaterSupply 执行任何不安全的类型,因为它已声明为 out 类型。

  1. 如果移除 out 关键字,编译器会在调用 addItemTo() 时给出错误提示,因为 Kotlin 无法保证您对该类型没有任何安全风险。

第 2 步:定义 in-type

in 类型与 out 类型类似,但适用于仅传入函数而不返回类型的通用类型。如果您尝试返回 in 类型,则会出现编译器错误。在此示例中,您将定义一个 in 类型作为接口的一部分。

  1. Aquarium.kt 中,定义一个接口 Cleaner,该接口接受仅限于 WaterSupply 的通用 T。由于它仅用作 clean() 的参数,因此您可以将其设为 in 参数。
interface Cleaner<in T: WaterSupply> {
    fun clean(waterSupply: T)
}
  1. 如需使用 Cleaner 接口,请创建一个 TapWaterCleaner 类,通过添加化学品实现 Cleaner 以清理 TapWater
class TapWaterCleaner : Cleaner<TapWater> {
    override fun clean(waterSupply: TapWater) =   waterSupply.addChemicalCleaners()
}
  1. Aquarium 类中,更新 addWater() 以接受 T 类型的 Cleaner,并在添加前对其进行清理。
class Aquarium<out T: WaterSupply>(val waterSupply: T) {
    fun addWater(cleaner: Cleaner<T>) {
        if (waterSupply.needsProcessing) {
            cleaner.clean(waterSupply)
        }
        println("water added")
    }
}
  1. 更新 genericsExample() 示例代码以创建一个 TapWaterCleaner(使用 TapWaterAquarium),然后使用清理器添加一些水。它会根据需要使用清理器。
fun genericsExample() {
    val cleaner = TapWaterCleaner()
    val aquarium = Aquarium(TapWater())
    aquarium.addWater(cleaner)
}

Kotlin 将使用 inout 类型的信息来确保您的代码安全地使用泛型。Outin 很容易记住:out 类型可以作为返回值向外传递,in 类型可以作为参数传入。

如果您希望更深入地了解类型和类型解决的问题,本文档将对此进行详细介绍。

在此任务中,您将了解通用函数及其适用情形。通常,只要函数接受具有通用类型的类的参数,就建议创建通用函数。

第 1 步:创建通用函数

  1. generics/Aquarium.kt 中,创建一个 isWaterClean()(接受 Aquarium)。您需要指定参数的通用类型;其中一个选项是使用 WaterSupply
fun isWaterClean(aquarium: Aquarium<WaterSupply>) {
   println("aquarium water is clean: ${aquarium.waterSupply.needsProcessing}")
}

但这意味着 Aquarium 必须具有 out 类型参数才能调用该方法。有时,outin 过于严格,因为您需要为输入和输出都使用类型。您可以通过将函数设为通用来移除 out 要求。

  1. 如需将函数变为通用函数,请在关键字 fun 后添加尖括号,并传入通用类型 T 和任何限制条件,在此示例中为 WaterSupply。将 Aquarium 更改为受 T(而非 WaterSupply)约束。
fun <T: WaterSupply> isWaterClean(aquarium: Aquarium<T>) {
   println("aquarium water is clean: ${!aquarium.waterSupply.needsProcessing}")
}

TisWaterClean() 的类型参数,用于指定水族馆的通用类型。这种模式很常见,建议您花点时间了解一下。

  1. 调用 isWaterClean() 函数,方法是在函数名之后和尖括号前指定尖括号内指定类型。
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    isWaterClean<TapWater>(aquarium)
}
  1. 由于参数 aquarium 的类型推断,因此该值不是必需的类型,因此请将其移除。运行程序并观察输出结果。
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    isWaterClean(aquarium)
}
⇒ aquarium water is clean: false

第 2 步:使用具体化类型创建通用方法

您也可以对方法使用通用函数,即使在具有自己的通用类型的类中也是如此。在此步骤中,您将向 Aquarium 添加一个通用方法,用于检查其类型为 WaterSupply

  1. Aquarium 类中,声明 hasWaterSupplyOfType() 方法,该方法接受一个通用参数 RT 已采用)限制在 WaterSupply 中,如果 waterSupply 的类型为 R,则返回 true。这与您之前声明的函数类似,但是在 Aquarium 类中。
fun <R: WaterSupply> hasWaterSupplyOfType() = waterSupply is R
  1. 请注意,最后的 R 带有红色下划线。将鼠标指针悬停在相应标记上,以查看错误是什么。
  2. 如需执行 is 检查,您需要告知 Kotlin 类型已具体化或真实,并且可以在函数中使用。为此,请将 inline 放在 fun 关键字前面,将 reified 放在通用类型 R 之前。
inline fun <reified R: WaterSupply> hasWaterSupplyOfType() = waterSupply is R

类型还原后,您可以像普通类型一样使用它,因为它是内嵌之后的真实类型。这意味着您可以使用 类型执行 is 检查。

如果您在此处不使用 reified,则相应类型不够“真实”,足以让 Kotlin 允许 is 检查。这是因为非具体化的类型只在编译时可用,而不能在运行时用到您的程序中。详细内容将在下一节中介绍。

  1. 传递 TapWater 作为类型。与调用通用函数一样,调用通用方法时,应该在函数名称后使用尖括号和类型。运行程序并观察结果。
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.hasWaterSupplyOfType<TapWater>())   // true
}
⇒ true

第 3 步:创建扩展函数

您还可以对具体函数和扩展函数使用具体化类型。

  1. Aquarium 类之外,在 WaterSupply 上定义一个名为 isOfType() 的扩展函数,用于检查传递的 WaterSupply 是否属于某个特定类型,例如 TapWater
inline fun <reified T: WaterSupply> WaterSupply.isOfType() = this is T
  1. 像调用方法一样调用扩展函数。
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.waterSupply.isOfType<TapWater>())  
}
⇒ true

对于这些扩展函数,只要是 Aquarium,即是什么类型(AquariumTowerTank 或一些其他子类)就行了。使用 star-projection 语法可以便捷地指定各种匹配。当您使用星形投影时,Kotlin 会确保您不会执行任何不安全的操作。

  1. 要使用星形投影,请将 <*> 放在 Aquarium 后面。将 hasWaterSupplyOfType() 变为扩展函数,因为它实际上不是 Aquarium 的核心 API 的一部分。
inline fun <reified R: WaterSupply> Aquarium<*>.hasWaterSupplyOfType() = waterSupply is R
  1. 更改对 hasWaterSupplyOfType() 的调用并运行您的程序。
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.hasWaterSupplyOfType<TapWater>())
}
⇒ true

在前面的示例中,您必须将泛型类型标记为 reified 并将函数设为 inline,因为 Kotlin 需要在运行时了解它们,而不仅仅是编译时。

所有通用类型都仅在 Kotlin 的编译过程中使用。这样,编译器就可以确保您可以安全地执行所有操作。在运行时,所有泛型类型都会被清空,因此之前关于检查已清空类型的错误消息。

因此,编译器可以创建正确的代码,而不必在运行时保留通用类型。但这的确意味着您有时会执行编译器不支持的一些操作,例如对通用类型执行 is 检查。因此,Kotlin 才增加了具体化或真实类型。

您可以在 Kotlin 文档中详细了解具体化类型和类型清除

本课程重点介绍泛型,这对于使代码更灵活、更易重复使用至关重要。

  • 创建通用类可使代码更灵活。
  • 添加通用限制条件以限制泛型所使用的类型。
  • inout 类型与泛型一起使用可以更好地进行类型检查,从而限制传入或返回类的类型。
  • 创建通用函数和方法,以便使用通用类型。例如:
    fun <T: WaterSupply> isWaterClean(aquarium: Aquarium<T>) { ... }
  • 使用通用扩展函数为类添加非核心功能。
  • 由于类型清除操作,有时需要使用具体化类型。与泛型类型不同,具体化类型会持续存在。
  • 使用 check() 函数验证您的代码是否按预期运行。例如:
    check(!waterSupply.needsProcessing) { "water supply needs processing first" }

Kotlin 文档

如果您想详细了解本课程中的任何主题,或者遇到问题,最好访问 https://kotlinlang.org

Kotlin 教程

https://try.kotlinlang.org 网站包含丰富的名为 Kotlin Koans 的教程,一种基于网络的解释器,以及一套完整的示例参考文档。

Udacity 课程

如需查看有关此主题的 Udacity 课程,请参阅面向编程人员的 Kotlin 训练营

IntelliJ IDEA

您可以在 JetBrains 网站上找到 IntelliJ IDEA 文档

此部分列出了在由讲师主导的课程中,学生学习此 Codelab 后可能需要完成的家庭作业。讲师自行决定是否执行以下操作:

  • 根据需要布置作业。
  • 告知学生如何提交家庭作业。
  • 给家庭作业评分。

讲师可以酌情采纳这些建议,并且可以自由布置自己认为合适的任何其他家庭作业。

如果您是在自学此 Codelab,可随时通过这些家庭作业来检测您的知识掌握情况。

回答这些问题

问题 1

以下哪一项是通用类型的命名惯例?

<Gen>

<Generic>

<T>

<X>

问题 2

对用于泛型类型的允许类型的限制称为:

▢ 通用限制

▢ 通用约束

▢ 歧义

▢ 通用类型限制

问题 3

具体指:

▢ 已计算对象的实际执行影响。

▢ 已针对类设置受限条目索引。

▢ 通用类型参数已转换为实际类型。

▢ 触发了远程错误指示器。

继续学习下一课:6. 功能操纵

如需大致了解本课程(包括指向其他 Codelab 的链接),请参阅面向编程人员的 Kotlin 训练营:欢迎学习本课程