面向编程人员的 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) {}
}

您可以像引用普通类型一样引用 Tget() 的返回类型为 T,而 addItem() 的形参类型为 T。当然,泛型列表非常有用,因此 List 类内置于 Kotlin 中。

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

在此步骤中,您将创建一些类,以便在下一步中使用。我们曾在之前的 Codelab 中介绍过子类化,但这里会简要回顾一下。

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

open class WaterSupply(var needsProcessing: Boolean)

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

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

如果您需要更多信息,请回顾之前关于 Kotlin 中继承的课程。

第 2 步:创建泛型类

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

  1. Aquarium.kt 中,定义一个 Aquarium 类,并在类名后用方括号添加 <T>
  2. Aquarium 添加一个类型为 T 的不可变属性 waterSupply
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 的实参推断出来;它仍会生成 TapWater 类型的 Aquarium
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

结果是您传递的字符串,因为 AquariumT. 没有任何限制,包括 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 后的 ?,将 T 显式设为 Any 类型。
class Aquarium<T: Any>(val waterSupply: T)

在此上下文中,Any 称为泛型约束。这意味着,只要不是 null,任何类型都可以作为 T 传递。

  1. 您真正想要的是确保只有 WaterSupply(或其子类之一)可以作为 T 的实参传递。将 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() 中,添加代码以创建具有 LakeWaterAquarium,然后向其中添加一些水。
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(),该函数需要一个 WaterSupplyAquarium
fun addItemTo(aquarium: Aquarium<WaterSupply>) = println("item added")
  1. genericsExample() 调用 addItemTo() 并运行程序。
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    addItemTo(aquarium)
}
⇒ item added

Kotlin 可以确保 addItemTo() 不会对泛型 WaterSupply 执行任何类型不安全的操作,因为 addItemTo() 被声明为 out 类型。

  1. 如果您移除 out 关键字,编译器会在调用 addItemTo() 时抛出错误,因为 Kotlin 无法确保您不会对该类型执行任何不安全的操作。

第 2 步:定义 in 类型

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

  1. Aquarium.kt 中,定义一个接口 Cleaner,该接口采用受限于 WaterSupply 的泛型 T。由于它仅用作 clean() 的实参,因此您可以将其设为 in 形参。
interface Cleaner<in T: WaterSupply> {
    fun clean(waterSupply: T)
}
  1. 如需使用 Cleaner 接口,请创建一个实现 Cleaner 的类 TapWaterCleaner,用于通过添加化学物质来清洁 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 中,创建一个接受 Aquarium 的函数 isWaterClean()。您需要指定形参的泛型类型;一种方法是使用 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(),该方法接受一个约束为 WaterSupply 的泛型形参 RT 已使用),如果 waterSupply 的类型为 R,则返回 true。这与您之前声明的函数类似,但位于 Aquarium 类内。
fun <R: WaterSupply> hasWaterSupplyOfType() = waterSupply is R
  1. 请注意,最后的 R 带红色下划线。将指针悬停在相应图标上,即可查看错误内容。
  2. 如需执行 is 检查,您需要告知 Kotlin 该类型是具体化的(或真实的),并且可以在函数中使用。为此,请在 fun 关键字前面放置 inline,并在泛型类型 R 前面放置 reified
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 或其他某个子类),只要它是 Aquarium 即可。使用星号投影语法可以方便地指定各种匹配项。使用星投影时,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 训练营:欢迎来到本课程”