深刻理解Java泛型

泛型是什么?

在咱们写代码的时候,常常都会看到相似于ArrayList<T>的代码,而这里的T既是泛型,泛型就是泛指一种类型的意思,也就是没有固定的类型,只有到使用的时候根据用户的需求才会最终肯定下类型。html

实际Java的泛型并非真泛型,而是一种伪泛型,由于Java在编译时会进行类型擦除,要理解Java泛型,那么泛型擦除就必须掌握。而在运行时,JVM是不认识泛型这种东西的,因此在运行时,并无泛型一说,泛型只有在编译时才有意义。java

也就是说ArrayList<Integer>ArrayList<String>在JVM中都是ArrayList类型,而ArrayList也称为原始类型。数组

该代码返回的结果为true,由结果可知,他们返回的类型的相同的,都是ArrayList类型。安全

可是在C#中的泛型是真泛型,即ArrayList<Integer>ArrayList<String>是两种类型。oracle

类型擦除

那么什么是类型擦除呢?app

类型擦除就是编译器在编译Java代码的时候,会将泛型给擦除掉,若是泛型是无界的,那么将泛型替换为Object类型,若是泛型是有界的,那么则将泛型替换为第一个有界的类型。ide

泛型类

最多见的就是定义的类中存在泛型,咱们看看类型擦除在类中是如何表现的。this

  • 无界泛型:
class Node<T> {
    T element;
    
    public T getNode(){
        return element;
    }
    
    public void set(T t){
        this.element = t;
    }
}
复制代码

对于这种无界的泛型,在编译器编译以后会变成什么样呢,根据咱们上面的解释,它会将泛型T替换为Objectspa

class Node {
    Object element;
    
    public Object getNode(){
        return element;
    }
    
    public void set(Object t){
        this.element = t;
    }
}
复制代码
  • 有界泛型1:

对于有界泛型来讲,就不是将泛型T直接替换为Object类型了,看以下代码:3d

class Node<T extends Comparable> {
    T element;
    
    public T getNode(){
        return element;
    }
    
    public void set(T t){
        this.element = t;
    }
}
复制代码

这段代码是一个有界的泛型,即泛型T的类型必须是Comparable类型或者是Comparable的子类类型,那么编译后的泛型将会被替换为Comparable

class Node<T extends Comparable<T>> {
    Comparable element;
    
    public Comparable getNode(){
        return element;
    }
    
    public void set(Comparable t){
        this.element = t;
    }
}
复制代码
  • 有界泛型2:

若是在有界泛型的类型参数中,既有类,又有接口,好比A是类,B、C是接口

class Node<T extends A & B & C> {
    T element;
    
    public T getNode(){
        return element;
    }
    
    public void set(T t){
        this.element = t;
    }
}
复制代码

那么A就必须写在最左边,不然将会编译错误,而且泛型T也会被替换为A类型。

  • 有界泛型3:

若是在有界泛型中存在多个类型参数的话,在类型擦除中,只会使用最左边的类型去替换泛型。

class Node<T extends Comparable<T> & Serializable> {
    T element;
    
    public T getNode(){
        return element;
    }
    
    public void set(T t){
        this.element = t;
    }
}
复制代码

该写法,泛型T会被替换为Comparable类型。

class Node<T extends Serializable & Comparable<T>> {
    T element;
    
    public T getNode(){
        return element;
    }
    
    public void set(T t){
        this.element = t;
    }
}
复制代码

可是,若是咱们将两个类型调换一下,即将Serializable类型放在最左边,那么泛型T就会被替换为Serializable类型。

根据有界泛型2和有界泛型3的例子咱们能够知道,对于有界的泛型来讲,泛型擦除会使用第一个参数类型来替换泛型,而对于既有类,又有接口的参数类型,类必须写在第一个参数类型中,也就是类必须在接口以前。也就是会优先使用类的类型来进行替换,其次才会使用接口类型来进行替换。

泛型方法

泛型并不仅能引用于类中,还能够运用于方法中。

