1. 上一章:第 18 章
  2. 下一章:第 20 章
Kotlin 图解指南 • 第 19 章

泛型型变

章节封面图片

正如我们在第 12 章中所学,我们可以在声明父类型的任何地方用子类型进行替换

UML 类图图示 List<Cow> MutableList<FarmAnimal> MutableList<Cow>

然而,当涉及泛型时,事情并不总是如我们预期的那样运作。虽然 MutableList<Cow>List<Cow> 的子类型,但它不是 MutableList<FarmAnimal> 的子类型。

然而,通过一点技巧,我们可以将参数化类型变形成我们需要的子类型。为此,我们需要先学习协变逆变,让我们开始吧!

协变

一份已签署的合同,表明接受硬币并退回零食

Parker 在当地的公园和娱乐部门工作。由于操场上经常满是饥饿的孩子们的父母,他想:”让我们在凉亭里放一台自动售货机吧。”于是他找到了 Vinnie,一个在附近经营自动售货机设置和填充业务的人。

”我需要一台自动售货机,让孩子们可以投入硬币并获得零食”,Parker 说。Vinnie 同意了,于是他们签订了一份合同。

文件签署后,Vinnie 在公园安装了新的自动售货机,顾客可以投入硬币并获得随机零食,如坚果混合物、一袋软糖或一根糖果棒。第二天,当 Parker 看到一个孩子投入五美分并从机器里拿到一袋坚果混合物时,他暗自微笑,知道一切正常。

Parker 看着一个孩子因拿到一袋坚果而微笑。

几周后,Parker 看到 Vinnie 在更换机器。Parker 对这个变化有点紧张,于是和他交谈。”这台新机器仍然会接受硬币退回零食,对吧?”

”是的”,Vinnie 开始说,”不过坚果混合物和软糖短缺了,所以这台自动售货机只能退回糖果棒。这仍然可以,对吧?”

Parker 想了想,最后回答说:”只要它接受硬币退回零食就行。糖果棒是一种零食,所以应该没问题。”

新机器替换旧机器后,一个孩子走过来,投入硬币,并收到了糖果棒。Parker 微笑了,很高兴新机器做了它应该做的事。

Parker 松了一口气,自动售货机仍在正常工作。一个孩子对他的糖果棒感到满意。

几周过去了。有一天,Vinnie 又一次在更换机器,这次换成了另一台。安装完成几分钟后,一位愤怒的父亲向 Parker 抱怨:”新自动售货机怎么了?我饥饿的孩子投入硬币后没有收到零食,而是收到了一个玩具玩偶——他不能吃玩具!

一位父亲对 Parker 感到愤怒,因为他的儿子正试图吃一个玩偶。

Parker 很快又追上 Vinnie 询问新机器的情况。Vinnie 回答说:”哦,我想用一台要么退回零食要么退回玩具的新机器替换旧的自动售货机。”

”不,不,不!合同上说机器应该接受硬币并退回零食。玩具不是零食!”意识到新机器没有做 Parker 需要的事,Vinnie 同意解决这个问题。

协变与替换

从这个故事我们可以看到,有些替换是有效的,但有些则不是。

  • 当 Vinnie 用一台只退回糖果棒的机器替换原始自动售货机时,一切正常,因为糖果棒仍然是孩子们可以吃的一种零食。
  • 然而,当他换成一台要么退回零食要么退回玩具的机器时,他违反了合同——机器不再只退回零食。
回顾三个自动售货机,展示哪些有效,哪些无效。

这为我们引出了在 Kotlin 中探索替换的一些重要观点。让我们从建模 Vinnie 提供的第一台自动售货机开始——那台接受硬币并退回零食的机器。

open class VendingMachine {
    open fun purchase(money: Coin): Snack = randomSnack()
}

虽然本章不会包含 Snack 及其相关类型的定义,但这里有一个类图显示了它们及其类型层次结构。

Product、Snack 和 Toy 的 UML 类图 ActionFigure TrailMix GummyBears CandyBar Toy BouncyBall Sticker Product Snack

现在让我们通过扩展上面的自动售货机来建模 Vinnie 的第二台自动售货机。这台机器看起来与上面几乎一模一样,但不再返回随机零食,只返回糖果棒。

class CandyBarMachine : VendingMachine() {
    override fun purchase(money: Coin): Snack = CandyBar()
}

任何期望 VendingMachine 的代码都可以使用 CandyBarMachine,因为它是 VendingMachine 的子类型——它仍然接受硬币退回零食,就像 VendingMachine 一样。正因如此,我们可以将 CandyBarMachine 替换 VendingMachine。换句话说,我们可以将 CandyBarMachine 赋值给声明为 VendingMachine 的变量。

子类型的实例可以赋值给声明为其父类型之一的变量。 val machine: VendingMachine = CandyBarMachine () supertype subtype

