擦除分析(类型擦除的局限性)(1)

因为类型擦除,泛型才能与 java 1.5 之前的代码兼容共存。

但类型擦险也有一些局限性,它会擦除很多继承相关的特性,从而引发一些新的问题。

例如:

1)类型变量在编译时会被擦除,那我们往 ArrayList<String> arrayList=new ArrayList<String>(); 所创建的数组列表 arrayList 中,能否使用 add 方法添加整形呢?

2)泛型变量 Integer 在编译时会被擦除,变为原始类型 Object ,为什么不能存别的类型呢?

类型擦除了,如何保证我们只能使用泛型变量限定的类型?

......

类型擦除所引发的这些问题,都是怎么解决的呢?本篇详解。

送《泛型最全知识导图》、《大厂泛型面试真题26道》,到本篇结尾处获得~

1 类型擦除的基本概述

我们上篇详细介绍了 类型擦除的作用、优缺点、使用过程等 ,这里就不再重复赘述,感兴趣的同学可以点进去温顾下。

2 泛型的类型擦除引发的问题

2.1 自动类型转换

因为类型擦除的问题,所有的泛型类型变量,最后都会被替换为原始类型。

这样就引发了一个问题:既然都会被替换为原始类型,为什么我们在获取时,不需要进行强制类型转换呢?

我们来看下 ArrayList.get () 方法:

public E get(int index) { RangeCheck(index); return (E) elementData[index]; }

可见,在 return 之前,会根据泛型变量进行强转。

我们假设泛型类型变量为Date,虽然泛型信息会被擦除掉,但会将 (E) elementData[index] ,编译为 (Date)elementData[index] 。因此,不需要自己进行强转,当存取一个泛型域时,会自动插入强制类型转换。

假设 Pair 类的 value 域是 public 的,表达式也会自动地在结果字节码中插入强制类型转换。

Date date = pair.value;

测试代码:

