0%

Kotlin中泛型的协变与逆变

Kotlin中的协变和逆变功能不容易理解,这里将用最简明的方式介绍这两个功能

in位置&out位置

首先了解一个约定。一个泛型类或者泛型接口中的方法,它的参数列表是接收数据的地方,我们就把它称为in位置,而它的返回值是输出数据的地方,我们就把它称为out位置,如图所示:

in&out位置

引入问题

有了这个约定前提,接下来定义3个类:

1
2
3
open class Person(val name: String, val age: Int)
class Student(name: String, age: Int): Person(name, age)
class Teacher(name: String, age: Int): Person(name, age)

StudentTeacher类是Person的子类,Person类有nameage两个字段

现在:如果某个方法接收一个Person类型的参数,而我们传入了一个Student类进去,由于Student类是Person类的子类,所以这样做是合法的。

但是:如果某个方法接收一个List<Person>类型的参数,而我们传入了一个List<Student>类型的参数进去,这在Java中是不合法的。因为List<Student>不是List<Person>的子类,这样将可能存在类型转换的安全隐患

为什么会存在这种安全隐患呢?

下面来看一个例子:

1
2
3
4
5
6
7
8
9
class SimpleData<T> {
private var data: T? = null
fun get(): T? {
return data
}
fun set(t: T) {
data = t
}
}

这里自定义一个SimpleData泛型类,内部封装了一个泛型字段dataset()get()方法分别用来为data字段赋值和获取data字段的值

我们假设编程语言允许向某个接受SimepleData<Person>参数的方法传入SimpleData<Student>的实例,那么如下代码就是合法的:

1
2
3
4
5
6
7
8
9
10
11
fun main() {
val student = Student("Tom", 19)
val data = SimpleData<Student>()
data.set(student)
handleSimepleData(data)
val studentData = data.get()
}
fun handleSimepleData(data: SimpleData<Person>) {
val teacher = Teacher("Jack", 30)
data.set(teacher)
}

我们发现这段代码有很大的问题,在main()方法中,我们创建了一个Student的实例,并将它封装到SimpleData<Student>当中,然后将SimpleData<Student>作为参数传递给handleSimepleData()方法。但是handleSimepleData()方法接收的是一个SimpleData<Person>参数(这里假设可以编译通过),那么在handleSimepleData()方法中,我们就可以创建一个Teacher类的实例,并用它来替换SimpleData<Person>参数中原有的数据。因为TeacherPerson的子类,所以这是合法的。

但是,我们又在main()方法中调用了SimpleData<Student>get()方法来获取它内部封装的Student数据,可现在SimpleData<Student>中实际包含的却是一个Teacher的实例,所以此时必定会产生类型转换异常

因此,Java中不允许使用这种方式来传递参数。换句话说,即使StudentPerson的子类,SimpleData<Student>并不是SimpleData<Person>的子类。

泛型协变

从上面的分析中我们发现,问题的关键是我们在handleSimepleData()方法中向SimpleData<Person>里设置了一个Teacher的实例

如果SimpleData在泛型T上是只读的话,肯定就没有类型转换的安全隐患了,那么这个时候SimpleData<Student>可不可以成为SimpleData<Person>的子类呢?

这里就引出了泛型协变的定义了:假如定义了一个MyClass<T>的泛型类,其中A是B的子类型,同时MyClass<A>又是MyClass<B>的子类型,那么我们就可以称MyClass在T这个泛型上是协变的

要实现一个泛型类在其泛型类型的数据上是只读的这一点,就需要让MyClass<T>类中的所有方法都不能接收T类型的参数。也就是说,T只能出现在out位置上,不能出现在in位置上

现在修改SimpleData类的代码:

1
2
3
4
5
class SimpleData<out T>(val data: T?) {
fun getData(): T? {
return data
}
}

这里我们对SimpleData类进行了改造,在泛型T的声明前面加上了一个out关键字。这就意味着现在T只能出现在out位置上,而不能出现在in位置上,同时也意味着SimpleData在泛型T上是协变的。

由于泛型T不能出现在in位置上,因此我们就不能使用setData()方法为data参数赋值了,所以这里改成了使用构造函数的方式来赋值。由于我们在构造函数中使用了val关键字对T进行声明,所以构造函数中的泛型T仍然是只读的。另外,即使我们使用了var关键字,但是只要给它加上private修饰符,保证这个泛型T对于外部而言是不可修改的,那么也是合法的写法

经过这样修改后,下面的代码就能完美编译而没有任何安全隐患了:

1
2
3
4
5
6
7
8
9
fun main() {
val student = Student("Tom", 19)
val data = SimpleData<Student>(student)
handleMyData(data)
val studentData = data.getData()
}
fun handleMyData(data: SimpleData<Person>) {
val personData = data.getData()
}