同样,我们可以将它作为参数传递给一个期望 VendingMachine 的函数。

子类型的实例可以作为参数传递给期望其父类型之一的函数。 fun purchaseSnackFrom (machine: VendingMachine) = machine. purchase ( Dime ()) val snack = purchaseSnackFrom ( CandyBarMachine ()) supertype subtype

正如我们在故事中所见,CandyBarSnack 的一种,所以声明 CandyBarMachine 只返回 CandyBar 对象也是安全的。让我们取出清单 19.2的代码并更新返回类型。

class CandyBarMachine : VendingMachine() {
    override fun purchase(money: Coin): CandyBar = CandyBar()
}

正如故事所揭示的,这只能单向工作。当 Vinnie 试图用一台返回可能是零食玩具的产品的机器替换自动售货机时,他违反了合同。孩子们期望能够吃自动售货机提供的任何东西,但玩具是不能吃的!

同样的原则在 Kotlin 中也适用——子类不能返回更通用的类型。例如,如果我们创建一个试图返回任何类型 ProductToyOrSnackMachine,将会收到编译时错误。

class ToyOrSnackMachine : VendingMachine() {
    override fun purchase(money: Coin): Product = randomToyOrSnack()
}
Error

大多数类和接口与其他类型关联。关联类型可以出现在多种位置——作为函数参数类型、函数返回类型、属性类型等。例如,VendingMachine 类与另外两个类型关联:

  • Coin —— purchase() 中参数的类型
  • Snack —— purchase() 的返回类型
类类型 VendingMachine 及其两个关联类型 Coin 和 Snack。 open class VendingMachine { open fun purchase (money: Coin): Snack = randomSnack () } class type associated type associated type

VendingMachine 类可以有父类型和子类型。同样,关联类型 CoinSnack 也可以有自己的父类型和子类型。

VendingMachine 的类型层次结构与其关联类型层次结构之间关系的性质可以用型变来描述。当我们并排查看 VendingMachineCandyBarMachine 的代码时,可以看到这种关系显现出来。

CandyBarMachine 是 VendingMachine 的子类型,而 CandyBar 是 Snack 的子类型。 open class VendingMachine { open fun purchase (money: Coin): Snack = } class CandyBarMachine : VendingMachine () { override fun purchase (money: Coin): CandyBar = } is a subtype of is a subtype of

具体来说,我们可以看到,当我们创建一个更具体VendingMachine 时,我们可以从 purchase() 函数返回更具体Snack。因为它们一起变得更具体,这种型变称为协变,其中 “co-” 是表示”一起”的前缀。谈到型变时,开发者通常会用以下方式之一来表达:

  • ”一个类型在其返回类型方面是协变的”
  • ”一个类型在其返回类型上是协变的”

总结本节,只需记住,在子类型(例如 CandyBarMachine)中,函数可以返回比在父类型中声明的更具体的类型(例如 CandyBar),但不能返回更通用的类型。

Parker 和 Vinnie 的冒险还在继续!做好准备——Vinnie 即将尝试替换更多自动售货机!

逆变

几周后,Parker 看到 Vinnie 又一次在更换机器。”我们正在升级我们的机器,使它们既能接受硬币也能接受纸币,”他解释道。

Parker 想了想。”好吧,我觉得没问题。只要机器仍然接受硬币退回零食就行。”新机器安装后,一个孩子走过来,投入一枚硬币,并从机器里得到了零食。”太好了,”Parker 心想,”一切仍然正常”。

接下来的几周一切顺利。尽管这台新的自动售货机有纸币接收器,但没有一个孩子使用过它,因为他们只有硬币。

几个孩子拿着硬币排队使用新的自动售货机。

你猜怎么着——几周后,Parker 看到 Vinnie 又一次在更换机器。Parker 耸耸肩继续走他的路。

然而,十分钟后,一个小女孩在零食机旁哭泣。”怎么了?”他问她。

一个小女孩在自动售货机旁哭泣,Parker 感到困惑。

”我想要零食。我有五美分硬币,但这台机器只接受一角硬币!”Parker 看了看新的自动售货机,果然,它有一个一角硬币的插槽,不接受任何其他类型的硬币。

Parker 再次向 Vinnie 抱怨。”不,不,不!合同上说机器应该接受硬币退回零食。五美分硬币是一种硬币,所以机器仍然需要接受它!”尴尬的 Vinnie 把之前的自动售货机放回原处。

逆变与替换

这个故事再次表明,有些替换是有效的,有些则不是。

  • 当 Vinnie 用一台既能接受硬币又能接受纸币的机器替换原始自动售货机时,一切仍然正常,因为它仍然接受硬币。孩子们身上只有硬币,所以他们从未使用过纸币接收器,但只要机器仍然能接受硬币,拥有纸币接收器也没有坏处。
  • 然而,当 Vinnie 换成只接受一角硬币的机器时,他违反了合同——机器不再接受五美分硬币、二十五美分硬币或任何其他类型的硬币。
