早在第 8 章介绍集合时,我们就看到了第一个泛型类型:List<String>。然后在第 9 章我们查看 Pair 和 Map 类时,又看到了更多泛型。为了专注于学习集合类型,我们忽略了这些类的细节。不过,我们已经推迟学习它们很久了!终于到了全面理解泛型的时候了,所以请系好安全带!
杯子和饮品
Jennifer 的面包咖啡馆提供美味的甜点和热饮,你可以在精致的桌子上或舒适的椅子上享用。
随着 Jennifer 的生意最近越来越好,她联系了正在学习 Kotlin 的哥哥 Eric,请他帮她模拟运营。Eric 决定从饮品菜单开始,其中包括浅度、中度和深度烘焙咖啡,顾客会用陶瓷杯享用。他决定使用枚举类来表示咖啡选项,并使用一个简单的类
enum class Coffee { LIGHT_ROAST, MEDIUM_ROAST, DARK_ROAST }
class Mug(val beverage: Coffee)
fun drink(coffee: Coffee) = println("Drinking coffee: $coffee")现在,每当有顾客点咖啡时,Eric 只需实例化一个装有正确类型咖啡的 Mug 即可。
val mug = Mug(Coffee.LIGHT_ROAST)当他喝一口咖啡时,Eric 可以用杯子里的饮品调用 drink() 函数。
drink(mug.beverage) // Drinking coffee: LIGHT_ROAST有一天,Jennifer 决定是时候扩展她的饮品菜单,增加热茶了。和咖啡一样,Eric 选择将这些新的茶选项建模为枚举类。他还创建了一个接受茶的 drink() 函数的重载版本。
enum class Tea { GREEN_TEA, BLACK_TEA, RED_TEA }
fun drink(tea: Tea) = println("Drinking tea: $tea")Mug 类也需要一些工作。毕竟,你不能将 Tea 的实例传递给只接受 Coffee 的 Mug。Eric 想了想。”好吧,我想我可以为茶创建另一个杯子类。”于是他把 Mug 重命名为 CoffeeMug,并添加了另一个名为 TeaMug 的类。
class CoffeeMug(val coffee: Coffee)
class TeaMug(val tea: Tea)就在他打完字的时候,Jennifer 走到他面前说:”你有没有听说过我下周要扩展饮品菜单?我会增加热巧克力和苹果酒!”
Eric 做了个鬼脸。”我姐姐在菜单上添加的饮品越多,我就越需要创建更多的杯子类。情况很快就会失控。我怎样才能创建一个可以容纳任意类型饮品的 Mug 类?”
于是他想到,子类型可以在编译器期望超类型的地方使用。”就是这样!”他喊道,”我要创建一个表示饮品一般概念的接口,并更新茶和咖啡类,使它们成为子类型!”他最近刚学了密封类型,决定将 Beverage 接口设为密封的。他还更新了 Mug 类,将其属性的类型设为 Beverage。以下是他最终的结果。
sealed interface Beverage
enum class Tea : Beverage { GREEN_TEA, BLACK_TEA, RED_TEA }
enum class Coffee : Beverage { LIGHT_ROAST, MEDIUM_ROAST, DARK_ROAST }
class Mug(val beverage: Beverage)做了这个更改之后,他能够创建咖啡或茶的 Mug 实例。
val mugOfCoffee = Mug(Coffee.LIGHT_ROAST)
val mugOfTea = Mug(Tea.BLACK_TEA)”太棒了!我有了一个可以容纳任意类型饮品的杯子类!”然而,当他运行 Kotlin 代码时,却惊出一身冷汗——编译器报错了——对 drink() 函数的调用不再工作了!
fun drink(coffee: Coffee) = println("Drinking coffee: $coffee")
fun drink(tea: Tea) = println("Drinking tea: $tea")
drink(mugOfCoffee.beverage)
drink(mugOfTea.beverage)为什么不能工作?让我们仔细看看。
声明类型、实际类型和赋值兼容性
val coffee: Coffee = Coffee.MEDIUM_ROAST
val beverage: Beverage = Coffee.MEDIUM_ROAST
val anything: Any = Coffee.MEDIUM_ROAST因此,变量的类型和该变量内部对象的类型之间可能存在差异。这引出了几个区分的术语。
- 变量声明时使用的类型称为其声明类型(declared type)。
- 变量内部对象的最具体类型称为其实际类型(actual type)或运行时类型(runtime type)。
当我们将对象赋值给变量、属性或参数时,实际类型或运行时类型都无关紧要。只有声明类型才是重要的。例如,在下面的代码清单中,第二行会失败。
val beverage: Beverage = Coffee.MEDIUM_ROAST
val coffee: Coffee = beverage尽管你我阅读这段代码时都知道,beverage 变量内部的对象在运行时的实际类型肯定是 Coffee,但它的声明类型是 Beverage。而且由于类型为 Beverage 的变量可能持有 Coffee 以外的对象——例如它可以持有 Tea 对象——Kotlin 不允许我们执行此赋值。
为了使赋值成功,等号右侧表达式的类型必须与左侧的声明类型相同,或是其子类型之一。
赋值兼容性(Assignment compatibility)是我们用来描述右侧对象是否可以赋值给左侧变量类型的术语。当它可以被赋值时,我们说该对象与该类型赋值兼容(assignment-compatible)。
尽管我们一直在讨论涉及等号字面值的赋值,但需要注意的是,当我们用参数调用函数时,我们实际上是在将对象赋值给函数的参数,因此所有相同的规则都适用。
让我们再看看 Eric 代码的相关部分。
fun drink(coffee: Coffee) = println("Drinking coffee: $coffee")
fun drink(tea: Tea) = println("Drinking tea: $tea")
class Mug(val beverage: Beverage)
val mugOfCoffee = Mug(Coffee.LIGHT_ROAST)
drink(mugOfCoffee.beverage)无论 beverage 属性在运行时的实际类型是什么(例如本例中的 Coffee),它既不能赋值给类型为 Coffee 的参数,也不能赋值给类型为 Tea 的参数,因为它的声明类型是 Beverage。换句话说,beverage 与任何一个 drink() 重载都不赋值兼容。这就是 Eric 的代码失败的原因。
为了让代码能够无错误地编译,他需要将 beverage 属性强制转换回 Coffee 类型,以使其与其中一个 drink() 重载赋值兼容。
val mugOfCoffee = Mug(Coffee.LIGHT_ROAST)
drink(mugOfCoffee.beverage as Coffee)虽然这样可以工作,但这意味着每次 Eric 从 Mug 获取 beverage 属性时,如果需要将其赋值给更具体的 Coffee 或 Tea 类型,他就需要进行强制转换。每次!
如果他能有一个可以处理任何类型 Beverage 的单一 Mug 类,并且避免一直对 beverage 属性进行强制转换,那就太好了。
幸运的是,这个问题可以用泛型类型来解决!
泛型类型简介
声明泛型类型
很久以来,我们一直利用函数>来复用表达式。通过用不同的实参>调用它们,我们得到不同的结果。例如,我们在清单 2.3>中创建了这个函数来计算圆的周长。
fun circumference(radius: Double) = 2 * pi * radius带参数的函数有点像填空表达式。
每次调用它时,你都会给它一个实参来填入那个空白。
所以函数的参数本质上是一个可以用值填充的空白。
如果类型也有类似的东西呢?例如,如果 beverage 属性的类型是一个我们可以填充不同类型的空白,那会怎么样?
想象一下我们可以在那里放置的所有不同类型!
嗯,就像函数可以有参数一样,类型也可以有类型参数(type parameter)。
要给类添加类型参数,我们可以将类型参数的名称放在类名右侧的尖括号 < 和 > 内,就像这样:
这里我们将类型参数命名为 BEVERAGE_TYPE,但更常见的做法是只用一个字母来命名,比如 T。
class Mug<T>(val beverage: T)这样,Mug 类现在可以与不同的类型一起使用了!让我们看看接下来该怎么做。
使用泛型类型
当我们调用带有参数的函数时,必须为该参数提供一个实参。同样,在创建 Mug 实例时,我们必须为名为 T 的类型参数提供一个类型实参(type argument)。为此,我们可以将类型实参放在类名旁边的尖括号中,就像这样:
就像用实参调用函数实际上等同于用该值替换参数一样,用类型实参创建类型也非常像用该类型填充类型参数。1
虽然构造泛型类时必须提供类型实参,但我们通常不需要自己写出来,因为 Kotlin 可以使用它的类型推断来推断出来。例如,由于 Mug 的构造函数接受一个类型为 T 的参数,Kotlin 知道如果你用 Mug 创建一个实例并传递一个 Coffee 对象,那么这个 Mug 实例的类型实参应该是 Coffee。
请注意,这个 mug 变量的类型不是 Mug<T>,而是 Mug<Coffee>,而且我们可以像这样显式指定>其类型:
val mug: Mug<Coffee> = Mug(Coffee.LIGHT_ROAST)能够区分 Mug<T> 和 Mug<Coffee> 是很重要的。具有类型参数的类(如 Mug<T>)称为泛型类型(generic type)。类型参数已被填充类型实参的泛型称为参数化类型(parameterized type)。
使 Mug 类成为泛型的好之处在于,beverage 属性将保留其特定类型。
- 当从
Mug<Coffee>获取beverage属性时,其类型将为Coffee。 - 当从
Mug<Tea>获取beverage属性时,其类型将为Tea。
正因为如此,在使用期望特定类型的代码时不需要对其进行强制转换!例如,在下面的代码中,我们不需要将 beverage 属性强制转换为 Tea,因为它已经是该类型了。所以,对 drink(tea: Tea) 的调用完全可以正常工作。
val mug: Mug<Tea> = Mug(Tea.GREEN_TEA)
drink(mug.beverage)Eric 做了所有这些更改后,顾客们得以享用他们的茶和咖啡。他能够使用单一的 Mug 类,而且不需要对 beverage 属性进行强制转换。
不过,他即将在代码中发现一些惊喜!让我们看看接下来发生了什么。
类型参数约束
有一天,Jennifer 决定将咖啡馆所有的陶瓷杯换成时髦的新型温度控制杯,这种杯子可以让顾客的茶和咖啡保持在最佳饮用温度。
她看着 Eric 说:”杯子需要根据里面的饮品来设置温度。如果杯子里是茶,就应该把温度设置为 140 度。如果里面是咖啡,那就应该只设置为 135 度。”
Eric 挽起袖子开始工作。他更新了所有的 Beverage 类型,让咖啡和茶都可以有自己理想的温度。
sealed interface Beverage {
val idealTemperature: Int
}
enum class Tea : Beverage {
GREEN_TEA, BLACK_TEA, RED_TEA;
override val idealTemperature = 140
}
enum class Coffee : Beverage {
LIGHT_ROAST, MEDIUM_ROAST, DARK_ROAST;
override val idealTemperature = 135
}接下来,为了表示温度控制杯,他在 Mug 类中添加了一个新的 temperature 属性,并将其设置为饮品的理想温度。令他惊讶的是,他最终得到了一个编译时错误!
class Mug<T>(val beverage: T) {
val temperature = beverage.idealTemperature
}看起来 Mug 类无法看到他添加的新的 idealTemperature 属性。当 Eric 正在疑惑这件事时,一位顾客走向 Jennifer,问为什么他的杯子里有一根绳子!
”绳子?这应该是饮品啊!” Jennifer 喊道。她瞪了一眼她的哥哥,Eric 看向顾客。确实如此,他的杯子里有一根绳子!Eric 低头看着电脑屏幕,又敲了一些代码。果然,把 String 放进 Mug 是可能的。事实上,按照目前 Mug 代码的写法,几乎任何东西都可以被塞进去!
val mugOfString: Mug<String> = Mug("How did this get in the mug?")
val mugOfInt: Mug<Int> = Mug(5)
val mugOfBoolean: Mug<Boolean> = Mug(true)
val mugOfEmptiness: Mug<Any?> = Mug(null)所以 Eric 现在有两个问题:
幸运的是,这两个问题的解决方案是相同的——Eric 需要约束类型参数,这样就只有 Beverage 类型可以放入其中。
为此,他可以添加类型参数约束(type parameter constraint),这将确保类型实参是特定类型。为此,在类型参数名称后添加冒号和作为该参数上界(upper bound)的类型名称。例如,要添加 Beverage 的上界约束,我们可以这样写。
通过此更改,T 只能是 Beverage 或其子类型之一——Tea 或 Coffee。
使用任何其他类型作为类型实参都会导致编译器错误。
val mugOfString: Mug<String> = Mug("This won't work any more!")此外,既然 Kotlin 知道 beverage 将是某种 Beverage,我们就可以访问 Beverage 类型包含的任何属性或函数!因为 Beverage 类型包含 idealTemperature 属性,所以下面的代码现在可以工作了。
class Mug<T : Beverage>(val beverage: T) {
val temperature = beverage.idealTemperature
}如果我们不指定上界,Kotlin 会假设默认为 Any?,这意味着它将接受任何类型,正如我们在清单 18.19中看到的。如果我们确切地知道只有某些类型应该用于类型实参,通常最好给它一个类型参数约束,以便编译器强制执行这些规则。
好了,我们已经从 Jennifer 的面包咖啡馆学到了很多关于泛型的知识。不过,泛型还有更多用途!让我们来看看它们的其他用法。
实践中的泛型
使用类型参数
到目前为止,我们只将类型参数用于属性参数>,但我们可以在类体中任何可以写类型的地方使用类型参数。正如这里所示,它们经常用于函数参数和返回类型。
class Dish<T>(private var food: T) {
fun replaceFood(newFood: T) {
println("Replacing $food with $newFood")
food = newFood
}
fun getFood(): T = food
}多类型参数的泛型
到目前为止,我们的示例只包含一个类型参数,但一个类可以有多个类型参数。例如,一个组合订单可以包含一个用于食物的类型参数和一个用于饮品的类型参数。在声明时,只需为每个类型参数使用不同的名称,并用逗号分隔,就像这样。
class ComboOrder<T : Food, U : Beverage>(val food: T, val beverage: U)
val combo: ComboOrder<Pastry, Tea> =
ComboOrder(Pastry.MUFFIN, Tea.GREEN_TEA)不过,不要滥用它!在泛型类型中使用超过两个或三个类型参数是很少见的。2
泛型接口和超类
除了类,接口也可以是泛型的。例如,这里有一个带一个属性的泛型接口。
interface Dish<T> { val food: T }在实现这个类时,我们可以使用实际类型替换类型参数。例如,我们可以像这样创建一个 BowlOfSoup 类和实例。
class BowlOfSoup(override val food: Soup) : Dish<Soup>
val bowlOfSoup: BowlOfSoup = BowlOfSoup(Soup.TOMATO)或者,实现类本身可以声明一个类型参数,并将其传递给接口,如下所示。
class Bowl<F>(override val food: F) : Dish<F>
val bowlOfSoup: Bowl<Soup> = Bowl(Soup.TOMATO)类似地,抽象类和开放类也可以是泛型的,扩展它们的工作方式与你期望的一样。
abstract class Dish<T>(val food: T)
class BowlOfSoup(food: Soup) : Dish<Soup>(food)
class Bowl<F>(food: F) : Dish<F>(food)在调用超类构造函数时,一定要提供类型实参。同样,该类型实参可以是子类上的类型参数,就像此代码清单第三行中的情况一样。
泛型函数
我们已经看到了类和接口如何成为泛型的,以及它们的函数如何利用这些类型参数。然而,函数也可以声明它们自己的类型参数。这对于顶级函数(那些在类外部声明的函数)尤其常见。
在这种情况下,类型参数声明在 fun 关键字和函数名之间。例如,我们可以创建一个包装 Mug 构造函数的顶级函数。
fun <T : Beverage> serve(beverage: T): Mug<T> = Mug(beverage)调用此函数时,我们可以通过将其放在函数名右侧的尖括号中来显式指定类型实参。
val mug = serve<Coffee>(Coffee.DARK_ROAST)但同样,Kotlin 通常可以推断类型,在这种情况下你可以省略它。
val mug = serve(Coffee.DARK_ROAST)我们甚至可以创建泛型扩展函数,其中接收者>类型是一个类型参数。例如,我们可以将 serve() 函数改写为扩展函数,如下所示:
fun <T : Beverage> T.pourIntoMug() = Mug(this)
val mug = Coffee.DARK_ROAST.pourIntoMug()现在我们对泛型的可能性有了很好的理解。Kotlin 的标准库也包含许多泛型类型和函数,我们在这本书中已经看到了一些。现在我们更了解了什么是泛型以及它们如何工作,让我们来回顾一下其中的一些!
标准库中的泛型
List 和 Set
回顾一下第 8 章>,我们使用 listOf() 和 mutableSetOf() 等泛型函数创建了 List 和 Set 集合。作为复习,这里是我们如何使用 listOf() 函数创建菜单项列表的方法。
val menu = listOf("Bagel", "Croissant", "Muffin", "Crumpet")与上面的 serve() 函数非常类似,listOf() 函数中的代码最终会调用一个名为 ArrayList 的泛型类的构造函数。ArrayList 类实现了 List 接口,该接口也是泛型的。
大多数时候,我们只是让 Kotlin 的类型推断为我们填充类型实参,但我们可以像这样显式指定它们:
val menu: List<String> = listOf<String>("Bagel", "Croissant", "Muffin", "Crumpet")listOf() 函数的类型实参是 String(无论我们是显式指定它还是让 Kotlin 根据函数的参数推断类型)。此函数的返回类型取决于调用时使用的类型实参。由于使用 String 类型实参调用,因此返回类型是 List<String>。
Pair
Pair 是一个泛型数据类>,我们在第 9 章>中遇到过它。它有两个类型参数,决定了它包含的两个元素的类型。你可能还记得,我们可以通过调用它的构造函数来创建 Pair 的实例:
val pair = Pair("Crumpet", "Tea")同样,我们大多数时候使用类型推断,但我们可以像这样显式指定两个类型实参。
val pair: Pair<String, String> = Pair<String, String>("Crumpet", "Tea")这个清单看起来比前面的清单更吓人,所以通常最好让 Kotlin 的类型推断来处理。
创建 Pair 的第二种方法是使用 to() 中缀函数,如下所示:
val pair = "Crumpet" to "Tea"to() 函数是一个泛型扩展函数,类似于我们上面创建的 pourIntoMug() 函数,只是它有两个类型参数。to()> 函数的存在是为了让我们的代码更自然地阅读,所以在调用它时不应该显式指定类型实参。不过,为了帮助揭开这个魔法的神秘面纱,这是你可以这样做的方式。
val pair = "Crumpet".to<String, String>("Tea")我们已经看到了泛型如何帮助我们、如何创建它们、如何使用它们,以及在标准库中使用它们的一些例子。在结束本章之前,让我们看看使用它们时涉及的一些权衡。
泛型的权衡
正如我们所看到的,泛型是一种重用类或接口的好方法,无需强制转换其属性或函数结果。不过,它们有一些权衡!以下是一些需要考虑的权衡。
泛型类型的赋值兼容性
回顾一下清单 18.6>,我们有一个非泛型的 Mug 类版本。它是这样的:
class Mug(val beverage: Beverage)使用此代码,任何 Mug 对象都可以赋值给任何 Mug 变量,无论它持有哪种饮品。例如,我们可以声明一个 Mug 变量,并为其分配一杯咖啡或一杯茶。
val mugOfCoffee: Mug = Mug(Coffee.DARK_ROAST)
val mugOfTea: Mug = Mug(Tea.RED_TEA)
var mug: Mug = mugOfCoffee
mug = mugOfTea现在让我们考虑这个类的泛型版本。
class Mug<T : Beverage>(val beverage: T)使用此类时,我们将得到参数化类型,例如 Mug<Coffee> 和 Mug<Tea>。默认情况下,这些参数化类型不可赋值兼容。例如,不能将 Mug<Tea> 的实例赋值给用 Mug<Coffee> 声明的变量。
val mugOfCoffee: Mug<Coffee> = Mug(Coffee.DARK_ROAST)
val mugOfTea: Mug<Tea> = Mug(Tea.RED_TEA)
var mug: Mug<Coffee> = mugOfCoffee
mug = mugOfTea这并不令人惊讶。然而,让一些开发人员感到惊讶的是,也不可以将 mugOfCoffee 或 mugOfTea 赋值给具有 Mug<Beverage> 类型的变量。
val mugOfCoffee: Mug<Coffee> = Mug(Coffee.DARK_ROAST)
val mugOfTea: Mug<Tea> = Mug(Tea.RED_TEA)
var mug: Mug<Beverage> = mugOfCoffee有一些方法可以解决这个问题,我们将在下一章中看到!请注意,当直接从构造函数调用赋值时,这个赋值确实可以工作。
val mug: Mug<Beverage> = Mug(Coffee.DARK_ROAST)类型擦除
也许最重要的权衡被称为类型擦除。虽然对象的类型实参在编译时是已知的,但它们在代码运行之前就被擦除了。换句话说,对象的类型实参在运行时是不知道的。
让我们看几个类型擦除如何影响代码的例子。
检查类型实参的类型
类型擦除的一个后果是,不可能使用 is 在运行时检查参数化类型的类型实参的类型。例如,下面的代码试图确定 mug> 实例是 Mug<Tea> 类型还是 Mug<Coffee> 类型。这会导致编译器错误。
val mug: Mug<Beverage> = Mug(Coffee.MEDIUM_ROAST)
when (mug) {
is Mug<Tea> -> println("Sipping on tea: ${mug.beverage}!")
is Mug<Coffee> -> println("Sipping on coffee: ${mug.beverage}!")
}但是,仍然可以检查用类型参数声明的属性>(如 beverage)的类型,所以这完全可以工作:
val mug: Mug<Beverage> = Mug(Coffee.MEDIUM_ROAST)
when (mug.beverage) {
is Tea -> println("Sipping on tea: ${mug.beverage}!")
is Coffee -> println("Sipping on coffee: ${mug.beverage}!")
}JVM 上的函数重载
Kotlin 代码可以面向不同类型的计算机系统和环境。大多数情况下,Kotlin 项目面向 Java 虚拟机(JVM),如果你一直在跟随本书中的代码进行操作,这可能就是你一直在做的。然而,你也可以使用 Kotlin 创建在不同平台上本机运行的程序,如 Windows、Linux、Mac 等。事实上,你甚至可以创建编译成 JavaScript 的 Kotlin 代码!
偶尔会有一些限制影响某些平台,而不影响其他平台。在类型擦除方面,面向 JVM 的 Kotlin 代码有一个不影响原生或 JavaScript 目标的限制:不可能使用函数重载>,其中函数的参数仅在它们的类型实参上有所不同。例如,在下面的代码中,这两个函数签名之间的唯一区别是它们参数的类型实参。在 JVM 上编译此代码会产生错误。
即使有这些权衡,泛型也非常有用,所以了解它们很重要!
总结
随着季节的变化,Jennifer 面包咖啡馆的菜单也在变化,但多亏了 Eric 对 Kotlin 泛型的新理解,适应这些变化变得轻而易举!现在你已经了解了泛型的一切,你也能够适应变化!以下是我们所学内容的快速回顾:
正如我们在本章中看到的,泛型的子类型化并不总是像我们期望的那样工作——Mug<Coffee> 不是天然的 Mug<Beverage> 子类型。然而,通过一些小的改动,我们可以实现这一点。敬请期待下一章,我们将讨论泛型型变这个引人入胜的话题!
-
请注意,插图的右侧显示了”有效的”类会是什么样子。换句话说,通过创建一个
Mug<Coffee>类型,就好像我们声明了另一个名为Mug<Coffee>的类,其beverage类型为Coffee。然而,我们实际上并不写那个代码——通过在清单 18.14>中创建泛型类,Kotlin 已经拥有了创建类型所需的一切! ↩︎ -
偶尔你可能会发现一个库在单个泛型类型中有许多类型参数。例如,Arrow 函数式编程库包含一个名为
Tuple10的类,它包含十个类型参数。不过,大多数时候,你自己的代码不需要那么多。 ↩︎