CH04-泛型机制
概览
Java 的泛型特性由 JDK 1.5 引入,因此为了兼容之前的版本,Java 泛型的实现采用了“伪泛型”策略。即 Java 在语法上支持泛型,但是在编译阶段会执行“类型擦除(Type Erasure)”,将所有的泛型表示都替换为实际的类型,就像完全没有泛型一样。
泛型的本质是为了参数化类型,在不创建新类型的情况下,通过泛型指定的不同类型来控制形式化参数所限制的具体类型。也就是说在泛型的使用过程中,操作的数据类型被指定为一个参数,该类型参数可以用在类、接口、方法中,分别被称为泛型类、泛型接口、泛型方法。
引入泛型的最直接意义在于:
- 适用于多种数据类型执行相同的代码逻辑(代码复用)。
- 泛型中的类型在使用时指定,不需要强制类型转换(类型安全,由编译器执行检查)。
基本使用
泛型类
class Point<T> {
private T var;
public T getVar() {
return var;
}
public void setVar(T var){
this.var = var;
}
}
Point<String> point = new Point<>();
pioint.setVar("value");
泛型接口
interface Info<T> {
T getVar();
}
class InfoImpl<T> implements Info<T> {
private T var;
}
泛型方法
class Caster {
public static <T> T castAs(Class<T> clazz, Object value) {
return clazz.cast(value);
}
}
泛型边界
- 参考 “泛型型变-Scala”。
泛型擦除
擦除原则:
- 消除类型参数声明,即删除
<>
及其包围的部分。 - 根据类型参数的上下界推断并替换所有的类型参数为具体类型:
- 如果类型参数是无限制通配符或没有上下界限定则替换为 Object。
- 如果存在上下界限定则根据子类替换原则取类型参数的最左侧限定类型,即父类。
- 为了保证类型安全,必要时插入强制类型转换代码。
- 自动产生“桥接方法”以保证类型擦除后的代码仍然具有泛型的“多态性”。
如何执行类型擦除:
参数类定义中的类型参数:无限制类型擦除
当类定义中类型参数没有任何限制时,直接将其替换为 Object,如
<T>
或<?>
会被直接替换为 Object。擦除类定义中的类型参数:有限制类型擦除
如果类定义中的类型参数有上下界限定,在擦除时将其替换为类型参数的上界或下界,如
<T extends Number>
和<? extends Number>
会被替换为 Number,<? super Number>
会被替换为 Object。擦除方法定义中的类型参数
原则和擦除类定义中的类型参数一致,这里仅擦除方法定义中的有限制类型参数为例。
如何证明类型擦除
原始类型相等
ArrayList<String> list1 = new ArrayList<>(); list1.add("abc"); ArrayList<Integer> list2 = new ArrayList<>(); list2.add(123); assert list1.getClass() == list2.getClass();
通过反射添加其他类型的元素
ArrayList<Integer> list = new ArrayList<>(); list.add(1); list.getClass().getMethod("add", Object.class).invoke(list, "abc");
类型擦除后的原生类型(Raw Type)
原生类型为擦除掉泛型信息后,保留在字节码中的类型变量的真正类型,无论何时定义一个泛型类型,对应的原始类型都会自动提供,类型变量擦除,并使用其限定类型或 Object 替换。
如何理解编译期检查
Java 编译器首先检查代码中泛型的类型,然后再执行类型擦除,然后再执行编译。
基于代码的类型检查是如何进行的呢?
比如以下代码:
ArrayList<String> list1 = new ArrayList(); //第一种 情况
ArrayList list2 = new ArrayList<String>(); //第二种 情况
以上代码可以编译通过,且不会出现变异警告。但是仅第一种可以实现与完全使用泛型参数一样的效果,第二种不行。
因为类型检查就是编译时完成的,new ArrayList()
只是在内存中开辟一个存储空间,可以存储任何类型的对象,而真正涉及类型检查的是它的引用,因为我们是通过引用 list1 来调用它的方法,比如调用其 add 方法,所以 list1 引用能能完成泛型类型的检查。而引用 list2 没有使用泛型,所以不行。
ArrayList<String> list1 = new ArrayList();
list1.add("1"); //编译通过
list1.add(1); //编译错误
String str1 = list1.get(0); //返回类型就是String
ArrayList list2 = new ArrayList<String>();
list2.add("1"); //编译通过
ist2.add(1); //编译通过
Object object = list2.get(0); //返回类型就是Object
new ArrayList<String>().add("11"); //编译通过
new ArrayList<String>().add(22); //编译错误
String str2 = new ArrayList<String>().get(0); //返回类型就是String
因此可以得出,类型检查就是针对引用执行的检查,谁是一个引用,通过该引用调用泛型方法,就会对这个引用调用的方法进行类型检测,而无关它真正引用的对象。
泛型多态/桥接方法
类型擦除会造成多态冲突,而 JVM 通过桥接方法类解决。
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 类型。
实际上类型擦除后,父类的泛型类型全部变成了 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,参数类型不一样,如果是在普通的继承体系中,根本就不会是重写,而是重载。但是如果通过基于重载的形式以子类来调用父类的方法:
DateInter dateInter = new DateInter();
dateInter.setValue(new Date());
dateInter.setValue(new Object()); //编译错误
如果是重载,那么子类中会有两个 setValue 方法,一个参数为 Object,一个参数为 Date,但编译错误可以得出这里实际上是重写。
原因是,当我们集成父类并设定其类型参数为 Date,期望将泛型类变成如下形式:
class Pair {
private Date value;
public Date getValue() {
return value;
}
public void setValue(Date value) {
this.value = value;
}
}
然后在子类中重写参数类型为 Date 的两个方法,实现继承中的多态。
但是由于种种原因,JVM 并不能将泛型类型转换为 Date,只能将其擦除,称为原生类型 Object。这样我们本意是重写来实现多态,但类型擦除后只能变为了重载。从而导致类型擦除与多态的冲突。因此 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
}
从编译结果来看,我们原本要重写方法实际上成了 4 个方法,最后两个方法即为编译器自动生成的桥接方法。可以发现桥接方法的参数类型都是 Object,也就是说,子类中真正覆盖父类方法的就是这两个桥接方法。桥接方法的内部会执行类型转换并调用我们重写的那两个方法。
基本类型不能作为泛型参数
比如只能使用 List<Integer>
而不能使用 List<int>
。因为当类型擦除后,List 的原生类型变为 Object,但是 Object 不能存储 int 值,只能是引用类型 Integer 的值。
然后实际操作中使用的 list.add(1)
,则是基本类型的自动装箱机制。
泛型类型不能实例化
T t = new T();
以上代码会直接报错。因为 Java 编译期没法确定泛型参数的实际类型,也就找不到对应的类的字节码文件,所以自然就不能完成编译。此外由于 T 被擦除为 Object,如果可以执行 new T()
,则就变成了 new Object()
,失去了代码的本意。
泛型数组:通过具体类型参数初始化
List<String>[] lsa = new List<String>[10]; // Not really allowed.
Object o = lsa;
Object[] oa = (Object[]) o;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
oa[1] = li; // Unsound, but passes run time store check
String s = lsa[1].get(0); // Run-time error ClassCastException.
由于擦除机制,上面代码可以给 oa[1]
赋值为 ArrayList 类型的变量也不会出现异常。但是在取出数据时要做一次类型转换,所以就会出现 ClassCastException。
如果可以执行泛型数组的声明则上面这种情况就不会出现任何警告和错误,只有在运行时才会报错,但是泛型的出现就是为了避免转型错误,所有 Java 支持泛型数组初始化则与初衷违背。
下面的代码是成立的:
List<?>[] lsa = new List<?>[10]; // OK, array of unbounded wildcard type.
Object o = lsa;
Object[] oa = (Object[]) o;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
oa[1] = li; // Correct.
Integer i = (Integer) lsa[1].get(0); // OK
所以说采用通配符的方式初始化泛型数组是允许的,因为对于通配符的方式是最后取出数据时才执行转型,符合预期逻辑。
Java 的泛型数组初始化时数组类型不能是具体的泛型类型,只能是通配符的形式,因为具体类型会导致可存入任意类型对象,在取出时会发生类型转换异常,会与泛型的设计思想冲突,而通配符形式本来就需要自己强转,符合预期。
泛型数组:正确的初始化数组实例
无论是 new ArrayList[10]
的形式还是通过泛型通配符的形式来初始化都存在转型异常风险。
在需要使用泛型数组时应尽量使用列表集合替代,此外也可以通过反射来创建一个具有指定类型的数组:
public class ArrayWithTypeToken<T> {
private T[] array;
public ArrayWithTypeToken(Class<T> type, int size) {
array = (T[]) Array.newInstance(type, size);
}
public void put(int index, T item) {
array[index] = item;
}
public T get(int index) {
return array[index];
}
public T[] create() {
return array;
}
}
//...
ArrayWithTypeToken<Integer> arrayToken = new ArrayWithTypeToken<Integer>(Integer.class, 100);
Integer[] array = arrayToken.create();
泛型类中的静态方法与静态变量
public class Test<T> {
public static T one; //编译错误
public static T show(T one){ //编译错误
return null;
}
}
因为泛型类中的类型参数是在定义对象时才指定的,而静态变量或静态方法不需要使用对象实例来调用。对象未被创建,如何确定该泛型参数的具体类型,所以错误。
public class Test<T> {
public static <T> T show(T one){ //这是正确的
return null;
}
}
注意这里 show 方法的类型参数 T 并非 Test 类的类型参数 T。
异常中使用泛型
- 不能抛出也不能捕获泛型对象。继承 Throwable 时使用泛型也是非法的。
- 因为异常仅在运行时抛出,编译期擦除参数类型后无法区分不同具体类型的泛型异常。
- 不能在 catch 子句中使用泛型变量
- 因为泛型信息会被擦除为原生类型,
catch(T e)
会变成catch(Throwable e)
- 根据异常捕获原则,如果下面还有别的 catch 语句来处理其他异常类型,导致冲突。
- 因为泛型信息会被擦除为原生类型,
- 可以在声明中抛出参数啊类型的异常,
<T extends Throwable> void do(T t) throws T
如何获取类型参数
java.lang.reflect.Type
是 Java 中所有类型的公共高级接口,代表了所有 Java 中的类型。Type 体系中的类型包括:
- 数组类型:GenericArrayType
- 参数化类型:ParameterizedType
- 类型变量:TypeVariable
- 通配符类型:WildcardType
- 原始类型:Class
- 基本类型:Class
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.