对本节中自动售货机的回顾,显示哪个有效,哪个无效。

这说明了一些关于替换的额外要点。让我们看看我们在清单 19.1中创建的原 VendingMachine

open class VendingMachine {
    open fun purchase(money: Coin): Snack = randomSnack()
}

Snack 一样,本章不会包含 Coin 及其类型层次结构的任何类定义,但这里有一个 UML 类图显示了它们的关系。

Money、Coin 和 Bill 的 UML 类图 TenDollar FiveDollar OneDollar Quarter Dime Nickel Bill Coin Money

如我们所见,Vinnie 用一台既能接受硬币也能接受纸币的机器替换基于硬币的自动售货机是安全的。同样,Kotlin 允许子类型声明更通用的参数类型也应该是安全的。

但猜猜怎么了?如果我们尝试将参数类型从 Coin 更改为 Money,则会收到编译器错误!

class AnyMoneyVendingMachine : VendingMachine() {
    override fun purchase(money: Money): Snack = randomSnack()
}
Error

同样,这对于 Kotlin 的类型系统来说是完全安全的。为什么这会导致编译器错误?

你可能记得,Kotlin 允许我们重载一个函数——一个类或接口可以有多个具有相同名称的函数,只要它们的参数类型不同。为了支持此功能,Kotlin 不允许我们更改子类型中函数参数的类型,就像我们在上面尝试做的那样。

解决这个问题的一种方法是简单地去掉 override 关键字,这意味着我们将重载该函数而不是重写它。

class AnyMoneyVendingMachine : VendingMachine() {
    fun purchase(money: Money): Snack = randomSnack()
}

请记住,当我们这样做时,我们最终会有两个 purchase() 函数,每个都有其自己的函数体——一个在 AnyMoneyVendingMachine 中,一个在 VendingMachine 中。

所以 Kotlin 的重载功能正在妨碍我们。不过,重载仅适用于使用 fun 关键字声明的函数,因此我们可以将 purchase() 从函数更改为具有函数类型的属性来解决这个问题,像这样。

open class VendingMachine {
    open val purchase: (Coin) -> Snack = { randomSnack() }
}

通过此更改,我们可以重写 AnyMoneyVendingMachine 来重写该属性。

class AnyMoneyVendingMachine : VendingMachine() {
    override val purchase: (Coin) -> Snack = { randomSnack() }
}

最后,我们可以用 Money 替换 Coin 参数类型。

class AnyMoneyVendingMachine : VendingMachine() {
    override val purchase: (Money) -> Snack = { randomSnack() }
}

通过这个修改,我们可以将 AnyMoneyVendingMachine 的实例赋值给类型为 VendingMachine 的变量。当赋值给 VendingMachine 变量时,它只能接受硬币。但当赋值给 AnyMoneyVendingMachine 时,它可以接受硬币或纸币。

val vendingMachine: VendingMachine = AnyMoneyVendingMachine()
val anyMoneyMachine: AnyMoneyVendingMachine = AnyMoneyVendingMachine()

val snack1: Snack = vendingMachine.purchase(Dime())
val snack2: Snack = anyMoneyMachine.purchase(Dime())
val snack3: Snack = anyMoneyMachine.purchase(OneDollarBill())

就像之前一样,我们可以将这两个类并排放置,发现类类型与 purchase() 返回类型之间型变的本质。

与之前相同类型的图片 open class VendingMachine { open val purchase : (Coin) -> Snack ... } class AnyMoneyVendingMachine : VendingMachine () { override val purchase : (Money) -> Snack = ... } is a subtype of is a subtype of

这次,我们可以看到,当我们创建一个更具体VendingMachine 时,我们可以在 purchase 中接受更通用的参数类型。上面的箭头指向相反的方向,所以这种型变称为逆变

正如你从故事中回忆的,当 Vinnie 试图用一台接受更具体硬币类型的自动售货机替换时,他违反了合同。同样,我们不能创建一个接受比其父类更具体类型的子类。例如,如果我们更新 purchase,使其参数类型为 Dime,我们将收到编译器错误。

class AnyMoneyVendingMachine : VendingMachine() {
    override val purchase: (Dime) -> Snack = { randomSnack() }
}
Error

因此,我们从这个故事中学到,子类型可以声明它接受更通用的类型,但不能接受更具体的类型。

现在我们理解了型变——包括协变和逆变——是时候回顾我们所学的,并看看这些概念如何应用于泛型了。

是什么让子类型成为子类型?

