Kotlin 进阶 | 不变型、协变、逆变

3,078 阅读11分钟

引入泛型之后,子类型的概念变得复杂,好不容易用刚学会的泛型定义了方法,用起来编译器却各种障碍。且听我把概念敲碎了再拼起来,娓娓道来。

子类型

任何时候,如果要使用 类型A 的值,都能用 类型B 的值作为替换(当做 A 的值),称 B 是 A 的子类型

从定义中可以看出,任何类型也是它自身的子类型。

把定义说的通俗一点就是 “小范围的类可以替换大范围的类”IntNumber的子类,是因为Int所代表的数的集合范围是Number所代表的子集。

“一个类是否是另一个的子类型”?这个问题对于编译器来说很重要,因为每次给变量赋值或为函数传递实参时都要做这个检查。只有值的类型是变量的子类型时,才允许变量存储该值。

泛型中的子类型

引入泛型之前,子类型的定义很明确,想要判断一个类是否是另一个的子类型也颇为直观,比如StringCharSequence的子类。

一旦引入泛型后,就变得复杂,比如:List<String>List<CharSequence>的子类吗?换句话说,所有使用List<CharSequence>的地方都能用List<String>代替吗?其实还蛮难一下子做出判断的。

为了解决这个难题,需要在原始定义的基础上推导出两个推论:

  1. 子类型方法接收参数的范围 不得小于 父类型方法
  1. 子类型方法返回值的范围 不得大于 父类型方法 只有满足了这两个条件,才能无副作用地将程序中父类对象都替换成子类对象(无副作用即是程序符合原有的逻辑)。

如果List<String>要成为List<CharSequence>的子类就必须满足下面两个条件:

  1. List<Sring>中方法接收参数的范围 不得小于 List<CharSequence>的方法
  2. List<Sring>中方法返回值的范围 不得大于 List<CharSequence>的方法

List定义如下:

// List带泛型的定义
public interface List<E> extends Collection<E> {
    boolean add(E e);
    E get(int index);
}

// List<String>定义如下:
public interface List<String> extends Collection<String> {
    boolean add(String e);
    String get(int index);
}

// List<CharSequence>定义如下:
public interface List<CharSequence> extends Collection<String> {
    boolean add(CharSequence e);
    CharSequence get(int index);
}

虽然String get(int index);返回值的范围比CharSequence get(int index);小,满足了第二个条件。

boolean add(String e);接收参数的范围明显比boolean add(CharSequence e);要小,不满足第一个条件。

所以List<Sring>不是List<CharSequence>的子类型。换句话说,把程序中的List<CharSequence>都替换成List<Sring>是不安全的,因为可能会存在这样的代码:

val spannable = Spannable()
val list: List<CharSequence> = mutableListOf()
list.add(spannable)

如果把这里的List<CharSequence>换成List<String>,编译器就会报错。因为boolean add(String e);只会处理String类型的实参,Spannable超出了这个范围。

不变型

把上面的例子表达成更抽象的定义如下:

假设 泛型 类A 包含 类型参数T,即class A<T>,而Type1Type2的子类,如果A<Type1>A<Type2>不存在父子关系,则称 类A 在类型参数上是不变型的

Kotlin 和 Java 中的类都是不变型的。

这样会造成一些限制,辛辛苦苦抽象了一个方法,它接收一个List<CharSequence>作为参数:handle(chars: List<CharSequence>),想当然地把List<String>传递进入时,编译器会报错。。。难道需要为List<String>重新定义一个相同的方法吗?

协变

不变型描述的是泛型类之间没有子类型关系,泛型类之间还有一种子类型关系叫协变

协变的意思是:类与其类型参数的抽象程度具有相同的变化方向。

(试图总结某个抽象概念时,总会说出一些让人听不懂的话。。。)

换句话说:当类型参数变得更具体时,类也变得更具体。当类型参数变得更抽象时,类也变得更抽象。

比如,从List<String>List<CharSequence>,类型参数从String变为更抽象的CharSequence,如果List<String>List<CharSequence>的变化方向也是更抽象(前者是后者的子类),就称List<T>在类型参数T上是协变的。(显然这个例子不是协变的而是不变型的)