public T getNode(){
    return element;
}
复制代码

对于非静态方法而言,类型参数能够是类中定义的,也能够是自定义的。

public <U> U get(U u){
    return u;
}
复制代码

与类的泛型使用相似,能够由一组类型参数组成,类型参数须要使用尖括号封闭,而且要放置于方法的返回值以前。该方法的做用是:接收一个U类型的参数,而且返回一个U类型的值。

而对于静态方法而言,类型参数只能使用自定义的,而不能使用类中定义的,类型参数必须放置于方法的返回值前面。

public static <T> int print(T t){
    System.out.println(t);
}
复制代码

至于为何不能使用类中定义的,由于类中定义的泛型都是在建立对象的时候使用的,而静态方法是属于类的,而不属于任何一个类。好比:

class Node<T> {
    T element;
    
    public T getNode(){
        return element;
    }
    
    public void set(T t){
        this.element = t;
    }
    
    // 静态方法A,错误的写法
    public static T get(){
        return element;
    }
    
    //静态方法B,正确的写法
    public static <T> T get(T t){
        return t;
    }
}
复制代码

咱们写代码时,能够Node<Integer>Node<String>,那么静态方法中的T是Integer类型仍是String类型呢?JVM是没法推断出来的,由于选择任何一种都是不正确的。

静态方法B中的T与类中的T并非同一个泛型T,他们是互相独立的。

咱们在使用泛型静态方法时,通常不须要直接写出泛型,编译器会根据传入的参数自动进行推断。

Node.<String>get("aaaa");

好比这段代码,咱们能够省略尖括号中的类型参数,由于编译器会自行推断出来,等价于下面这句:

Node.get("aaaa");

多态与泛型

咱们考虑这样一个状况:

class Node<T> {
    T element;
    
    public T getNode(){
        return element;
    }
    
    public void set(T t){
        this.element = t;
    }
}

class MyNode extends Node<Integer>{
    @Override
    public void set(Integer t){
        super.set(t);
    }
}
复制代码

考虑如下代码:

MyNode myNode = new MyNode();
myNode.setEle(5);
Node n = myNode;
n.setEle("abc");
Integer x = myNode.getEle();
复制代码

该代码在编译期是能够经过的,可是在运行期将会抛出类型转换异常。致使整个异常发生是在第四行代码执行时将会发生一个类型转换,而整个类型转换将String转换为Integer,因此抛出异常。

由于咱们知道Node类型在编译时,会进行类型擦除,因此当咱们使用一个静态类型为Node的变量去接受MyNode类型时,咱们看到方法签名为set(Object t)的方法。

而在实际执行时,当咱们传递一个字符串参数时,是执行的MyNode中的set(Object t)方法(与方法的分派有关,具体请查阅《深刻理解Java虚拟机 第三版》8.3.2章节),可是 set(Integer t)不是已经重写了Node类中的set(T t)方法吗,可是其实是没有重写的,由于Node类型中并无签名为set(Integer t)的方法,即便编译以后,也只有一个set(Object t)方法,那么java开发团队是如何解决这个问题的呢?

实际上当出现此种状况的时候,编译器会在MyNode类中生成一个桥方法,该桥方法的签名就是set(Object t),而该桥方法才是真正重写了Node中的set(Object t)

而桥方法内部是如何实现的呢,其实很简单:

public void set(Object t){
    set((Integer) t);
}
复制代码

因此MyNode类中的代码将是以下所示:

class MyNode extends Node<Integer>{

    // 桥方法,由编译器生成
    public void set(Object t){
        set((Integer) t);
    }
	
    @Override
    public void set(Integer t){
        super.set(t);
    }
}
复制代码

因此当咱们调用n.set("abc"),实际就是在调用set(Object t),而且对String类型的值进行了类型转换,转换为Integer,因此才会在运行时抛出类型转换异常。

没法使用泛型的场景

不能使用基本类型做为类型参数

class Pair<K, V> {

    private K key;
    private V value;

    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    // ...
}
复制代码