每当 Vinnie 用另一台自动售货机替换时,只要新机器做合同规定的所有事情,一切就都没问题。具体来说,合同规定自动售货机必须接受硬币退回零食。如果新机器做到了这些,它就是一个合适的替代品。在它违反合同的两次情况下,它不是一个合适的替代品。

那么是什么让子类型成为子类型呢?能够替换其父类型之一。子类型必须完全支持其父类型的合同。具体来说,这意味着它必须遵守以下三个规则:1

  1. 子类型必须具有与其父类型相同的所有公共属性和函数
  2. 其函数的参数类型必须相同或比其父类型中的更通用
  3. 其函数的返回类型必须相同或比其父类型中的更具体

我们通常认为子类型是扩展另一个类的类、扩展另一个接口的接口,或实现接口的类。在所有这三种情况下,Kotlin 的类型系统将确保子类遵循上述三个规则。然而,当涉及参数化类型时——如 VendingMachine<Snack>VendingMachine<CandyBar>——我们不能显式声明一个类型是另一个类型的子类型。

为了帮助演示这一点,让我们将 VendingMachine 转换为泛型类。作为此更改的一部分,我们将添加一个 snack 构造函数参数,这是调用 purchase() 函数时将返回的零食。2

class VendingMachine<T : Snack>(private val snack: T) {
    fun purchase(money: Coin): T = snack
}

正如我们在上一章中学到的,我们可以从这个泛型创建参数化类型,例如 VendingMachine<Snack>VendingMachine<CandyBar>。尽管我们不能显式声明 VendingMachine<CandyBar>VendingMachine<Snack> 的子类型,但这样做是非常安全的,因为它不会违反合同——上述所有三个规则都将满足。

我们可以通过想象有效参数化类型会是什么样子来可视化这一点。换句话说,让我们获取上面的泛型 VendingMachine,并在类型参数出现的每个地方用类型实参替换它。所有三个规则都满足吗?

VendingMachine<Snack> 和 VendingMachine<CandyBar> 的有效参数化类型。 class VendingMachine<Snack>( ) { fun purchase (money: Coin): Snack } class VendingMachine<CandyBar>( ) { fun purchase (money: Coin): CandyBar } 2. same as or more general than 3. same as or more specific than 1. same functions/properties

因此,VendingMachine<CandyBar> 完全满足 VendingMachine<Snack> 的合同,这意味着它可以安全地成为其子类型。不过,这不会自动发生。例如,如果我们尝试将其赋值给声明为 VendingMachine<Snack> 的变量,将会收到编译器错误。

val candyBarMachine: VendingMachine<CandyBar> = VendingMachine(CandyBar())
val vendingMachine: VendingMachine<Snack> = candyBarMachine
Error

因此,在我们告诉 Kotlin 我们的意图并再多做一件事之前,VendingMachine<CandyBar> 不会成为 VendingMachine<Snack> 的子类型。

型变修饰符

正如我们在上面看到的,VendingMachine<CandyBar> 完全满足 VendingMachine<Snack> 的合同,所以它应该可以成为它的子类型。然而,类型参数可能在整个泛型类型的正文中被使用在很多地方。它可以用作函数的返回类型、函数的参数、属性的类型,等等。让我们考虑如果向 VendingMachine 接口添加一个 refund() 函数会发生什么。

class VendingMachine<T : Snack>(private val snack: T) {
    fun purchase(money: Coin): T = snack
    fun refund(snack: T): Coin = Dime()
}

在这种情况下,类型参数 T同时用作函数结果类型函数参数类型。这个版本的 VendingMachine<CandyBar> 是否满足 VendingMachine<Snack> 的合同?让我们再次比较有效参数化类型。首先,让我们看看 purchase() 函数。

VendingMachine<Snack> 和 VendingMachine<CandyBar> 的有效参数化类型表明 purchase() 契约已满足。 class VendingMachine<Snack>( ) { fun purchase (money: Coin): Snack fun refund (snack: Snack): Coin } class VendingMachine<CandyBar>( ) { fun purchase (money: Coin): CandyBar fun refund (snack: CandyBar): Coin } 2. same as or more general than 3. same as or more specific than 1. same functions/properties

和之前一样,对于 purchase() 函数,VendingMachine<CandyBar> 满足 VendingMachine<Snack> 的合同。到目前为止一切顺利。现在让我们看看 refund() 函数。

VendingMachine<Snack> 和 VendingMachine<CandyBar> 的有效参数化类型表明 refund() 契约未满足。 class VendingMachine<Snack>( ) { fun purchase (money: Coin): Snack fun refund (snack: Snack): Coin } class VendingMachine<CandyBar>( ) { fun purchase (money: Coin): CandyBar fun refund (snack: CandyBar): Coin } 2. same as or more general than 3. same as or more specific than 1. same functions/properties

