Java魔法堂:解读基于Type Erasure的泛型

时间:2022-06-12 00:26:56

一、前言                            

还记得JDK1.4时遍历列表的辛酸吗?我可是记忆犹新啊,那时因项目需求我从C#转身到Java的怀抱,然后因JDK1.4少了泛型这样语法糖(还有自动装箱、拆箱),让我受尽苦头啊,不过也反映自己的水平还有待提高,呵呵。JDK1.5引入了泛型、自动装箱拆箱等特性,C#到Java的过渡就流畅了不少。下面我们先重温两者非泛型和泛型的区别吧!

// 非泛型遍历列表
List lst = new ArrayList();
lst.add();
lst.add();
int sum = ;
for (Iterator = lst.iterator(); lst.hasNext();){
Integer i = (Integer)lst.next();
sum += i.intValue();
} // 泛型遍历列表
List<Integer> lst = new ArrayList<Integer>();
lst.add();
lst.add();
int sum = ;
for (Iterator = lst.iterator(); lst.hasNext();){
Integer i = lst.next();
sum += i;
}

泛型的最主要作用是在编译时期就检查集合元素的类型,而不是运行时才抛出ClassCastException。

泛型的官方文档:http://docs.oracle.com/javase/tutorial/java/generics/erasure.html

注意:以下内容基于JDK7和HotSpot。

二、认识泛型                          

在介绍之前先定义两个测试类,分别是 类P 和 类S extends P 。

1. 声明泛型变量,如 List<String> lst = new ArrayList<String>();

注意点——泛型不支持协变

// S为P的子类,但List<S>并不是List<P>的子类,也就是不支持协变
// 因此下列语句无法通过编译
List<P> lst = new ArrayList<S>(); // 而数组支持协变
P[] array = new S[];

注意点——父类作为类型参数,则可以子类实例作为集合元素

List<P> lst = new ArrayList<P>();
lst.add(new S());

  2. 声明带通配符泛型变量,如 List<?> lst = new ArrayList<P>();

通配符 ? 表示类型参数为未知类型,因此可赋予任何类型的类型参数给它。

当集合的类型参数 ? 为时,无法向集合添加除null外的其他类型的实例。(null属于所有类的子类,因此可以赋予到未知类型中)

List<?> lst = new ArrayList<P>();
lst = new ArrayList<S>();
// 以下这句将导致编译失败
lst.add(new S()); // 以下这句则OK
lst.add(null);

因此带通配符的泛型变量一般用于检索遍历集合元素使用,而不做添加元素的操作。