public class Test { public static void main(String[] args) { ArrayList<Date> list=new ArrayList<Date>(); list.add(new Date()); Date myDate=list.get(0); }

反编了下字节码:

public static void main(Java.lang.String[]); Code: 0: new #16 // class java/util/ArrayList 3: dup 4: invokespecial #18 // Method java/util/ArrayList."<init :()V 7: astore_1 8: aload_1 9: new #19 // class java/util/Date 12: dup 13: invokespecial #21 // Method java/util/Date."<init>":() 16: invokevirtual #22 // Method java/util/ArrayList.add:(L va/lang/Object;)Z 19: pop 20: aload_1 21: iconst_0 22: invokevirtual #26 // Method java/util/ArrayList.get:(I java/lang/Object; 25: checkcast #19 // class java/util/Date 28: astore_2 29: return

上面代码第 22 ,它调用的是 ArrayList.get() 方法,方法返回值是 Object ,说明类型擦除了。

再看代码第 25,它做了一个 checkcast 操作,即检查类型 #19 , 在上面找 #19 引用的类型,它是9: new #19 // class java/util/Date ,是一个Date类型,即做 Date 类型的强转。

所以,是在调用的地方进行强转,不是在 get 方法里进行强转的。

2.2 类型擦除与多态的冲突

下面这个泛型类:

class Pair<T> { private T value; public T getValue() { return value; } public void setValue(T value) { this.value = value; } }

我们想要一个子类来继承它:

class DateInter extends Pair<Date> { @Override public void setValue(Date value) { super.setValue(value); } @Override public Date getValue() { return super.getValue(); } }

在这个子类中,我们设定父类的泛型类型为 Pair<Date> ,覆盖了父类的两个方法。

本意是:将父类的泛型类型限定为 Date ,那么父类里面的两个方法的参数,都为 Date 类型。

public Date getValue() { return value; } public void setValue(Date value) { this.value = value; }

所以,我们在子类中重写这两个方法,是完全没有问题的。从 @Override 标签中也可以看见,也没有问题。

实际上真的没问题吗?

我们来剖析下:

实际上,类型擦除后,父类的泛型类型全部变为了原始类型 Object 。所以,父类编译后会变成下面的样子:

class Pair { private Object value; public Object getValue() { return value; } public void setValue(Object value) { this.value = value; } }

再看子类两个重写方法的类型:

@Override public void setValue(Date value) { super.setValue(value); } @Override public Date getValue() { return super.getValue(); }

setValue 方法,父类的类型是 Object ,而子类的类型是 Date ,参数类型不一样。这如果是在普通的继承关系中,根本就不会是重写,而是重载。

我们在一个 main 方法中测试下:

public static void main(String[] args) throws ClassNotFoundException { DateInter dateInter=new DateInter(); dateInter.setValue(new Date()); dateInter.setValue(new Object());//编译错误 }

如果是重载:

那么,子类中的两个 setValue 方法:一个是参数Object类型,一个是Date类型。可是我们发现,根本就没有这样的一个子类,继承自父类的 Object 类型参数的方法。

所以,确定是重写,而不是重载了。

原因是,我们传入父类的泛型类型是 Date,Pair<Date>,本意是要将泛型类变成如下:

class Pair { private Date value; public Date getValue() { return value; } public void setValue(Date value) { this.value = value; } }

然后,在子类中重写参数类型为 Date 的那两个方法,来实现继承中的多态。

但由于种种原因,虚拟机不能将泛型类型变为 Date ,只能将类型擦除掉,变为原始类型 Object 。我们的本意虽是进行重写来实现多态,但类型擦除后,就只能变为了重载。如此一来,类型擦除就和多态有了冲突。

JVM 虽然知道我们的本意,但是它却不能直接实现。怎么才能去重写我们想要的 Date 类型参数的方法呢

JVM 可以使用桥方法,来实现这项功能。

首先,我们用 javap -c className 的方式,反编译 DateInter 子类的字节码,结果如下:

class com.tao.test.DateInter extends com.tao.test.Pair<java.util.Date> { com.tao.test.DateInter(); Code: 0: aload_0 1: invokespecial #8 // Method com/tao/test/Pair."<init>" :()V 4: return public void setValue(java.util.Date); //我们重写的setValue方法 Code: 0: aload_0 1: aload_1 2: invokespecial #16 // Method com/tao/test/Pair.setValue :(Ljava/lang/Object;)V 5: return public java.util.Date getValue(); //我们重写的getValue方法 Code: 0: aload_0 1: invokespecial #23 // Method com/tao/test/Pair.getValue :()Ljava/lang/Object; 4: checkcast #26 // class java/util/Date 7: areturn public java.lang.Object getValue(); //编译时由编译器生成的桥方法 Code: 0: aload_0 1: invokevirtual #28 // Method getValue:()Ljava/util/Date 去调用我们重写的getValue方法 ; 4: areturn public void setValue(java.lang.Object); //编译时由编译器生成的桥方法 Code: 0: aload_0 1: aload_1 2: checkcast #26 // class java/util/Date 5: invokevirtual #30 // Method setValue:(Ljava/util/Date; 去调用我们重写的setValue方法 )V 8: return }

从编译的结果来看:

  • 我们本意重写 setValue 和 getValue 方法的子类,结果却有4个方法,最后两个方法,就是编译器自己生成的桥方法。
  • 桥方法的参数类型都是 Object ,也就是说,子类中真正覆盖父类两个方法的,就是这两个我们看不到的桥方法。
  • 打在我们自定义的 setvalue 和 getValue 方法上面的 @Oveerride ,只是个假象。桥方法的内部实现,就只是去调用我们自己重写的那两个方法。

所以,虚拟机巧妙的使用了桥方法,来解决了类型擦除和多态的冲突。

值得注意的是,这两个桥方法的意义不同:

  • setValue 方法是为了解决类型擦除与多态之间的冲突;
  • getValue 却有普遍的意义。

如果这是一个普通的继承关系,那么父类的 setValue 方法如下:

public ObjectgetValue() { return super.getValue(); }

而子类重写的方法是:

public Date getValue() { return super.getValue(); }

其实在普通的类继承中,这是普遍存在的重写,这就是协变

让人感到疑惑的是,子类中的桥方法 Object getValue() 和 Date getValue() 是同时存在的。

  • 如果是常规的两个方法,他们的方法签名也是一样的。也就是说,虚拟机是不能分辨这两个方法的。
  • 如果是我们自己编写 Java 代码,这样的代码是无法通过编译器的检查的,但是虚拟机却允许这样做。

这是因为,虚拟机通过参数类型和返回类型来确定一个方法,所以编译器为了实现泛型的多态,允许自己做这个看起来“不合法”的事情,然后交给虚拟器去区别。

2.3 泛型类型变量不能是基本数据类型

我们不能使用类型参数,来替换基本类型。因为当类型擦除后,ArrayList 的原始类型变为 Object ,由于 Object 类型不能存储 double 值,所以只能引用 Double 的值。

例如:

没有 ArrayList<double> ,只有 ArrayList<Double> 。

2.4 不能在运行时进行类型查询

类型擦除之后,ArrayList<String> 只剩下原始类型,泛型信息 String 就不存在了。

ArrayList<String> arrayList=new ArrayList<String>();

那么,在运行时进行类型查询,使用下面的方法是错误的。

if( arrayList instanceof ArrayList<String>)

2.5 异常中使用泛型的问题

不能抛出,也不能捕获泛型类的对象

事实上,泛型类扩展 Throwable 都不合法。

例如:下面的定义将不会通过编译:

public class Problem<T> extends Exception{......}

不能扩展 Throwable 的原因是:异常都是在运行时捕获和抛出的,而在编译时,泛型信息全都会被擦除掉。

我们假设上面的编译可行,再来看下面的定义:

try{ }catch(Problem<Integer> e1){ 。。 }catch(Problem<Number> e2){ ... }

类型信息被擦除后,这两个地方的 catch ,都变为原始类型 Object :

try{ }catch(Problem<Object> e1){ 。。 }catch(Problem<Object> e2){ ...

catch 两个一模一样的普通异常,不能通过编译:

try{ }catch(Exception e1){ 。。 }catch(Exception e2){//编译错误 ...

不能在 catch 子句中使用泛型变量

public static <T extends Throwable> void doWork(Class<T> t){ try{ ... }catch(T e){ //编译错误 ... } }

泛型信息在编译时,已经变成了原始类型。即上面的 T 会变为原始类型 Throwable 。

如果可以在 catch 子句中使用泛型变量,那么,下面的定义呢:

public static <T extends Throwable> void doWork(Class<T> t){ try{ ... }catch(T e){ //编译错误 ... }catch(IndexOutOfBounds e){ } }

异常捕获的原则是“子类在前面,父类在后面”,上述情况违背了这个原则。

即便使用该静态方法的 T 是 ArrayIndexOutofBounds ,在编译后,仍然会变成 Throwable ,ArrayIndexOutofBounds 是 IndexOutofBounds 的子类,违背了异常捕获的原则。

因此,Java 为了避免这样的情况,禁止在 catch 子句中使用泛型变量。

但是,在异常声明中,可以使用类型变量。

下面方法是合法的:

public static<T extends Throwable> void doWork(T t) throws T{ try{ ... }catch(Throwable realCause){ t.initCause(realCause); throw t; } }

2.6 不能声明参数化类型的数组

代码示例:

Pair<String>[] table = newPair<String>(10); //ERROR

这是因为擦除后,table 的类型变为 Pair[] ,可以转化成一个 Object[] 。

Object[] objarray =table;

数组可以记住自己的元素类型,下面的赋值会抛出一个 ArrayStoreException 异常。

objarray ="Hello"; //ERROR

对于泛型而言,擦除降低了这个机制的效率。

下面的赋值可以通过数组存储的检测,但仍然会导致类型错误:

objarray =new Pair<Employee>();

提示:如果需要收集参数化类型对象,直接使用 ArrayList:ArrayList<Pair<String>> ,最安全且有效。

2.7 不能实例化泛型类型

代码示例:

first = new T(); //ERROR

这是错误的,类型擦除会将这个操作成 new Object() 。

不能建立一个泛型数组:

public<T> T[] minMax(T[] a){ T[] mm = new T[2]; //ERROR ... }

擦除会使得这个方法总是构靠一个 Object[2] 数组(错误字吗?构造?)。但是,我们还可以用反射来构造泛型对象和数组。

利用反射,调用 Array.newInstance :

publicstatic <T extends Comparable> T[]minmax(T[] a) { T[] mm == (T[])Array.newInstance(a.getClass().getComponentType(),2); ... // 以替换掉以下代码 // Obeject[] mm = new Object[2]; // return (T[]) mm; }

2.8 类型擦除后的冲突

当泛型类型被擦除后,创建条件不能产生冲突

如果在 Pair 类中,添加下面的 equals 方法:

class Pair<T> { public boolean equals(T value) { return null; } }

从概念上,它有两个 equals 方法:

  • booleanequals(String); // 在Pair<T>中定义
  • boolean equals(Object); // 从object中继承

这只是一种错觉,实际上,擦除后方法 boolean equals(T) ,变成了方法 boolean equals(Object) 。

这与 Object.equals 方法是冲突的,补救的办法是重新命名引发错误的方法。

要支持擦除的转换,需强行制一个类或者类型变量,不能同时成为两个接口的子类,而这两个子类是同一接口的不同参数化。

下面的代码是非法的:

class Calendar implements Comparable<Calendar>{ ... }

class GregorianCalendar extends Calendar implements Comparable<GregorianCalendar>{...} //ERROR

GregorianCalendar 会实现 Comparable<Calender> 和 Compable<GregorianCalendar> ,这是同一个接口的不同参数化实现。

这一限制与类型擦除的关系并不很明确。非泛型版本:

class Calendar implements Comparable{ ... }

class GregorianCalendar extends Calendar implements Comparable{...} //ERROR

这是合法的。

2 如何解决泛型的类型擦除带来的问题

通常采用的解决方法是:

先检查、再编译,以及检查编译的对象和引用传递的问题。

Java 编译器是通过先检查代码中泛型的类型,然后再进行类型擦除、进行编译的。

代码示例:

public static void main(String[] args) { ArrayList<String> arrayList=new ArrayList<String>(); arrayList.add("123"); arrayList.add(123);//编译错误 }

可见,使用 add 方法添加一个整形,在 eclipse 中,就会直接报错,这是在编译之前的检查。

如果在编译之后检查,类型擦除后,原始类型为 Object ,是应该允许运行任意引用类型来添加的。事实上并不是这样的,这说明了泛型变量的使用,是在编译前检查的。

那么,这个类型检查针对什么呢?我们来看看参数化类型与原始类型的兼容

以 ArrayList 为例,以前的写法:

ArrayList arrayList=new ArrayList();

现在的写法:

ArrayList<String> arrayList=new ArrayList<String>();

如果与之前的代码兼容,引用传值之间,就会出现如下的情况:

ArrayList<String> arrayList1=new ArrayList(); //第一种情况 ArrayList arrayList2=new ArrayList<String>();//第二种情况

这样操作没有错误,但会出现编译时警告。

  • 第一种情况,可以实现与完全使用泛型参数一样的效果;
  • 第二种情况,则完全没效果。

这是因为:

类型检查是编译时完成的,New ArrayList() 只是在内存中开辟一个存储空间,可以存储任何的类型对象。真正涉及类型检查的是它的引用,因为我们是使用它,引用 ArrayList1 来调用它的方法,比如说调用 add() 方法。

所以, ArrayList1 引用能完成泛型类型的检查,而 ArrayList2 引用没有使用泛型,故不行。

代码示例:

public class Test10 { public static void main(String[] args) { // ArrayList<String> arrayList1=new ArrayList(); arrayList1.add("1");//编译通过 arrayList1.add(1);//编译错误 String str1=arrayList1.get(0);//返回类型就是String ArrayList arrayList2=new ArrayList<String>(); arrayList2.add("1");//编译通过 arrayList2.add(1);//编译通过 Object object=arrayList2.get(0);//返回类型就是Object new ArrayList<String>().add("11");//编译通过 new ArrayList<String>().add(22);//编译错误 String string=new ArrayList<String>().get(0);//返回类型就是String } }

看完上面的示例,我们大致就明白了:

类型检查是针对引用的,谁是一个引用,用这个引用调用泛型方法,就会对这个引用调用的方法进行类型检测,而无关它真正引用的对象。

我们再来看下,泛型中的参数化类型为什么不考虑继承关系?

在 Java 中,下面形式的引用传递是不允许的:

ArrayList<String> arrayList1=new ArrayList<Object>();//编译错误 ArrayList<Object> arrayList1=new ArrayList<String>();//编译错误

先看第一种情况,将第一种情况拓展成下面的形式:

ArrayList<Object> arrayList1=new ArrayList<Object>(); arrayList1.add(new Object()); arrayList1.add(new Object()); ArrayList<String> arrayList2=arrayList1;//编译错误

实际上,在第 4 行代码时,就会出现编译错误。

假设:

它的编译没错,当我们使用 arrayList2 引用,用 get() 方法取值时,返回的都是 String 类型的对象(类型检测是根据引用来决定的)。实际上,它里面已经被我们存放了 Object类型 的对象,这样,就会出现 ClassCastException 。

所以,为避免出现这样的错误,Java 不允许进行这样的引用传递。这是泛型出现的原因(为了解决类型转换的问题),我们不能违背它的设计初衷。

再来看第二种情况,我们将第二种情况拓展成下面的形式:

ArrayList<String> arrayList1=new ArrayList<String>(); arrayList1.add(new String()); arrayList1.add(new String()); ArrayList<Object> arrayList2=arrayList1;//编译错误

显而易见,第二种比第一种更好。最起码,我们用 ArrayList2 取值时,不会出现 ClassCastException 。

这样操作的意义:

  • 泛型出现的原因,是为了解决类型转换的问题。我们使用了泛型,还要自己强转,这就违背了泛型设计的初衷, Java 不允许这么做。
  • 如果又用 ArrayList2 往里面 add() 新的对象,取出时,它怎么知道我取出的是 String 类型、还是 Object 类型呢?

所以,我们要特别注意,泛型中的引用传递的问题。

总结

本篇通过源码实例,列举了泛型擦除的局限性,以及局限性导致的8个常见问题,并给到具体可行的解决方法,可以作为泛型擦除使用参考,建议收藏备用、多练习。

我是大全哥,持续更新成体系的 Java 核心技术。知识成体系学习才高效

如果觉得有帮助,请顺手 点赞 支持下,谢谢。

我们下期见~

附泛型学习资料:

1 《泛型知识全景导图》

快速构建泛型知识体系,高清版本原图,几乎囊括了所有泛型核心知识点。

擦除分析(类型擦除的局限性)(2)

泛型知识全景导图

2 《大厂泛型面试真题26道》

精选大厂高频泛型面试题,都是我最新整理的,备面、复习时都可以查看。

擦除分析(类型擦除的局限性)(3)

大厂泛型面试题26道

擦除分析(类型擦除的局限性)(4)

--- end ---

,