哎呀!尽管 refund()返回类型满足合同,但参数类型不满足,因为 CandyBar 不是”相同或比 Snack 更通用”——它更具体。所以,如果 VendingMachine<T> 包含 refund() 函数,那么 VendingMachine<CandyBar>不能VendingMachine<Snack> 的子类型。

如果 Kotlin 要将 VendingMachine<CandyBar> 视为 VendingMachine<Snack> 的子类型,我们必须向 Kotlin 承诺,我们不会将此类型参数用作函数参数类型,就像我们在 refund() 中做的那样。相反,它只能出现在out 位置——换句话说,作为函数返回类型,或作为只读属性的类型。

为了做出这个承诺,我们可以向类型参数添加一个型变修饰符。Kotlin 有两个型变修饰符——让我们来看看它们。

out 修饰符

第一个型变修饰符名为 out,这是我们告诉 Kotlin 此类型参数将只出现在out 位置的方式。让我们向类型参数 T 添加 out 修饰符。

class VendingMachine<out T : Snack>(private val snack: T) {
    fun purchase(money: Coin): T = snack
}

这个简单的更改就是让清单 19.14的代码编译无误所需的全部——VendingMachine<CandyBar> 现在是 VendingMachine<Snack> 的子类型了!

val candyBarMachine: VendingMachine<CandyBar> = VendingMachine(CandyBar())
val vendingMachine: VendingMachine<Snack> = candyBarMachine

正如我们在本章前面看到的,类型与其关联函数的结果类型协变的。因此,通过确保此类型参数仅用作结果类型,我们知道 VendingMachine 类型与 T 协变是安全的。

同样,out 修饰符是向 Kotlin 的一个承诺,即我们将只在 out 位置使用类型参数。如果我们尝试在in 位置使用相同的类型参数——即作为函数参数类型——那么我们将收到编译器错误。为了演示这一点,我们可以加入清单 19.15中的 refund() 函数,然后观察编译器因我们违反承诺而报错。

class VendingMachine<out T : Snack>(private val snack: T) {
    fun purchase(money: Coin): T = snack
    fun refund(snack: T): Coin = Dime()
}
Error

因此,通过向类型参数添加 out 修饰符,我们有了优势,即 VendingMachine<CandyBar> 现在是 VendingMachine<Snack> 的子类型,但也有了劣势,即我们不能再在in 位置使用 T

in 修饰符

你可能已经猜到了,Kotlin 包含一个与 out 修饰符互补的修饰符,称为 in。但在我们看它之前,让我们对 VendingMachine 类进行一些调整。不是用类型参数表示其零食类型,而是用类型参数表示其金钱类型。

class VendingMachine<T : Money> {
    fun purchase(money: T): Snack = randomSnack()
}

与之前类似,我们可以尝试将 VendingMachine<Money> 的实例赋值给 VendingMachine<Coin>,但会收到错误。

val moneyVendingMachine: VendingMachine<Money> = VendingMachine()
val coinVendingMachine: VendingMachine<Coin> = moneyVendingMachine
Error

尽管 T 仅被用于in 位置,但我们必须使用 in 型变修饰符向 Kotlin 声明这一点。

class VendingMachine<in T : Money> {
    fun purchase(money: T): Snack = randomSnack()
}

通过此更改,清单 19.20 的代码成功编译。

val moneyVendingMachine: VendingMachine<Money> = VendingMachine()
val coinVendingMachine: VendingMachine<Coin> = moneyVendingMachine

out 修饰符一样,我们在这里做了一个权衡:当我们用 in 修饰符声明类型参数时,我们向 Kotlin 承诺它将只出现在in 位置。同样,如果我们添加一个 refund() 函数,我们需要在out 位置使用 T——作为该函数的返回类型——这将导致编译器错误。

class VendingMachine<in T : Money>(private val money: T) {
    fun purchase(money: T): Snack = randomSnack()
    fun refund(snack: Snack): T = money
}
Error

总结:

  • out 修饰符可用于确保类型参数仅公开出现在 out 位置,这使其适合协变。
  • 相反,in 修饰符可用于确保它仅公开出现在 in 位置,这样它就适合逆变。

为简单起见,在本章中我们一次只使用一个类型参数。不过,我们完全可以拥有多个类型参数,当有多个时,我们可以在每个类型参数上使用型变修饰符。让我们看看这是什么样子。

多个类型参数的型变

重要的是要注意,泛型型变不是描述整个类型——而是描述类型与其某个类型参数之间的关系。因此,它可以对一个类型参数协变而对另一个逆变。不是为 MoneyProduct 使用单一类型参数,而是让我们为每个都包含一个。

class VendingMachine<in T : Money, out R: Product>(private val product: R) {
    fun purchase(money: T): R = product
}

