Java与Kotlin中的泛型之:擦除、不变、协变、逆变

前言

  • 对于Java中泛型的使用方法和应用场景等,不在本文章中作讨论,在阅读此篇文章时,我已经默认你对Java泛型有了一个较为清楚的认识和较为熟悉的应用熟练度。
  • 代码中的部分声明因篇幅原因没办法完全展示,只展示关键代码,但是别担心,你一定能看懂。
  • 本文章的内容均参考《Kotlin核心编程》中对该知识点的讲述,以及结合本人的实际开发经验。

概述

Java中的泛型(Generics)是Java 5中引入的一个特性,它提供了一种编译时类型安全检测机制,该机制允许程序员在类、接口、方法中使用类型参数(type parameters)。这样,类、接口和方法就可以操作多种类型的数据,而不是在编译时就已经确定下来的某一种类型。泛型的使用极大地增强了Java代码的复用性、灵活性和安全性。

尤其是JDK源码中在Java集合框架中大量使用到泛型,例如我们较为常用的List接口声明:

public interface List<E> extends Collection<E>

一道Java面试题

请问:以下输出结果是什么?

List<String> list1 = new ArrayList<String>();
List<Integer> list2 = new ArrayList<Integer>();
System.out.println(list1.getClass() == list2.getClass());

你可能会认为:List<String>List<Integer>很明显不是同一个类型,一定是返回false

但运行之后你会发现,返回结果为:true

在这其中作妖的就是接下来要说的:泛型的类型擦除

泛型的背后:类型擦除(Type Erasure)

概念

类型擦除是指:通过一些技术手段去掉(或”擦除“)编程语言中的类型信息,使得在运行时不再依赖于这些类型信息。这通常涉及到在编译器对类型信息进行替换处理,以便在运行时能够以一种统一的方式处理不同类型的对象。

简单来说就是:编译器会对类型进行擦除掩盖,使得程序在运行时无法得知泛型具体的类型信息。

Java中的Array与List

思考一个问题,你是否在Java中写过如下代码:

Array<SomeType>  array = new Array<>();

可能你会疑惑,数组的定义不是以下这种形式吗:

SomeType[] array = new SomeType[10];

是的,我们无法像定义一个List一样,使用泛型定义Array中的类型,这涉及到一个关键点:

Java无法声明一个泛型数组。

Java为什么无法声明一个泛型数组?

在解释这个问题之前,我们先来看一下Java对于泛型的实现方式

Dog[] dogArray = new Dog[10];
List<Dog> dogList = new ArrayList<>();

System.out.println(dogArray.getClass());
System.out.println(dogList.getClass());