当建立该类型的对象时,不能使用基本类型做为类型参数K,V的值。

Pair<int, char> p = new pair<>(1, 'a'); 编译时就会抛出错误

而只能使用非基本类型做为类型参数K,V的值。

Pair<Integer, Character> p = new Pair(1, 'a'); 正确的用法

不能建立类型参数的实例

public static <E> void append(List<E> list) {
    E elem = new E();  // 编译时抛出错误
    list.add(elem);
}
复制代码

咱们不能为类型参数建立实例,不然将会抛出错误。

咱们可使用反射来实现这种需求:

public static <E> void append(List<E> list, Class<E> c) {
    E elem = cls.newInstance();
    list.add(elem);
}
复制代码

不能将静态类型字段的类型设置为类型参数

public class MobileDevice<T> {
    private static T os; // 编译时抛出错误

    // ...
}
复制代码

由于静态字段是属于类的,而不是属于对象的,因此没法肯定参数类型T的具体类型是什么。

好比有以下代码:

MobileDevice<Integer> md1 = new MobileDevice<>();

MobileDevice<String> md2 = new MobileDevice<>();

MobileDevice<Double> md3 = new MobileDevice<>();

由于静态字段os是被对象md一、md二、md3共享的,那么os字段的类型到底是哪一个呢?这是没法推断或者肯定的,因此不能将静态类型字段的类型设置为类型参数。

不能将类型转换或者instanceof与参数化类型一块儿使用

public static <E> void rtti(List<E> list) {
    if (list instanceof ArrayList<Integer>) {  // 编译时抛出错误
        // ...
    }
}
复制代码

其实理解这个也很简单,由于泛型在编译时将会被擦除,因此在运行时,并不知道类型参数是什么,因此也就没法判断ArrayList<Integer>ArrayList<String>之间的区别,所以运行时只能识别原始类型ArrayList

而能作的只有使用一个通配符(通配符?表示任意类型)去验证类型是否为ArrayList

public static <E> void rtti(List<E> list) {
    if (list instanceof ArrayList<?>) {  // 正确
        // ...
    }
}
复制代码

一般,咱们也不能将类型转换为参数化类型,除非是使用参数化类型是通配符进行修饰

List<Integer> li = new ArrayList<>();
List<Number>  ln = (List<Number>) li;  // 编译时错误
List<?> n = (List<?>)li; // 正确,能够省略(List<?>)
复制代码

可是,在某种状况下,编译器知道类型参数始终有效,并容许强制类型转换

List<String> l1 = ...;
ArrayList<String> l2 = (ArrayList<String>)l1;  // 正确
复制代码

不能建立参数化类型的数组

List<String>[] arrays = new List<String>[2]; 编译时抛出错误

其实要理解这个约束也很简单,咱们先举个简单的例子:

Object[] arr = new String[10];
arr[0] = "abc"; // 正确
arr[1] = 10; // 抛出ArrayStoreException,由于该数组只能接受String类型
复制代码

有了上面那个例子,咱们如今来看下面这个例子:

Object[] arr = new List<String>[10]; // 假设咱们能够这么作,实际会抛出编译时错误
arr[1] = new ArrayList<String>(); // 正常执行
arr[0] = new ArrayList<Integer>(); // 根据上面那个列子,这里应该抛出ArrayStoreException
复制代码

假设咱们可使用参数化类型的数组,那么根据第二个例子,在执行第三行代码时,就应该抛出异常,由于ArrayList<Integer> 类型并不符合List<String>类型,可是不容许这样作的缘由是JVM没法识别,由于编译时会进行类型擦除。类型擦除以后,JVM只认识ArrayList这个类型。

不能建立、捕获参数化类型的对象

一个泛型类不能间接或者直接的继承Throwable类。