在此代码中,VendingMachine 对于 T逆变的,对于 R协变的。从这个泛型类中,我们可以实例化广泛范围的参数化类型。其中一些包括:

从 VendingMachine 泛型类创建的多个参数化类型。 VendingMachine<Money, Product> VendingMachine<Coin, Product> VendingMachine<Nickel, Snack> VendingMachine<Nickel, Product> VendingMachine<Coin, Toy> VendingMachine<Money, ActionFigure>

有了两个类型参数、各种金钱类型和各种产品类型,很容易在参数化类型中迷失哪些是其他类型的子类型。只要记住,子类型必须完全支持其父类型的合同。以下是上述参数化类型的类型层次结构。

相同的参数化类型,排列成 UML 类图。 VendingMachine<Coin, Toy> VendingMachine<Nickel, Product> VendingMachine<Nickel, Snack> VendingMachine<Coin, Product> VendingMachine<Money, Product> VendingMachine<Money, ActionFigure>

知道我们可以在多个类型参数上应用型变修饰符是有帮助的。不过,在本章的其余部分,我们将回到单个类型参数,以使示例易于理解。

我们已经看到 inout 修饰符如何创建型变,但我们也看到了权衡——用 in 声明的类型参数不能用作结果类型,而用 out 声明的类型参数不能用作函数参数类型。但有时候,这些权衡是行不通的。当这种情况发生时,我们仍然可以通过使用类型投影来获得一些型变的好处。

类型投影

到目前为止,我们已经能够通过在类型参数上使用型变修饰符来使 VendingMachine<CandyBar> 成为 VendingMachine<Snack> 的子类型。让我们更新清单 19.16中的代码,使其适用于任何类型的 Product 和任何类型的 Money

class VendingMachine<out T : Product>(private val product: T) {
    fun purchase(money: Money): T = product
}

在此代码中,我们再次在类型参数上使用了 out。因为我们将这个型变修饰符放在类型参数声明的位置,这被称为声明处型变。不过,我们并不总是能使用声明处型变。例如,如果我们真的非常需要清单 19.18中的 refund() 函数怎么办?

class VendingMachine<T : Product>(private val product: T) {
    fun purchase(money: Money): T = product
    fun refund(product: T): Money = Dime()
}

我们不能使用 out 型变修饰符,因为 Trefund() 中被用于 in 位置。我们也不能使用 in 修饰符,因为它在 purchase() 中被用于 out 位置。我们是不是就没办法了?这是否意味着 VendingMachine<CandyBar> 永远不会被认为是 VendingMachine<Snack> 的子类型?

值得庆幸的是,Kotlin 提供了第二个选项。我们可以在类型参数上使用型变修饰符,也可以在类型实参上使用。为了演示这一点,让我们从一个接受 VendingMachine<Snack> 的函数开始。

fun getSnackFrom(machine: VendingMachine<Snack>): Snack {
    return machine.purchase(Dime())
}

由于清单 19.26中的类型参数上没有型变修饰符,VendingMachine<CandyBar>不是 VendingMachine<Snack> 的子类型,所以我们无法用 CandyBarMachine 的实例调用该函数。

val candyBarMachine: VendingMachine<CandyBar> = VendingMachine(CandyBar())
getSnackFrom(candyBarMachine)
Error

现在让我们在 getSnackFrom() 函数的类型实参上添加一个 out 修饰符,如下所示。

fun getSnackFrom(machine: VendingMachine<out Snack>): Snack {
    return machine.purchase(Dime())
}

通过此更改,清单 19.28 的代码成功编译!

val candyBarMachine: VendingMachine<CandyBar> = VendingMachine(CandyBar())
getSnackFrom(candyBarMachine)

当我们在类型实参上放置型变修饰符而不是在类型参数上放置时,我们会在代码中使用该类型的地方(例如 getSnackFrom())创建型变,而不是在声明它的地方(例如 VendingMachine)。因此,我们称之为使用处型变

就像声明处型变一样,使用处型变也有权衡。正如我们稍后将看到的,由于 machine 参数具有 out 修饰符,我们将无法在该函数体内调用 refund() 函数。幸运的是,这个函数的函数体不需要 refund() 函数,所以这个权衡在这里完全是可以接受的!

getSnackFrom() 的函数体无法调用 refund(),但也不需要调用它。 fun getSnackFrom (machine: VendingMachine< out Snack>): Snack { return machine. purchase ( Dime ()) } This function body can't call refund(), but it also has no need to.

请记住,声明处型变适用于整个项目,但使用处型变仅在我们将型变修饰符放在类型实参上的项目特定部分有效。在上面的示例代码中,它仅对 getSnackFrom() 函数有效。因此,如果项目中的其他函数仍然需要使用 refund() 函数,这完全没问题。在那些地方,VendingMachine<CandyBar> 将不能成为 VendingMachine<Snack> 的子类型。