如果一个泛型类是协变的,就意味着它在类的层面保留了类型参数的子类型关系

Kotlin 中,声明类在类型参数上是协变的,需要添加out保留字:

class MyList<out T>{ ... }

虽然将泛型类声明为协变可以让其子类型化关系更符合直觉,但这需要付出代价:

class MyList<out T> {
    fun set(item: T) {}//报错: Type parameter is declare as "out" but occur at "in" position in type T
    fun get(): T {...}
}
  • T出现在方法的参数位,称set(item: T)消费类型为T的值

  • T出现在返回值位时,称get(): T生产类型为T的值

Tout修饰后,它只能出现在返回值位,即它只能被泛型类生产而不能被消费。

所以out会产生两个效果:

  1. 它保留了泛型类的子类型化。
  2. 它限制了类型参数只能出现在返回值位。

这两点是相辅相成的:正因为它限制了类型参数不能出现在参数位,所以子类型化得以保留。正因为它保留了子类型化,所以类型参数只能出现在返回值位。

假设类型参数出现在了参数位,就会出现在这样的情况:

class MyList<String> {
    fun set(itme: String)
    fun get(): String
}

class MyList<CharSequence> {
    fun set(itme: CharSequence)
    fun get(): CharSequence
}

因为fun set(itme: String)可以接收的参数范围比fun set(itme: CharSequence)小,不符合第一条退推论,所以MyList<String>不是MyList<CharSequence>的子类型。

而添加了out之后,相当于告诉编译器把出错的方法删掉以保留子类型化:

class MyList<out T> {
    fun get(): T {...}
}

class MyList<String> {
    fun get(): String
}

class MyList<CharSequence> {
    fun get(): CharSequence
}

此时fun get(): String返回值的范围比fun get(): CharSequence小,符合第二条推论,所以MyList<String>MyList<CharSequence>的子类型。

逆变

除了不变型协变,泛型类之间还有一种子类型关系:逆变

逆变的意思是:类与其类型参数的抽象程度具有相反的变化方向。

换句话说:当类型参数变得更具体时,类却变得更抽象。当类型参数变得更抽象时,类却变得更具体。

逆变有一点反直觉,它想实现的效果是:List<CharSequence>成为List<String>是的子类型。

如果一个泛型类是逆变的,就意味着它在类的层面反转了类型参数的子类型关系

Kotlin 中,声明类在类型参数上是逆变的,需要添加in保留字:

class MyList<in T>{ ... }

同样地,这需要付出代价:

class MyList<in T> {
    fun set(item: T) {}
    fun get(): T {...}//报错: Type parameter is declare as "in" but occur at "out" position in type T
}

Tin修饰后,它只能出现在参数位,即它只能被泛型类消费而不能被生产。

由此可见:

outint不仅限定了参数可以出现的位置,还限定了什么类可以成为子类型。

PECS 原则 & POCI 原则

PECS = producer extends,consumer super,即如果泛型类生产泛型对象,则使用<? extends T>通配符表示协变。如果泛型类消费泛型对象,则使用<? super T>通配符表示逆变。

Kotlin 中使用更简单的out, in表达协变和逆变。所以 PECS 原则在 Kotlin 中可以表述为POCI 原则

类型投影

生活中的投影,是把一个三维物体变成二维物体,投影看上去还是那个物体只是降了一维。

程序中的类型投影也是类似的意思:

将类型投影意味着保留该类型的有些能力,去掉另一些能力。通过类型投影可以动态地改变泛型类的子类型关系。

类型投影通常应用于将不变型的泛型类动态地转换成逆变协变

比如,MutableList就是不变型的:

public interface MutableList<E> : List<E>, MutableCollection<E> {
	// 类型参数出现在 out 位置
    public fun removeAt(index: Int): E
	// 类型参数出现在 in 位置
    public fun add(index: Int, element: E): Unit
    ...
}

MutableList<E>是不变型,所以泛型参数可随意出现在inout位置。

但不变型有时候会缩小方法的适用范围,比如:

fun <T> copy(source: MutableList<T>, destination: MutableList<T>){
	for (item in source){
    	destination.add(item)
    }
}

