擦除分析(类型擦除的局限性)
因为类型擦除,泛型才能与 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 《大厂泛型面试真题26道》
精选大厂高频泛型面试题,都是我最新整理的,备面、复习时都可以查看。
大厂泛型面试题26道
--- end ---
,
免责声明:本文仅代表文章作者的个人观点,与本站无关。其原创性、真实性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容文字的真实性、完整性和原创性本站不作任何保证或承诺,请读者仅作参考,并自行核实相关内容。文章投诉邮箱:anhduc.ph@yahoo.com