Out 投影

需要注意的是,在这个函数的函数体内,machine 的类型不是 VendingMachine<Snack>。它的类型是 VendingMachine<out Snack>,这是一种类型投影。由于这个类型实参上有 out 修饰符,这种特殊的类型投影被称为out 投影

究竟什么是投影?

沙滩球投下它的影子。

可以把投影想象成球的影子。如果你用光照射它,它会在墙上投下影子。球本身是一个具有三个维度的球体,但球在墙上的影子是一个只有两个维度的圆。影子仍然球,但它缺少了深度。3

类似地,当我们创建类型投影时,有点像是在通过限制函数输入和输出的类型来移除对象的某些”深度”。例如,在上面的 getSnackFrom() 函数中,我们从 VendingMachine<Snack> 创建了一个名为 VendingMachine<out Snack> 的类型投影,它看起来与原始类型非常相似,只是 refund() 函数不再接受 Snack,而是接受一个名为 Nothing 的类型!

原始泛型类、有效参数化类型和有效的 out 投影类型。 class VendingMachine< out Snack>( ) { fun purchase (money: Money): Snack = fun refund (product: Nothing): Money = } Effective Out-Projected Type class VendingMachine<Snack>( ) { fun purchase (money: Money): Snack = fun refund (product: Snack): Money = } Effective Parameterized Type class VendingMachine< T : Product>( ) { fun purchase (money: Money): T = fun refund (product: T ): Money = } Original Generic Class

究竟什么是 Nothing?正如在第 14 章中提到的,Kotlin 中的每个类型都是名为 Any 的类的子类型。同样,Kotlin 中的每个类型也是名为 Nothing 的类的超类型

UML - Any 和 Nothing Circle Boolean String Nothing Any

Nothing 类永远无法实例化,因为它只有一个 private 构造函数。由于不可能创建 Nothing 的实例,我们在这个函数中永远无法调用 refund()

因此,out 投影是通过在类型实参上添加 out 修饰符来创建的。它看起来与原始的参数化类型相似,但在类型实参出现在 in 位置的每个地方,它都被替换为 Nothing 类型。

In 投影

你可能已经猜到了,我们也可以使用 in 修饰符来创建in 投影,就像这个函数一样。

fun getRefundFrom(machine: VendingMachine<in CandyBar>): Money {
    return machine.refund(CandyBar())
}

与上面的 out 投影一样,在这个函数的函数体内,我们最终会得到 VendingMachine<CandyBar> 的投影。但这次,不是影响函数的参数类型,而是函数的结果类型受到了影响。

使用 in 投影时,在类型实参出现在 out 位置的每个地方,它都会被强制转换为 Any?。完全有可能调用这些函数并获得结果。但是,如果我们想对这个结果做任何有用的事情,可能需要将其强制转换回更具体的类型。

原始泛型类、有效参数化类型和有效的 in 投影类型。 class VendingMachine< in Snack>( ) { fun purchase (money: Money): Any? = fun refund (product: Snack): Money = } Effective In-Projected Type class VendingMachine<Snack>( ) { fun purchase (money: Money): Snack = fun refund (product: Snack): Money = } Effective Parameterized Type class VendingMachine< T : Product>( ) { fun purchase (money: Money): T = fun refund (product: T ): Money = } Original Generic Class

因此,out 投影和 in 投影是两种类型投影。out 投影通过强制将 in 位置的类型实参替换为 Nothing 来创建协变,而 in 投影通过强制将 out 位置的类型实参替换为 Any? 来创建逆变。

有时候我们希望函数接受泛型类型的所有实例,不管它们的类型参数如何。对于这些情况,Kotlin 包含了另一种投影。

Star 投影

还记得 Vinnie 吗?嗯,在月底,他会维护所有的自动售货机,进行基本维护以保持它们的良好运行。这个操作不涉及任何 MoneyProduct——他只需要进去紧一紧几颗螺丝。

这是更新后的 VendingMachine 版本,包含一个名为 tune() 的函数。正如你所看到的,这个新函数根本不使用类型参数——既不用作函数参数也不用作返回类型。

class VendingMachine<T : Snack>(private val snack: T) {
    fun purchase(money: Coin): T = snack
    fun refund(snack: T): Coin = Dime()
    fun tune() = println("All tuned up!")
}

所有的自动售货机都需要调校。它们返回的 Snack 类型完全无关紧要。

在这些情况下,我们可能希望函数接受从 VendingMachine 创建的任意类型的实例。Kotlin 通过一种称为star 投影的特殊类型投影使这变得很容易。要创建 star 投影,只需使用星号 *(即”星号”)代替类型实参,而不是使用型变修饰符。例如,这是一个将接受任何类型的 VendingMachine 的函数,无论其类型参数如何。