void read(List<?> lst){
for (Object o : lst){
System.out.println((o.toString());
}
}
List<String> lst = new ArrayList<String>();
lst.add("");
lst.add("");
read(lst);

到这里会发现使用带通配符的泛型集合(unbounded wildcard generic type) 与 使用非泛型集合(raw type)的效果是一样的,其实并不是这样.

我们可以向非泛型集合添加任何类型的元素, 而通配符的泛型集合则只允许添加null而已, 从而提高了类型安全性. 而且我们还可以使用带限制条件的带边界通配符的泛型集合呢!

3. 声明带边界通配符 ? extends 的泛型变量,如 List<? extends P> lst = new ArrayList<S>();

边界通配符 ? extends 限制了实际的类型参数必须为指定的类本身或其子类才能通过编译。

void read(List<? extends P> lst){
for (P p : lst){
System.out.println(p);
}
}
List<P> lst = new ArrayList<P>();
lst.add(new P());
lst.add(new S());
read(lst);

  4. 声明带边界通配符 ? super 的泛型变量,如 List<? super S> lst = new ArrayList<P>();

边界通配符 ? super限制了实际的类型参数必须为指定的类本身或其父类才能通过编译。

注意:集合元素的类型必须为指定的类本身或其子类。

void read(List<? super S> lst){
for (S s : lst)
System.out.println(s);
}
List<P> lst = new ArrayList<P>();
lst.add(new S());
read(lst);

5. 定义泛型类或接口,如 class Fruit<T>{} 和 interface Fruit<T>{}

T为类型参数占位符,一般以单个大写字母来命名。以下为推荐的占位符名称:

K——键,比如映射的键。
V——值,比如List、Set的内容,Map中的值
E——异常类
T——泛型

除了异常类、枚举和匿名内部类外,其他类或接口均可定义为泛型类。

泛型类的类型参数可供实例方法、实例字段和构造函数中使用,不能用于类方法、类字段和静态代码块上。

class Fruit<T>{
// 类型参数占位符作为实例字段的类型
private T fruit; // 类型参数占位符作为实例方法的返回值类型
T getFruit(){
return fruit;
}
// 类型参数占位符作为实例方法的入参类型
void setFruit(T fruit){
this.fruit = fruit;
}
private List<T> fruits;
// 类型参数占位符作为边界通配符的限制条件
void setFruits(List<? extends T> lst){
fruits = (List<T>)lst;
}
// 类型参数占位符作为实例方法的入参类型的类型参数
void setFruits2(List<T> lst){
fruits = lst;
} // 构造函数不用带泛型
Fruit(){
// 类型参数占位符作为局部变量的类型
fruits = new ArrayList<T>();
T fruit = null;
}
}

和边界通配符一般类型参数占位符也可带边界,如 class Fruit<T extends P>{} 。当有多个与关系的限制条件时,则用&来连接多个父类,如 class Fruit<T extends A&B&C&D>{} 。

也可以定义多个类型参数占位符,如 class Fruit<S,T>{} 、 class Fruit<S, T extends A>{} 等。

下面到关于继承泛型类或接口的问题了,假设现在有泛型类P的类定义为 class P<T>{} ,那么在继承类P时我们有两种选择

1. 指定类P的类型参数

2. 继承类P的类型参数

// 1. 指定父类的类型参数
class S extends P<String>{} // 2. 继承父类的类型参数
class S<T> extends P<T>{}

   6.使用泛型类或接口,如 Fruit<?> fruit = new Fruit<Apple>();

现在问题来了,假如Fruit类定义如下: public class Fruit<T extends P>{}

那么假设使用方式为 Fruit<? extends String> fruit; ,大家决定编译能通过吗?答案是否定的,类型参数已经被限制为P或P的子类了,因此只有 Fruit<? extends P> 或 Fruit<? extends S> 可通过编译。

   7. 定义泛型方法

无论是实例方法、类方法还是抽象方法均可以定义为泛型方法。

// 实例方法
public <T> void say(T[] msgs){
for (T msg : msgs)
System.out.println(msg.toString());
}
public <T extends P> T create(Class<T> clazz) throws InstantiationException, IllegalAccessException{
return clazz.newInstance();
} // 类方法
public static <T> void say(T msg){
System.out.println(msg.toString());
}
public static <T extends P> T create(Class<T> clazz) throws InstantiationException, IllegalAccessException{
return clazz.newInstance();
} // 抽象方法
public abstract <T> void say(T msg);
public abstract <T extends P> T create(Class<T> clazz) throws InstantiationException, IllegalAccessException{}

   8. 使用泛型方法

使用泛型方法分别有 隐式指定实际类型显式指定实际类型 两种形式。

P p = new P();
String msg = "Hello";
// 隐式指定实际类型
p.say(msg); // 显式指定实际类型
p.<String>say(msg);

一般情况下使用隐式指定实际类型的方式即可。

  9. 使用泛型数组

    只能使用通配符来创建泛型数组

List<?>[] lsa = new ArrayList<String>[]; // 抛异常
List<?>[] lsa = new ArrayList<?>[]; List<String> list = new ArrayList<String>();
list.add("test");
lsa[] = list;
System.out.println(lsa[].get());    

四、类型擦除(Type Erasure)和代码膨胀(Code Bloat)    

到此大家对Java的泛型有了一定程度的了解了,但在应用时却时不时就发生些匪夷所思的事情。在介绍这些诡异案例之前,我们要补补一些基础知识,那就是Java到底是如何实现泛型的。

泛型的实现思路有两种

1. Code Specialization:在实例化一个泛型类或泛型方法时将产生一份新的目标代码(字节码或二进制码)。如针对一个泛型List,当程序中出现List<String>和List<Integer>时,则会生成List<String>,List<Integer>等的Class实例。

2. Code Sharing:对每个泛型只生成唯一一份目标代码,该泛型类的所有实例的数据类型均映射到这份目标代码中,在需要的时候执行类型检查和类型转换。如针对List<String>和List<Integer>只生成一个List<Object>的Class实例。

C++的模板 和 C# 就是典型的Code Specialization。由于在程序中出现N种L泛型List则会生成N个Class实例,因此会造成代码膨胀(Code Bloat)。

而Java则采用Code Sharing的思路,并通过类型擦除(Type Erasure)来实现。

类型擦除的过程大致分为两步:

①. 使用泛型参数extends的边界类型来代替泛型参数(<T> 默认为<T extends Object>,<?>默认为<? extends Object>)。

②. 在需要的位置插入类型检查和类型转换的语句。

interface Comparable<T>{
int compareTo(T that);
}
final class NumericVal implements Comparable<NumericVal>{
public int compareTo(NumericVal that){ return ;}
}

擦除后:

interface Comparable{
int compareTo(Object that);
}
final class NumericVal implements Comparable{
public int compareTo(NumericVal that){ return ;}
// 编译器自动生成
public int compareTo(Object that){
return this.compareTo((NumbericVal)that);
}
}

也就是说

List<String> lstStr = new ArrayList<String>();
List<Integer> intStr = new ArrayList<Integer>();
System.out.println(lstStr.getClass() == intStr.getClas()); // 显示true,因为lstStr和intStr的类型均被擦除为List了

五、各种基于Type Erasure的泛型的诡异场景          

1. 泛型类型共享类变量

class Fruit<T>{
static String price = ;
}
Fruit<Apple>.price = ;
Fruit<Pear>.price = ;
System.out.println(Fruit.<Apple>.price); // 输出5

2. instanceof 类型参数占位符 抛出编译异常

List<String> strLst = new ArrayList<String>();
if (strLst instanceof List<String>){} // 不通过编译
if (strLst instanceof List){} // 通过编译

3. new 类型参数占位符 抛出编译异常

class P<T>{
T val = new T(); // 不通过编译
}

4. 定义泛型异常类 抛出编译异常

class MyException<T> extends Exception{} // 不通过编译

5. 不同的泛型类型形参无法作为不同描述符标识来区分方法

// 视为相同的方法,因此会出现冲突
public void say(List<String> msg){}
public void say(List<Integer> number){} // JDK6后可通过不同的返回值类来解决冲突
// 对于Java语言而言,方法的签名仅为方法名+参数列表,但对于Bytecodes而言方法的签名还包含返回值类型。因此在这种特殊情况下,Java编译器允许这种处理手段
public void say(List<String> msg){}
public int say(List<Integer> number){}

六、再深入一些                          

  1. 采用隐式指定类型参数类型的方式调用泛型方法,那到底是如何决定的实际类型呢?

假如现有一个泛型方法的定义为 <T extends Number> T handle(T arg1, T arg2){ return arg1;}

那么根据类型擦除的操作步骤,T的实际类型必须是Number的。看看字节码吧 Method handle:(Ljava/lang/Number;Ljava/lang/Number;)Ljava/lang/Number;Ljava/lang/Number;

剩下的就是类型检查和类型转换的活了,根据不同的入参类型和对返回值进行类型转换的组合将导致不同的结果。

// 编译时报“交叉类型”编译失败
Integer ret = handle(, 1L); // 编译成功
Number ret = handle(, 1L);
Integer ret = handle(,);

Number ret = handle(1, 1L)对应的Bytecodes为

: invokestatic  #                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
: invokevirtual # // Method handle:(Ljava/lang/Number;Ljava/lang/Number;)Ljava/lang/Number;

而Interger ret = handle(1, 1L)对应的Bytescodes则多了checkcast指令用于作类型转换

: invokestatic  #                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
: invokevirtual # // Method handle:(Ljava/lang/Number;Ljava/lang/Number;)Ljava/lang/Number;
: checkcast # // class java/lang/Integer

根据上述规则,所以下列代码会由于方法定义冲突而编译失败

// 编译失败
<T extends String> void println(T msg){}
void println(String msg){}

  2. 效果一致但写法不同的两个泛型方法

public static <T extends P> T getP1(Class<T> clazz){
  T ret = null;
try{
  ret = clazz.newInstance();
}
catch(InstantiationException|IllegalAccessException e){}
return ret;
}
} public static <T> T getP2(Class<? extends P> clazz){
  T ret = null;
try{
  ret = (T)clazz.newInstance();
}
catch(InstantiationException|IllegalAccessException e){}
  return ret;
}
}

