本文介绍了Java中泛型的诞生背景、优点、基本用法、泛型类和泛型方法,类型擦除和虚拟机如何处理泛型,以及泛型的协变、逆变和PECS原则。文章详细解释了泛型在实际开发中的应用和注意事项。
在没有泛型之前,必须使用Object编写适用于多种类型的代码,这导致了代码的不安全和难以阅读。Java的设计者为了解决这个问题,在Java5中引入了泛型。
泛型的引入使程序更加易读,安全性有所保证。通过使用泛型,我们可以方便地指示虚拟机集合类包含元素的类型,避免类型转换异常。
虚拟机没有泛型类型对象,所有对象在虚拟机中都属于普通类。这意味着在程序编译并运行后,类型变量会被擦除(erased)并替换为限定类型。类型擦除的目的是为了保证与旧代码和类文件的兼容性。
协变、逆变和PECS原则是泛型中非常重要的概念。协变主要用于只读操作,逆变用于写操作。PECS原则(Producer extends Consumer super)总结了这一点,作为元素的生产者要用协变,作为消费者要用逆变。
本文主要介绍泛型诞生的前世今生,特性,以及著名PECS原则的由来。
在日常开发中,必不可少的会使用到泛型,这个过程中经常会出现类似“为什么这样会编译报错?”,“为什么这个列表无法添加元素?”的问题,也会出现感叹Java的泛型限制太多了很难用的情况。为了更好的使用泛型,就需要更深的了解它,因此本文主要介绍泛型诞生的前世今生,特性,以及著名PECS原则的由来。
在没有泛型之前,必须使用Object编写适用于多种类型的代码,想想就令人头疼,并且非常的不安全。同时由于数组的存在,设计者为了让其可以比较通用的进行处理,也让数组允许协变,这又为程序添加了一些天然的不安全因素。为了解决这些情况,Java的设计者终于在Java5中引入泛型,然而,正是因为引入泛型的时机较晚,为了兼容先前的代码,设计者也不得不做出一些限制,来让使用者(也就是我们)以难受换来一些安全。
简单来说,泛型的引入有以下好处:
以ArrayList举例,在增加泛型类之前,其通用性是用继承来实现的,ArrayList类只维护一个Object引用的数组,当我们使用这个工具类时,想要获取指定类型的对象必须经过强转:
import java.util.ArrayList;
import java.util.Date;
public class Main {
public static void main(String[] args) {
ArrayList list = new ArrayList();
String res = (String) list.get(0);
list.add(new Date());
}
}
这种写法在编译类型时不会报错,但一旦使用get获取结果并试图将Date转换为其他类型时,很有可能出现类型转换异常,为了解决这种情况,类型参数应用而生。
类型参数(Type parameter)使得ArrayList以及其他可能用到的集合类能够方便的指示虚拟机其包含元素的类型:
import java.util.ArrayList;
public class Main {
public static void main(String[] args) {
ArrayList objects = new ArrayList<>();
objects.add("Hello");
}
}
这使得代码具有更好的可读性,并且在调用get()的时候,无需进行强转,最重要的是,编译器终于可以检查一个插入操作是否符合要求,运行时可能出现的各种类型转换错误得以在编译阶段就被阻止。
import java.util.ArrayList;
import java.util.Date;
public class Main {
public static void main(String[] args) {
ArrayList objects = new ArrayList<>();
//we can do it like that
objects.add("Hello");
//wrong example
objects.add(new Date());
}
}
一般来说,使用泛型工具类很容易,但是自己编写会相对困难很多,设计者必须考虑的相当周全才能使自己的泛型类库比较完善。
泛型类是有一个或者多个类型变量的类,泛型类中的属性可以
全都不是泛型
,不过一般不会这样做,毕竟类型变量在整个类上定义就是用于指定方法的返回类型以及字段的类型,定义代码如下:
public class Animal {
private String name;
private T mouth;
public T getMouth(){
return mouth;
}
}
泛型类可以有多个类型变量:
public class Animal {
private String name;
private T mouth;
private U eyes;
public T getMouth(){
return mouth;
}
}
泛型方法可以在普通类中定义,也可以在泛型类中定义,例如:
public class Animal {
private T value;
public static T get(T... a){
return a[a.length-1];
}
public T getFirst(){
return value;
}
}
虚拟机没有泛型类型对象,也就是说,所有对象在虚拟机中都属于普通类,这意味着在程序编译并运行后我们的类型变量会被擦除(erased)并替换为限定类型,擦掉类型参数后的类型就叫做原始类型(raw type),正是因为有类型参数,所以下面的比较结果会为true:
这里的替换规则我个人理解为:“替换最近上界”,也就是无限定符修饰,则为顶级父类Object,如果有,则会替换为其指定的类型。最直观的示例如下,这就是类型擦除的体现:
前面说过,泛型是在1.5才提出的,因此类型擦除的目的就是为了保证已有的代码和类文件依然合法,也就是向低版本兼容。这样做会带来几个问题:
1.类型参数不支持基本类型,只支持引用类型,这是因为泛型会被擦除为具体类型,而Object不能存储基本类型的值。
运行时你只能对原始类型进行类型检测:
2.不能实例化类型参数
不能实例化泛型数组,因为类型擦除会将数组变为Object数组,如果允许实例化,极易造成类型转换异常。
在编写泛型方法调用时,如果擦出了返回类型,编译器会插入强制类型转换。例如下面的代码:
public class Main {
public static void main(String[] args) {
Animal pair = new Animal<>();
Integer first = pair.getFirst();
}
}
getFirst擦除类型后的返回类型是Object,编译器自动插入转换到Integer的强制类型转换,也就是说,编译器把这个方法调用转换为两条虚拟机指令:
子类重写父类方法时,必须和父类保持相同的方法名称,参数列表和返回类型。那么问题来了,如果按照之前的思路来讲,当泛型父类或接口的类型参数被擦除了,那么子类岂不是不构成重写条件?(参数类型很可能变化):
擦除前:
擦除后:
为了解决这个事情,Java引入了桥接方法,为每个继承/实现泛型类/接口的子类服务,以此保持多态性,字节码如下:
(图片来源:RudeCrab)
其实现原理,就是重写擦除后的父类方法,并在其内部委托了原始的子类方法,巧妙绕过了擦除带来的影响。不仅如此,就算不是泛型类,当子类方法重写父类方法的返回类型是父类返回类型的子类时,编译器也会生成桥接方法来满足重写的规则。
Java核心技术中总结的非常到位:
变型是类型系统中很重要的概念,主要有三个规则协变,逆变,和不变:
这三个类型可以解释为:假设有一个类型构造器f,它可以将已知类型转换为另一种类型,那么,有Animal父类和Dog子类。
-
则f(Dog)是f(Animal)的子类,称为协变;
-
则f(Dog)是f(Animal)的父类,成为逆变;
-
则f(Dog)和f(Animal)没有任何关系;