class MathException<T> extends Exception { /* ... */ } // 间接继承,编译时抛出错误
复制代码
class QueueFullException<T> extends Throwable { /* ... */ // 直接继承,编译时抛出错误
复制代码

在方法中不能捕获类型参数的实例。

public static <T extends Exception, J> void execute(List<J> jobs) {
    try {
        for (J job : jobs)
            // ...
    } catch (T e) {   // 编译时抛出错误
        // ...
    }
}
复制代码

可是能够在方法中抛出类型参数

class Parser<T extends Exception> {
    public void parse(File file) throws T {     // 正确
        // ...
    }
}
复制代码

不能重载类型擦除以后拥有相同签名的方法

public class Example {
    public void print(Set<String> strSet) { }
    public void print(Set<Integer> intSet) { }
}
复制代码

这两个方法在类型擦除以后的代码:

public class Example {
    public void print(Set strSet) { }
    public void print(Set intSet) { }
}
复制代码

这两个方法的签名就如出一辙了,这在Java语言规范中是不合法的。

不可验证的类型

若是一个类型的类型信息在运行时是彻底可用的,那么这个类型就是可验证的类型,其中包括基本类型、非泛型类型、原始类型、绑定无界通配符的泛型。

不可验证类型的类型信息在编译时已经被类型擦除机制移除了。不可验证类型在运行时没有所有可用的信息,好比ArrayList<Integer>ArrayList<String>,JVM在运行时没法识别这两种类型的不一样之处,JVM只认识ArrayList这种类型。因此Java的泛型是伪泛型,在编译时才有用。

堆污染

堆污染发生的状况是将一个具备类型参数的变量指向一个不具备类型参数的对象。

public class Main {
    public static <T> void addToList (List<T> listArg, T... elements) {
        for (T x : elements) {
            listArg.add(x);
        }
    }

    public static void faultyMethod(List<String>... l) {
        Object[] objectArray = l;     // 有效
        objectArray[0] = Arrays.asList(42);
        String s = l[0].get(0);       // 抛出ClassCastException
    }
}
复制代码

当编译器遇到可变参数的方法时,编译器将会把可变形式参数转换为一个数组。可是,在Java语言中,没法建立带有参数化类型的数组(在没法使用泛型场景中的第五个场景有描述)。咱们拿addToList方法来描述,编译器会将T...elements转换为T[] elements,可是,因为存在类型擦除,最终,编译器会将T...elements转换为Object[] elements,所以,这里就可能产生堆污染。

咱们看到faultyMethod方法,这里的可变参数l赋值给类型为Object[]的变量是有效的,由于变量l通过编译器编译后就是转换为List[]类型,所以咱们能够往里面放置任何该类型或者该类型的子类类型的对象,由于类型已经被擦除了,因此咱们能够放置任何List类型的值进去,这里就出现一个数组对象中,既能够放入List<String>的对象,也能够放入List<Integer>,或者其余类型。这里就出现了堆污染。

禁止不可验证形参的可变参数发出警告

若是你能保证你的可变参数不会出现转换错误,那么就能够添加@SafeVarags注解来取消警告的出现。

也能够添加@SuppressWarnings({"unchecked", "varargs"})注解来取消警告。可是这必须创建在你能确保本身的代码安全的状况下才能添加。

思考

类型擦除实验

咱们如今来下面这段代码:

这段代码是经过反射来获取Node类型的参数类型,以前不是说在编译时不是会进行类型擦除吗,那么JVM是怎么在运行时还能获取到它的参数类型的。

咱们能够经过反编译来看看,反编译以后的class文件是怎么样的。咱们先反编译Node文件:

咱们能够看到,这里并无将T擦除,并替换为 Object类型。因此JVM才能经过该类型信息获取到参数类型。

那么类型擦除是发生在哪里呢?

咱们再来看另一段代码就能明白了:

咱们这里获取了Node中的element字段的类型:

这里打印出来的结果就是Object类型。咱们能够给Node类型的参数类型添加一个下界,让它继承Comparable接口,而后再打印一下类型:

经过这两个例子能够说明,类型的擦除并不会发生在泛型声明上,而是发生在泛型的使用上。

参考文献:

  1. Oracle文档
相关文章
相关标签/搜索