这是一个拷贝集合的方法,引入泛型是为了避免为每一种具体的类型都重新定义一遍方法。现在这个方法可以在任何数据类型相同的两个列表见拷贝内容。

但如果我想把一个字符串集合拷贝到可以包含任意对象的集合中怎么办?

val strings = mutableListOf( "a", "b", "c" )
val anys = mutableListOf<Any>()

copy( strings, anys )// 报错

因为copy()的定义要求源和目的集合具有相同的类型。

为了让copy()方法能适用于这种情况,可以这样改写:

fun <R: T, T> copy(source: MutableList<R>, destination: MutableList<T>){
	for (item in source){
    	destination.add(item)
    }
}

引入第二个泛型R,它是T的子类型,并指定它为源集合类型参数。

这个改动一下子扩展了source参数接受实参类型的范围,原本它只能和destination使用同样的类型,现在它可以使用所有destination的子类型。

运用变型可以简化这个改动:

fun <T> copy(source: MutableList<out T>, destination: MutableList<T>){
	for (item in source){
    	destination.add(item)
    }
}

source参数声明的地方,发生了out 类型投影,投影去掉了MutableList中所有消费类型参数的方法,保留了所有生产类型参数的方法。

虽然source参数丧失了部分能力,但牺牲总是有回报的,source所能接受实参的类型范围被扩展了。(碰巧,copy()方法体中也不需要source丧失的那部分能力。)

MutableList<out T>MutableList<T>的区别如下:

public interface MutableList<out T> {
	// 类型参数出现在 out 位置的方法保持原样
    public fun removeAt(index: Int): T
	// 类型参数出现在 in 位置的方法被改写
    public fun add(index: Int, element: Nothing): Unit
    ...
}

out保留字命令编译器去改写泛型类中所有消费类型参数的方法,将in位置的参数类型改成Nothing

Nothing是所有类的子类,为啥要这样改呢?因为想让MutableList<String>成为MutableList<out CharSequence>的子类型

回忆一下子类型的 2 个推论:

  1. 子类型方法接收的参数范围 不得小于 父类型方法
  2. 子类型方法返回的结果的范围 不得大于 父类型方法

当没有out投影时,public fun add(index: Int, element: String): Unit接收参数的范围 小于public fun add(index: Int, element: CharSequence): Unit接收参数的范围,它不符合第一条规则,所以MutableList<String>不是Group<out CharSequence>的子类型。

换成public fun add(index: Int, element: Nothing): Unit后,情况就大不一样了。Nothing是所有类的子类,它也不能被实例化,并且没有子类型。换句话说如果一个方法接收Nothing类型的参数,意味着没有任何类型可以作为参数传入(唯一可以传入的 Nothing 却不能实例化)。这样的话public fun add(index: Int, element: String): Unit接收参数的范围就比“什么也不能接收”大了(好歹它能接收 String 类型)。

类似地,in保留字命令编译器去改写泛型类中所有生产类型参数的方法,将out位置的参数改成Any?

public interface MutableList<in T> {
	// 类型参数出现在 out 位置的方法被改写
    public fun removeAt(index: Int): Any?
	// 类型参数出现在 in 位置的方法被保留
    public fun add(index: Int, element: T): Unit
    ...
}

Any?是所有类的父类,这也就很巧妙地让MutableList<in T>符合了第二条推论(Any?已经是最大的范围了,随便返回什么类型都是它的子类)

最后,还有一种特殊的投影叫star 投影,它的效果是in投影out投影之和。(详见下表)

Kotlin 中一共有三种类型投影,总结如下(其中,Group、Dog、Animal都是类名,且 Dog 是 Animal 的子类型):

投影类型投影实例变型继承关系限制
out 投影Group< out Animal >协变Group< Dog > 是 Group< out Animal > 子类类型参数不能作为方法参数
in 投影Group< in Animal >逆变Group< in Animal > 是 Group< Dog > 子类类型参数不能作为方法返回值
star 投影Group< * >--Group< 任何类型 > 都是 Group< * > 子类类型参数不能做方法参数也不能做返回值

推荐阅读