getP1的内容不难理解,类型参数占位符T会被编译成P,因此类型擦除后的代码为:

public static P getP1(Class clazz){
 P ret = null;
try{
  ret = (P)clazz.newInstance();
}
catch(InstantiationException|IllegalAccessException e){}
return ret;
}
}

而getP2中T被编译为Object,而clazz.newInstance()返回值类型为Object,那么为什么要加(T)来进行显式的类型转换呢?但假如将<T>改成<T extends Number>,那显式类型转换就变为必须品了。我猜想是因为getP2的书写方式导致返回值与入参的两者的类型参数是没有任何关联的,无法保证一定能成功地执行隐式类型转换,因此规定开发人员必须进行显式的类型转换,否则就无法通过编译。但最吊的是Bytecodes里没有类型转换的语句

: invokevirtual #                  // Method java/lang/Class.newInstance:()Ljava/lang/Object;
: astore_1

七、总结                          

若有纰漏请大家指正,谢谢!

尊重原创,转载请注明来自:http://www.cnblogs.com/fsjohnhuang/p/4288614.html ^_^肥仔John

八、参考                          

http://blog.zhaojie.me/2010/02/why-not-csharp-on-jvm-type-erasure.html

http://blog.csdn.net/lonelyroamer/article/details/7868820

http://www.programcreek.com/2013/12/raw-type-set-vs-unbounded-wildcard-set/