// 运行结果
class [LDog;
class java.util.ArrayList

从上面的运行结果我们可以得知,Java中的Array在运行时是可以获取到自身类型的,而List<Dog>在运行时只知道自己是一个List,而无法获取泛型参数的类型。

这个现象与Java底层对泛型的实现有关:Java为了向后兼容所作出的牺牲。这需要在原本不支持泛型的基础上,以不改变老版本代码为目的,在新版本中以一种比较别扭的方式实现泛型———类型擦除

因此我们可以下一个结论:Java中的泛型是类型擦除的,可以看做伪泛型,简单来说,你无法在程序运行时获取到一个对象的具体类型。

以下是解释,不感兴趣可以跳过。

在Java 5之前,泛型还没有登上Java的舞台,老程序中有大量的如下代码:

ArrayList list = new ArrayList(); // 没有泛型

一般在没有泛型的语言上支持泛型,一般有两种方式,以集合为例:

  • 全新设计一个集合框架,不保证兼容老代码,优点是可以写出更符合新标准的代码;缺点是需要适应新的语法,更严重的是可能无法改造老的业务代码。
  • 在老的集合框架上改造,添加一些特性,在兼容老代码的前提下,支持泛型。

很显然,Java选择了后者,这也是有历史原因的,主要有以下两点原因:

  • 在Java 5之前已经有大量非泛型代码存在了,若不兼容它们,则会让使用者抗拒升级,因为他要付出大量的时间去改造老代码。
  • Java曾经有过重新设计一个集合框架的教训,比如Java 1到Java 2过程中的VectorArrayListHashtableHashMap,引起了大量使用者的不满。

好了,在知道了Java中泛型的实现方式之后,现在我们来回答Java为什么无法声明一个泛型数组:

由于类型擦除,你不能直接创建一个泛型数组,比如 T[],因为编译器无法确定 T 的具体类型,而数组的运行时类型检查又需要知道确切的元素类型。

List<Object>与List<String>有何不同?

这个问题其实是在问:为什么使用类型擦除实现泛型可以解决Java新老代码的兼容问题?

如果我们试着编译这两行代码:

ArrayList list = new ArrayList();
ArrayList<String> stringList = new ArrayList<>();

编译后:

0: new
3: dup
4: invokespecial
7: astore_1
8: new
11: dup
12: invokespecial
15: astore_2

我们不难发现,这两行声明的ArrayList在编译后的字节码是完全一致的,这也侧面说明了,低版本编译的.class文件在高版本的JVM上运行不会出现问题。

这时你可能会产生一个疑问:既然泛型在编译后是会擦除泛型类型的,那么我们又为什么可以使用泛型的相关特性,比如:类型检查、类型自动转换呢?

类型检查是编译器在编译前就会帮我们进行的,所以类型擦除不会影响它。

Kotlin中的泛型

Kotlin汇总的泛型机制与Java是一样的,Kotlin的泛型也是类型擦除的,所以上面的特性在Kotlin中同样存在。

但与Java不同的是,Kotlin中的数组是支持泛型的!!!因此,你可以用如下的方法定义一个Kotlin数组:

val pigArray = arrayOfNulls<Pig>(10)

泛型不变性

概念

泛型的不变性指的是泛型容器(如List, Set等)在类型参数上不支持协变逆变

性质

具体来说,如果你有一个List<String>,那么你不能将它直接赋值给一个List<Object>类型的变量,尽管在直觉上,所有的String都是Object,应该能够这样做。

注意:这里我们讨论的是List而不是Array,这两者是有本质区别的。

List<String> strings = new ArrayList<>();  
List<Object> objects = strings; // 编译错误

上述代码会导致编译错误,因为泛型在Java中是不变的。List<String>List<Object>被视为完全不相关的类型,即使StringObject的子类型。

可能说到这里你还是不太理解泛型不变性的概念,协变和逆变是什么意思?接下来通过一段代码来引出泛型的协变与逆变。

Apple[] appleArray = new Apple[10];
Fruit[] fruitArray = appleArray; // 允许
fruitArray[0] = new Banana(); // 编译通过,运行报ArrayStoreException

List<Apple> appleList = new ArrayList<Apple>();
List<Fruit> fruitList = appleList; // 不允许

我们可以发现一个奇怪的现象,上半部分的Array编译通过了,而下半部分的List却连编译都过不去。要回答这个问题,就涉及到一个关键的知识点:

在Java中:Array是协变的,而List是不变的。

简单来说,就是Object[]是所有对象数组的父类,而List<Object>却不是List<T>的父类。接下来就对Java以及Kotlin中的泛型协变和逆变作出说明。

泛型协变(Covariance)

概念

假设类型A是类型B的子类型,那么Generic<A>也是generic<B>的子类型。

例如:在Kotlin中StringAny的子类型,那么List<String>也是List<Any>的子类型。

Kotlin中的协变

List与MutableList

我们先先来看看这两个接口的定义:

public interface List<out E> : kotlin.collections.Collection<E>

public interface MutableList<E> : kotlin.collections.List<E>, kotlin.collections.MutableCollection<E>

细心的你应该已经发现了,除了继承的接口稍有不同外,List接口的泛型部分多了个out关键字。而正是这个out,使得这两个List接口的实现类的功能有了些许差别。

正如Mutable的意思一样,MutableList接口的实现类都是可修改的。相反,List接口的实现类都是不可修改的,这一点也可以从List接口的源码中得到应征。

public interface List<out E> : kotlin.collections.Collection<E> {
    public abstract val size: kotlin.Int

    public abstract operator fun contains(element: E): kotlin.Boolean

    public abstract fun containsAll(elements: kotlin.collections.Collection<E>): kotlin.Boolean

    public abstract operator fun get(index: kotlin.Int): E

    public abstract fun indexOf(element: E): kotlin.Int

    public abstract fun isEmpty(): kotlin.Boolean

    public abstract operator fun iterator(): kotlin.collections.Iterator<E>

    public abstract fun lastIndexOf(element: E): kotlin.Int

    public abstract fun listIterator(): kotlin.collections.ListIterator<E>

    public abstract fun listIterator(index: kotlin.Int): kotlin.collections.ListIterator<E>

    public abstract fun subList(fromIndex: kotlin.Int, toIndex: kotlin.Int): kotlin.collections.List<E>
}

可以看到,List接口中并没有定义add方法,也没有常用的remove或者replace等方法,也就是说这个List一旦创建就不能再被修改,这就是将泛型声明为协变需要付出的代价。

实现方法:out-协变关键字

那么你可能会有一个疑问:为什么泛型协变会有这个限制呢?我们来看一个例子:

val stringList:List<String> = ArrayList<String>()
val anyList:List<Any> = stringList
anyList.add(1)// 这其实已经报错了
val str: String = anyList.get(0) // Int无法转换为String

从上面的例子可以看出,加入支持协变的List允许插入新对象,那么他就不再是类型安全的类,也就违背了泛型的初衷。所以我们可以得出结论:

支持协变的List只可以读取,不可以添加。

out这个关键字也可以看出,out就是出的意思,可以理解为这是一个只读列表

因此,我们可以总结出out关键字的几个注意事项记忆方法

  • out表示生产内容。

  • <out T>表示可协变为T的子类型。

  • 通常情况下:若一个泛型类Generic<out T>支持协变,那么它里面的方法的参数类型不能使用T类型,因为一个方法的参数不允许传入参数父类型的对象,因为那样可能导致错误。

List<out E>中的indexOf方法

我们再来细心查看List接口的方法定义:(我已经为你删除了本小节不关心的代码)

public interface List<out E> : kotlin.collections.Collection<E> {
    public abstract operator fun contains(element: E): kotlin.Boolean

    public abstract fun containsAll(elements: kotlin.collections.Collection<E>): kotlin.Boolean

    public abstract fun indexOf(element: E): kotlin.Int

    public abstract fun lastIndexOf(element: E): kotlin.Int
}

毫无例外,这些方法中的参数都带了协变泛型E,再回头看看out关键字的注意事项,好像与第三点有点冲突,我对此的解释是这样的:

这些方法都需要传入一个element:E才能实现方法,以indexOf方法为例:

  • indexOf方法的目的是在列表中查找与给定元素相等的元素,并返回其索引。由于这个方法不需要修改列表,因此不会因为**协变泛型E**带来类型安全问题。

  • EList<out E>的上下文中是“协变的”,是指在某些只读操作(如通过List<out E>引用访问元素)的上下文中,允许更广泛的类型兼容性。但indexOf方法本身并不涉及类型的“输出”或“输入”的转换,它只是简单地接受一个参数并返回结果。

Java中的协变

还记得刚才的小结论吗?

在Java中:Array是协变的,而List是不变的。

因此我们不能将List<Integer>赋值给List<Object>。如果Java允许这种情况发生,那么它将会和数组支持泛型一样,不在保证类型安全,而Java设计师明确泛型最基本的条件就是保证类型安全,所以不支持这种行为。

List接口定义
public interface List<E> extends Collection<E>

在Java的集合框架中这样定义的List接口(即:普通方式定义的)的泛型是不变的,简单来说就是不管类型A和类型B是什么关系,Generic<A>Generic<B>都没有任何关系。

定义一个支持协变的List

如果你的Java基础比较好的话,相信你一定见过如下定义泛型的形式,只不过不是很了解为什么要这样定义泛型,相信看到这里,你应该能恍然大悟:

List <? extends Object> list = new ArrayList<String>();

但这样实现的泛型协变看起来非常别扭,这也是Java泛型一直被诟病的原因。

泛型逆变(Contravariance)

概念

假设类型A是类型B的子类型,那么Generic<B>反过来是generic<A>的子类型。

例如:在Kotlin中DoubleNumber的子类型,那么List<Number>反过来是List<Double>的子类型。

Kotlin中的逆变

假设现在需要对一个List<Int>进行排序,利用sortWitch方法,传入一个比较器指定排序规则,我们可以写出如下代码:

val intComparator = Comparator<Int> { n1, n2 ->
    n1.compareTo(n2)
}
val intList = listOf(4,8,5,7,3,6,2,9)
println(intList.sortedWith(intComparator))

现在我们实现了需求,但是如果现在又需要对List<Double>List<Long>等排序怎么办?

试想一下,可不可以定义一个公共的比较器,使得这些数值类List都能够正确排序?相信聪明的你已经想到了他们共同的父类Number

于是我们改进上面的代码:

val numberComparator = Comparator<Number> { n1, n2 ->
    n1.toDouble().compareTo(n2.toDouble())
}
val intList = listOf(4, 8, 5, 7, 3, 6, 2, 9)
println(intList.sortedWith(numberComparator))

编译和运行都没有问题,完美实现了功能,那么接下来的问题就变成了:为什么Comparator<Number>可以代替Comparator<Int>传入sortedWith方法进行排序?

Iterable中的sortedWith方法

我们先来看定义:(这里用到了扩展语法,对Iterable接口的实现类拓展了sortedWith方法)

public fun <T> Iterable<T>.sortedWith(comparator: Comparator<in T>): List<T>

细心的你应该已经发现了该方法的泛型部分多了个in关键字。而正是这个in,使得Comparator<Number>可以代替Comparator<Int>传入sortedWith方法进行排序。

正如in的意思一样,可以理解为“进入方法的泛型”,也就是参数的类型,参数在方法中扮演的正是消费者,等待函数体对其进行消费。

实现方法:in-逆变关键字

现在如果让你实现一个需求:简单定义一个可写的列表,用泛型逆变定义,不需要考虑其他功能,只完成可写的定义。

interface WritableList<in T> {
  fun add(newElement: T): Int
}

好了,是不是如此简单,完全符合我们刚刚的需求。现在又来了新的需求,我希望把读取功能也加上。

interface WritableList<in T> {
    fun add(newElement: T): Int

    fun get(index: Int): T // Type parameter T is declared as 'in' but occurs in 'out' position in type T

    fun get(index: Int): Any // 允许,但前提是要把上面的get方法移除,否则会有冲突
}

这时候就出现问题了,通过报错信息我们可以得知:我们不能将泛型参数类型当做方法返回值的类型。

因此,我们可以总结出in关键字的几个注意事项记忆方法

  • in表示消费内容。

  • <in T>表示可逆变为T的父类型。

  • 若一个泛型类Generic<in T>支持逆变,那么它里面的方法的返回类型不能使用T类型。

Java中的逆变

在Java中使用<? super T>可以达到相同的效果,在此不多赘述。

Kotlin解除泛型协变和逆变的限制

如果你可以确定和保证你的代码中不会因为泛型协变和逆变带来副作用,解除编译器的警告,Kotlin为你提供了@UnsafeVariance注解来解除这个限制

一个扩展知识

现在你已经知道了Kotlin是泛型擦除的,那么如果我确实要在Kotlin代码中获取泛型参数类型,该怎么办到呢?

答案是:使用内联函数获取泛型参数类型。

Kotlin中的内联函数在编译的时候,编译器便会将相应函数的字节码插入调用的地方,以减少匿名内部类的创建开销,也就是说,参数类型也会被插入字节码中,我们就可以获取到泛型参数的类型了。

inline fun <reified T> getType(): Class<T> {
    return T::class.java
}

请注意:这时候获取到的是一个Java版本的Class对象,在Kotlin中使用Class<T>时,你需要更加小心处理类型安全和类型擦除的问题。

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