fun service(machine: VendingMachine<*>) {
    print("Tuning up $machine... ")
    machine.tune()
}

这个函数可以用任何类型的 VendingMachine 调用,无论其类型参数如何。

service(VendingMachine(CandyBar()))   // Works with VendingMachine<CandyBar>
service(VendingMachine(TrailMix()))   // Works with VendingMachine<TrailMix>
service(VendingMachine(GummyBears())) // Works with VendingMachine<GummyBears>

star 投影看起来与原始的参数化类型相同,但:

  • 在类型参数用于 in 位置的任何地方,它都会被替换为 Nothing 类型。
  • 在类型参数用于 out 位置的任何地方,它都会被替换为类型参数的上界。请记住——如果没有指定上界,默认为 Any?
原始泛型类、有效参数化类型和有效的 star 投影类型。 class VendingMachine( ) { fun purchase (money: Money): Product = fun refund (product: Nothing): Money = } Effective Star-Projected Type class VendingMachine<Snack>( ) { fun purchase (money: Money): Snack = fun refund (product: Snack): Money = } Effective Parameterized Type class VendingMachine< T : Product>( ) { fun purchase (money: Money): T = fun refund (product: T ): Money = } Original Generic Class

总之,当你希望接受泛型类型的任何实例时(无论其类型参数如何),star 投影非常有用。

标准库中的型变

既然我们已经学习了协变、逆变、型变修饰符和类型投影,我们可以更好地理解标准库中某些类型的工作方式。我们在本章开头提出了一个事实:MutableList<Cow>List<Cow> 的子类型,但它不是 MutableList<FarmAnimal> 的子类型。为什么是这样?

  • MutableList<Cow>List<Cow> 的子类型,因为 MutableList 扩展了 List 接口。这只是常规的接口继承。
  • MutableList<Cow>不是 MutableList<FarmAnimal> 的子类型,因为它允许你既可以读取也可以修改其元素,这意味着其类型参数同时出现在 in 位置 out 位置。因此,它不能有型变修饰符。这类似于我们清单 19.26中的 VendingMachine

正如我们所看到的,Kotlin 中的集合类型通常有两种风格——只读类型(例如 List)和可变类型(例如 MutableList)。只读类型在其类型参数上会有 out 修饰符,因此 List<Cow> 将是 List<FarmAnimal> 的子类型。然而,可变类型不会有任何型变修饰符,因此 MutableList<Cow>不会MutableList<FarmAnimal> 的子类型。不过,正如你现在所知道的,当合理时,你可以使用类型投影来解决这个问题!

总结

Parker 坐在长椅上,看着在公园里跑来跑去的家庭,他思考着 Vinnie 安装的自动售货机之间的差异。现在他们两人都理解了哪些自动售货机会满足合同,他们对将来可能需要的任何替换都感到更自在了。Vinnie 走到自动售货机前投入一枚一角硬币。他转向仍然坐在长椅上的 Parker。”想要零食吗?”他问他。

恭喜你完成了本章的学习!以下是我们所学内容的回顾:

  • 协变如何描述类型及其函数返回类型之间的关系。
  • 逆变如何描述类型及其函数参数类型之间的关系。
  • 我们如何可以在类型参数上使用型变修饰符来创建声明处型变。
  • 我们如何可以在类型参数上使用型变修饰符来创建使用处型变
  • 标准库中的集合类型如何使用型变修饰符。

继续在你的 Kotlin 项目中使用这些概念来帮助你巩固理解!下一章中,我们将介绍协程这个非常令人兴奋的主题!到时候见!


  1. 本章的目标是解释泛型型变,因此这三个规则侧重于类型的结构。不过,更严格地说,类的行为也应该兼容。(参见 Liskov, B., & Wing, J. M. (1994). A behavioral notion of subtyping. ACM Transactions on Programming Languages and Systems, 16(6), 1811-1841)。此外,Meilir Page-Jones 通过前置条件、后置条件和类不变量的视角更普遍地观察了这三个规则。(Page-Jones, M. (2000). Fundamentals of Object-Oriented Design in UML. Addison-Wesley Professional. p. 283)。 ↩︎

  2. 由于 purchase() 的返回类型根据类型参数而变化,我们不能再简单地使用 randomSnack()——零食必须具有与类型参数相同的类型。例如,在 VendingMachine<CandyBar> 中,purchase() 函数必须返回 CandyBar,而不是 Snack。在构造函数中设置此值是一种简单的方法。如果你愿意,可以将 snack 属性更改为构造新零食的函数类型。例如,private val snack: () -> T。 ↩︎

  3. ”类型投影”这个术语中的”投影”一词在技术上是源于数学,但概念是相同的。 ↩︎