由于SimpleData类已经进行了协变声明,那么SimpleData<Student>自然就是SimpleData<Person>的子类了,所以这里能够安全的向handleMyData()方法中传递参数。然后在handleMyData()方法中获取SimpleData封装的数据,虽然这里泛型声明的是Person类型,实际获得的会是一个Student的实例,但由于Person是Student的父类,向上转型是完全安全的

泛型逆变

泛型逆变和泛型协变的定义完全相反:假如定义了一个MyClass<T>的泛型类,其中A是B的子类型,同时MyClass<B>又是MyClass<A>的子类型,那么我们就可以称MyClass在T这个泛型上是逆变的

从直观的角度来思考,逆变的规则好像挺奇怪的,原本A是B的子类型,怎么MyClass<B>又反过来成为MyClass<A>的子类型了呢?下面通过一个例子来说明

先定义一个Transformer接口,用于执行一些转换操作:

1
2
3
interface Transformer<T> {
fun transform(t: T): String
}

在这个接口中的transform()方法会将T类型的参数转换成String类型返回

实现这个接口方法:

1
2
3
4
5
6
7
8
9
10
11
12
fun main() {
val trans = object : Transformer<Person> {
override fun transform(t: Person): String {
return "${t.name} ${t.age}"
}
}
handleTransformer(trans)
}
fun handleTransformer(trans: Transformer<Student>) {
val student = Student("Tom", 19)
val result = trans.transform(student)
}

首先我们在main()函数里编写了一个Transformer<Person>的匿名类实现,并通过transform()方法将传入的Person对象转换成了一个”姓名+年龄“拼接的字符串。而handleTransformer()方法接收的是一个Transformer<Student>类型的参数,这里在handleTransformer()方法中创建了一个Student对象,并调用transform()方法将Student对象转换成一个字符串

这段代码从安全的角度来分析是没有任何问题的,因为Student是Person的子类,使用Transformer<Person>的匿名类实现将Student对象转换成一个字符串也是绝对安全的。但是实际上,在调用handleTransformer()方法的时候却会提示语法错误,因为此时Transformer<Person>并不是Transformer<Student>的子类型

这个时候逆变就派上用场了,它即是专门处理这种情况的。现在修改Transformer接口中的代码:

1
2
3
interface Transformer<in T> {
fun tranform(t: T): String
}

这里我们在泛型T的声明前面加上了in关键字,这意味着T只能出现在in位置上,而不能出现在out位置上,同时也意味着Transformer在泛型T上是逆变的

经过这样的修改,Transformer<Person>就是Transformer<Student>的子类型了

思考

我们知道了协变的时候泛型T不能出现在in位置上,那么为什么逆变的时候又不能出现在out位置上呢?

为了说明这个问题,我们现在假设逆变允许让泛型T出现在out位置上的,修改Transformer中的代码:

1
2
3
interface Transformer<in T> {
fun transform(name: String, age: Int): @UnsafeVariance T
}

我们将transform()方法改成了接收name和age这两个参数,并把返回值类型改成了泛型T。由于逆变是不允许泛型T出现在out位置上的,这里为了能让编译器正常编译通过,我们在返回值类型前面加上了@UnsafeVariance注解,这样编译器就会允许泛型T出现在out位置上了。注意不要滥用这个注解,因为运行时因此出现了类型转换异常,Kotlin对此是不负责的

那么,这个时候会产生什么样的安全隐患呢?

1
2
3
4
5
6
7
8
9
10
11
12
fun main() {
val trans = object : Transformer<Person> {
override fun transform(name: String, age: Int): Person {
return Teacher(name, age)
}
}
handleTransformer(trans)
}

fun handleTransformer(trans: Transformer<Student>) {
val result = trans.transform("Tom", 19)
}

上述代码就是一个典型的违反逆变规则而造成类型转换异常的例子。在Transformer<Person>的匿名类实现中,我们使用transform()方法中传入的name和age参数构建了一个Teacher对象并返回。由于transform()方法的返回值是Person对象,而Teacher是Person的子类,因此这样写是合法的

但是在handleTransforme()方法中,我们调用了Transformer<Student>transform()方法,并传入了name和age参数,期望得到的是一个Student对象返回,然而实际上transform()方法返回的是一个Teacher对象,因此这里必然会造成类型转换异常

由于这段代码可以编译通过,我们运行一下:

error

可以看到程序出现异常,提示我们Teacher类型是无法转换成Student类型的

也就是说,Kotlin在提供协变和逆变功能时,就已经把各种潜在的类型转换安全隐患全部考虑进去了,只要我们严格按照其语法规则,让泛型在协变时只出现在out位置上,逆变时只出现在in上,就不会存在类型转换异常的车情况。虽然@UnsafeVariance注解可以打破这一语法规则,但同时也会带来额外的风险,所以我们在使用@UnsafeVariance注解时,必须清楚自己在干什么

您的支持将鼓励我的创作!

欢迎关注我的其它发布渠道