8.3 细节和局限性
8.3 细节和局限性
本节介绍泛型中的一些细节和局限性,这些局限性主要与Java的实现机制有关。Java中,泛型是通过类型擦除来实现的,类型参数在编译时会被替换为Object,运行时Java虚拟机不知道泛型这回事,这带来了很多局限性,其中有的部分是比较容易理解的,有的则是非常违反直觉的。
一项技术,往往只有理解了其局限性,才算是真正理解了它,才能更好地应用它。下面我们将从以下几个方面来介绍这些细节和局限性:
- 使用泛型类、方法和接口。
- 定义泛型类、方法和接口。
- 泛型与数组。
8.3.1 使用泛型类、方法和接口
在使用泛型类、方法和接口时,有一些值得注意的地方,比如:
- 基本类型不能用于实例化类型参数。
- 运行时类型信息不适用于泛型。
- 类型擦除可能会引发一些冲突。
我们逐个来看下。Java中,因为类型参数会被替换为Object,所以Java泛型中不能使用基本数据类型,也就是说,类似下面的写法是不合法的:
1 | Pair<int> minmax = new Pair<int>(1,100); |
解决方法是使用基本类型对应的包装类。
在介绍继承的实现原理时,我们提到在内存中每个类都有一份类型信息,而每个对象也都保存着其对应类型信息的引用。关于运行时信息,后续章节我们会进一步详细介绍,这里简要说明一下。在Java中,这个类型信息也是一个对象,它的类型为Class, Class本身也是一个泛型类,每个类的类型对象可以通过<类名>.class的方式引用,比如String. class、Integer.class。这个类型对象也可以通过对象的getClass()方法获得,比如:
1 | Class<? > cls = "hello".getClass(); |
这个类型对象只有一份,与泛型无关,所以Java不支持类似如下写法:
1 | Pair<Integer>.class |
一个泛型对象的getClass方法的返回值与原始类型对象也是相同的,比如,下面代码的输出都是true:
1 | Pair<Integer> p1 = new Pair<Integer>(1,100); |
之前,我们介绍过instanceof关键字,instanceof后面是接口或类名,instanceof是运行时判断,也与泛型无关,所以,Java也不支持类似如下写法:
1 | if(p1 instanceof Pair<Integer>) |
不过,Java支持如下写法:
1 | if(p1 instanceof Pair<?>) |
由于类型擦除,可能会引发一些编译冲突,这些冲突初看上去并不容易理解,我们通过一些例子介绍。8.2.3节我们介绍过一个例子,有两个类Base和Child, Base的声明为:
1 | class Base implements Comparable<Base> |
Child的声明为:
1 | class Child extends Base |
Child没有专门实现Comparable接口,8.2.3节我们说Base类已经有了比较所需的全部信息,所以Child没有必要实现,可是如果Child希望自定义这个比较方法呢?直觉上,可以这样修改Child类:
1 | class Child extends Base implements Comparable<Child>{ |
遗憾的是,Java编译器会提示错误,Comparable接口不能被实现两次,且两次实现的类型参数还不同,一次是Comparable<Base>
,一次是Comparable<Child>
。为什么不允许呢?因为类型擦除后,实际上只能有一个。
那Child有什么办法修改比较方法呢?只能是重写Base类的实现,如下所示:
1 | class Child extends Base { |
另外,你可能认为可以如下定义重载方法:
1 | public static void test(DynamicArray<Integer> intArr) |
虽然参数都是DynamicArray,但实例化类型不同,一个是DynamicArray<Integer>
,另一个是DynamicArray<String>
,同样,遗憾的是,Java不允许这种写法,理由同样是类型擦除后它们的声明是一样的。
8.3.2 定义泛型类、方法和接口
在定义泛型类、方法和接口时,也有一些需要注意的地方,比如:
- 不能通过类型参数创建对象。
- 泛型类类型参数不能用于静态变量和方法。
- 了解多个类型限定的语法。
我们逐个介绍。不能通过类型参数创建对象,比如,T是类型参数,下面的写法都是非法的:
1 | T elm = new T(); |
为什么非法呢?因为如果允许,那么用户会以为创建的就是对应类型的对象,但由于类型擦除,Java只能创建Object类型的对象,而无法创建T类型的对象,容易引起误解,所以Java干脆禁止这么做。
那如果确实希望根据类型创建对象呢?需要设计API接受类型对象,即Class对象,并使用Java中的反射机制。第21章会介绍反射,这里简要说明一下。如果类型有默认构造方法,可以调用Class的newInstance方法构建对象,类似这样:
1 | public static <T> T create(Class<T> type){ |
比如:
1 | Date date = create(Date.class); |
对于泛型类声明的类型参数,可以在实例变量和方法中使用,但在静态变量和静态方法中是不能使用的。类似下面这种写法是非法的:
1 | public class Singleton<T> { |
如果合法,那么对于每种实例化类型,都需要有一个对应的静态变量和方法。但由于类型擦除,Singleton类型只有一份,静态变量和方法都是类型的属性,且与类型参数无关,所以不能使用泛型类类型参数。
不过,对于静态方法,它可以是泛型方法,可以声明自己的类型参数,这个参数与泛型类的类型参数是没有关系的。
之前介绍类型参数限定的时候,我们提到上界可以为某个类、某个接口或者其他类型参数,但上界都是只有一个,Java中还支持多个上界,多个上界之间以&分隔,类似这样:
1 | T extends Base & Comparable & Serializable |
Base为上界类,Comparable和Serializable为上界接口。如果有上界类,类应该放在第一个,类型擦除时,会用第一个上界替换。
8.3.3 泛型与数组
泛型与数组的关系稍微复杂一些,我们单独介绍。
引入泛型后,一个令人惊讶的事实是,不能创建泛型数组。比如,我们可能想这样创建一个Pair的泛型数组,以表示7.6节中介绍的奖励面额和权重。
1 | Pair<Object, Integer>[] options = new Pair<Object, Integer>[]{ |
Java会提示编译错误,不能创建泛型数组。这是为什么呢?我们先来进一步理解一下数组。
前面我们解释过,类型参数之间有继承关系的容器之间是没有关系的,比如,一个DynamicArray<Integer>
对象不能赋值给一个DynamicArray<Number>
变量。不过,数组是可以的,看代码:
1 | Integer[] ints = new Integer[10]; |
后面两种赋值都是允许的。数组为什么可以呢?数组是Java直接支持的概念,它知道数组元素的实际类型,知道Object和Number都是Integer的父类型,所以这个操作是允许的。
虽然Java允许这种转换,但如果使用不当,可能会引起运行时异常,比如:
1 | Integer[] ints = new Integer[10]; |
编译是没有问题的,运行时会抛出ArrayStoreException,因为Java知道实际的类型是Integer,所以写入String会抛出异常。
理解了数组的这个行为,我们再来看泛型数组。如果Java允许创建泛型数组,则会发生非常严重的问题,我们看看具体会发生什么:
1 | Pair<Object, Integer>[] options = new Pair<Object, Integer>[3]; |
如果可以创建泛型数组options,那它就可以赋值给其他类型的数组objs,而最后一行明显错误的赋值操作,则既不会引起编译错误,也不会触发运行时异常,因为Pair<Double, String>
的运行时类型是Pair,和objs的运行时类型Pair[]
是匹配的。但我们知道,它的实际类型是不匹配的,在程序的其他地方,当把objs[0]
作为Pair<Object, Integer>
进行处理的时候,一定会触发异常。
也就是说,如果允许创建泛型数组,那就可能会有上面这种错误操作,它既不会引起编译错误,也不会立即触发运行时异常,却相当于埋下了一颗炸弹,不定什么时候爆发,为避免这种情况,Java干脆就禁止创建泛型数组。
但现实需要能够存放泛型对象的容器,怎么办呢?可以使用原始类型的数组,比如:
1 | Pair[] options = new Pair[]{ |
更好的选择是,使用后续章节介绍的泛型容器。目前,可以使用我们自己实现的Dy-namicArray,比如:
1 | DynamicArray<Pair<String, Integer>> options = new DynamicArray<>(); |
DynamicArray内部的数组为Object类型,一些操作插入了强制类型转换,外部接口是类型安全的,对数组的访问都是内部代码,可以避免误用和类型异常。
有时,我们希望转换泛型容器为一个数组,比如,对于DynamicArray,我们可能希望它有这么一个方法:
1 | public E[] toArray() |
而希望可以这么用:
1 | DynamicArray<Integer> ints = new DynamicArray<Integer>(); |
先使用动态容器收集一些数据,然后转换为一个固定数组,这也是一个常见的合理需求,怎么来实现这个toArray方法呢?可能想先这样:
1 | E[] arr = new E[size]; |
遗憾的是,如之前所述,这是不合法的。Java运行时根本不知道E是什么,也就无法做到创建E类型的数组。另一种想法是这样:
1 | public E[] toArray(){ |
或者使用之前介绍的Arrays方法:
1 | public E[] toArray(){ |
结果都是一样的,没有编译错误了,但运行时会抛出ClassCastException异常,原因是Object类型的数组不能转换为Integer类型的数组。
那怎么办呢?可以利用Java中的运行时类型信息和反射机制,这些概念我们后续章节再详细介绍。这里我们简要介绍下。Java必须在运行时知道要转换成的数组类型,类型可以作为参数传递给toArray方法,比如:
1 | public E[] toArray(Class<E> type){ |
Class<E>
表示要转换成的数组类型信息,有了这个类型信息,Array类的newInstance方法就可以创建出真正类型的数组对象。调用toArray方法时,需要传递需要的类型,比如,可以这样:
1 | Integer[] arr = ints.toArray(Integer.class); |
我们来稍微总结下泛型与数组的关系:
- Java不支持创建泛型数组。
- 如果要存放泛型对象,可以使用原始类型的数组,或者使用泛型容器。
- 泛型容器内部使用Object数组,如果要转换泛型容器为对应类型的数组,需要使用反射。
8.3.4 小结
本节介绍了泛型的一些细节和局限性,这些局限性主要是由于Java泛型的实现机制引起的,这些局限性包括:不能使用基本类型,没有运行时类型信息,类型擦除会引发一些冲突,不能通过类型参数创建对象,不能用于静态变量等。我们还单独讨论了泛型与数组的关系。
我们需要理解这些局限性,幸运的是,一般并不需要特别去记忆,因为用错的时候, Java开发环境和编译器会进行提示,当被提示时能够理解并从容应对即可。
至此,关于泛型的介绍就结束了。泛型是Java容器类的基础,理解了泛型,接下来,就让我们开始探索Java中的容器类。