深入理解 Java 泛型与类型擦除

1. 什么是泛型

Java 1.5 发行版本中增加了泛型(Generic)。在没有泛型之前,从集合中读取到的每一个对象都必须进行装换。如果有人不小心插入了类型错误的对象,在运行时的装换处理器就会出错。有了泛型之后,可以告诉编译器每个集合中接受哪些对象类型。编译器自动为你的插入进行转化,并在编译器告知是否插入了类型错误对象。

那什么是泛型呢?声明中具有一个或者多个类型参数的类或者接口就是泛型类或者接口。例如,从 Java 1.5 发行版本开始,List 接口就只有单个类型参数 E,表示列表的元素类型。从技术的角度看,这个接口的名称应该是指现在的 List<E>,但是人们经常把它简称为 List。泛型类和接口都统称为泛型。每种泛型定义一组参数化的类型,构成格式为:先是类和接口的名称,接着用尖括号(‘<>’)把对应于泛型形式类型参数的实际类型参数列表括起来。例如,List<String> 是一个参数化的类型,表示元素类型为 String 的列表。String 是与形式类型参数 E 相对应的实际类型参数。

泛型都定义一个原生态类型,即不带任何实际类型参数的泛型名称。例如,与 List<E> 相对应的原生态类型是 List。原生态类型就像从类型声明中删除了所有泛型信息一样。实际上,原生态类型 List 与 Java 没有泛型之前的接口类型 List 完全一样。

术语 示例
泛型 List<E>
形式类型参数 E
参数化的类型 List
实际类型参数 String
原生态类型 List
有限制类型参数 <E extends NUmber>

2. 为什么使用泛型

使用泛型的代码比非泛型代码有很多好处。

2.1 类型安全

Java 编译器对泛型代码应用强类型检查,如果代码违反类型安全就会发出错误。修复编译时错误比修复运行时错误更容易,因为运行时错误很难排查。

假设在 Java 1.5 发行版本之前,我们使用 List 定义了一个只保存正方形对象的列表 squareList。如果不小心将一个长方形对象放进了列表中,这一错误插入照样编译和运行并且不会出现任何错误,直到我们从列表中获取正方形对象的时候才会受到错误提示:

// 目标是定义一个只保存正方形的列表
List squareList = new ArrayList();
squareList.add(new Square(4));
squareList.add(new Square(2));
// 误添加一个长方形
squareList.add(new Rectangle(5, 3));
for (Object square : squareList) {
Square s = (Square) square; // throw ClassCastException
System.out.println(s.getArea());
}

到了运行阶段发现错误再排查就难得多了,出错之后应该尽快发现,最好是在编译阶段就被发现。有了泛型,我们就可以利用改进后的类型声明来告诉编译器我们要的是一个正方形对象的列表:

List<Square> squareList = new ArrayList();

通过这条声明,编译器知道 squareList 应该只包含 Square 实例,并会给予保证,准确的告诉我们哪里出错了:

2.2 无需强制类型转换

下面没有泛型的代码片段需要强制类型转换:

List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0);

当重写为使用泛型时,代码不需要强制类型转换:

List<String> list = new ArrayList<String>();
list.add("hello");
String s = list.get(0); // no cast

2.3 其他

此外,还可以提升可读性,从编码阶段就知道泛型类、泛型方法等处理的对象类型是什么;还可以提高代码的复用,泛型合并了同类型的处理代码,使代码的重用度提高。

3. 泛型的实现方法:类型擦除

类型擦除可以理解为泛型只是在编译时强制执行类型约束,并在运行时丢弃(或者擦除)元素类型信息。擦除就是使泛型可以与没有使用泛型的代码随意进行互用。类型擦除可以发生在类(或变量)以及方法级别。

3.1 泛型类的类型擦除

在类型擦除过程中,Java 编译器擦除所有类型参数,如果类型参数是有限制的,则使用其第一个限制参数替换每个类型参数,如果类型参数不受限制,则用 Object 替换。考虑以下表示单向链表节点的泛型类:

public class Node<T> {
private T data;
private Node<T> next;
public Node(T data, Node<T> next) {
this.data = data;
this.next = next;
}
public T getData() { return data; }
// ...
}

由于类型参数 T 不受限制,因此 Java 编译器将其替换为 Object:

public class Node {
private Object data;
private Node next;
public Node(Object data, Node next) {
this.data = data;
this.next = next;
}
public Object getData() { return data; }
// ...
}

在下面的例子中,泛型 Node 类使用了一个有限制的类型参数:

public class Node<T extends Comparable<T>> {
private T data;
private Node<T> next;
public Node(T data, Node<T> next) {
this.data = data;
this.next = next;
}
public T getData() { return data; }
// ...
}

Java 编译器将有限制的类型参数 T 替换为第一受限类 Comparable:

public class Node {
private Comparable data;
private Node next;
public Node(Comparable data, Node next) {
this.data = data;
this.next = next;
}
public Comparable getData() { return data; }
// ...
}

3.2 泛型方法的类型擦除

Java 编译器还会擦除泛型方法参数中的类型参数。考虑以下泛型方法:

// 计算元素 elem 在数组中出现的次数
public static <T> int count(T[] anArray, T elem) {
int cnt = 0;
for (T e : anArray)
if (e.equals(elem))
++cnt;
return cnt;
}

因为 T 是非限制类型参数,Java 编译器将它替换为 Object:

public static int count(Object[] anArray, Object elem) {
int cnt = 0;
for (Object e : anArray)
if (e.equals(elem))
++cnt;
return cnt;
}

假设定义了以下类:

class Shape { /* ... */ }
class Circle extends Shape { /* ... */ }
class Rectangle extends Shape { /* ... */ }

我们可以编写一个泛型方法来绘制不同的形状:

public static <T extends Shape> void draw(T shape) { /* ... */ }

Java 编译器会用 Shape 替换 T:

public static void draw(Shape shape) { /* ... */ }

3.3 类型擦除与桥方法

现在有这样一个泛型类:

public class Node<T> {
public T data;
public Node(T data) { this.data = data; }
public void setData(T data) {
System.out.println("Node.setData");
this.data = data;
}
}

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

public class MyNode extends Node<Integer> {
public MyNode(Integer data) { super(data); }
@Override
public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}
}

在这个子类中,我们设定父类的参数化的类型为 Node,在子类中我们覆盖了父类的方法,我们的本意是:将父类的泛型类型限定为 Integer,那么父类里面方法的参数为 Integer 类型:

public class Node {
public Integer data;
public Node(Integer data) { this.data = data; }
public void setData(String data) {
System.out.println("Node.setData");
this.data = data;
}
}

所以,我们在子类中重写这个方法一点问题也没有,另外从 @Override 标签中也可以看到没什么问题,实际上是这样的吗?实际上,类型擦除后,父类的的泛型实际类型参数全部变为了 Object,所以父类编译之后会变成下面的样子:

public class Node {
public Object data;
public Node(Object data) { this.data = data; }
public void setData(Object data) {
System.out.println("Node.setData");
this.data = data;
}
}

我们来分析一下 setData 方法,父类的类型是 Object,而子类的类型是 Integer,参数类型不一样,如果是在普通的继承关系中,根本就不会是重写,而是重载。我们在一个 main 方法测试一下:

public static void main(String[] args) throws ClassNotFoundException {  
MyNode myNode = new MyNode();
myNode.setData(123);
myNode.setData(new Object()); //编译错误
}

如果是重载,那么子类中 setData 方法应该有两个,一个是参数 Object 类型,一个是 Integer 类型,可是我们发现,根本就没有这样的一个子类继承自父类的 Object 类型参数的方法。所以说,确实是重写了,而不是重载了。为什么会这样呢?原因是我们传入父类的泛型实际类型参数是 Integer,我们的本意是将泛型类变为如下:

public class Node {
public Integer data;
public Node(Integer data) { this.data = data; }
public void setData(String data) {
System.out.println("Node.setData");
this.data = data;
}
}

然后在子类中重写参数类型为 Integer 的方法,实现继承中的多态。可是由于种种原因,虚拟机并不能将泛型实际类型参数变为 Integer,只能将类型擦除掉,变为 Object。这样,我们的本意是进行重写,实现多态。可是类型擦除后,只能变为了重载。这样,类型擦除就和多态有了冲突。JVM 知道你的本意吗?知道!!!可是它能直接实现吗,不能!!!如果真的不能的话,那我们怎么去重写我们想要的 Integer 类型参数的方法呢。于是 JVM 采用了一个特殊的方法,来完成这项功能,那就是桥方法:

class MyNode extends Node {
public MyNode(Integer data) { super(data); }
// 编译器生成的桥方法
public void setData(Object data) {
setData((Integer) data);
}
public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}
}

可以看到桥方法的参数类型都是 Object,也就是说,子类中真正覆盖父类方法的就是这个我们看不到的桥方法。而打在我们自己定义的 setData 方法上面的 @Overide 只不过是假象。而桥方法的内部实现,就只是去调用我们自己重写的那两个方法。所以,虚拟机巧妙的使用了桥方法,来解决了类型擦除和多态的冲突。

4. 泛型类型擦除证明

我们通过两个例子来证明泛型的类型擦除。第一个例子中,我们定义了两个 List 数组:第一个是 List<String> 泛型类型的字符串列表,只能存储字符串,另一个是 List<Integer> 泛型类型的数值型列表,只能存储整数。最后,我们通过 strList 对象和 intList 对象的 getClass() 方法获取他们的类的信息,最后发现结果为 true。说明 String 和 Integer 实际类型参数都被擦除掉了,只剩下原始类型:

// 字符串列表
ArrayList<String> strList = new ArrayList<String>();
strList.add("abc");

// 数值型列表
ArrayList<Integer> intList = new ArrayList<Integer>();
intList.add(123);

// strList: class java.util.ArrayList
System.out.println("strList: " + strList.getClass());
// intList: class java.util.ArrayList
System.out.println("intList: " + intList.getClass());
// true
System.out.println(strList.getClass() == intList.getClass());

在第二个例子中,我们定义了一个 List<Integer> 泛型类型的数值型列表。如果直接调用 add() 方法,那么只能存储整数数据,如果我们利用反射调用 add() 方法的时候,却可以存储字符串,这说明了 Integer 实际类型参数在编译之后被擦除掉了,只保留了原始类型 List:

List<Integer> list = new ArrayList<Integer>();
// 直接调用 add 方法只能存储整形,因为泛型实际类型为 Integer
list.add(1);
// 反射方式调用 add 方法可以存储字符串
list.getClass().getMethod("add", Object.class).invoke(list, "abc");
for (int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}

5. 类型擦除带来的限制

因为种种原因,Java 不能实现真正的泛型,只能使用类型擦除来实现伪泛型,这样虽然不会有类型膨胀问题,但是也引起来许多新问题,所以,SUN 对这些问题做出了种种限制,避免我们发生各种错误。

5.1 自动类型转换

因为类型擦除的问题,所以所有的泛型类型变量最后都会被替换为原始类型。既然都被替换为原始类型,那么为什么我们在获取的时候,不需要进行强制类型转换呢?看下ArrayList.get()方法:

public E get(int index) {
rangeCheck(index);
return elementData(index);
}

@SuppressWarnings("unchecked")
E elementData(int index) {
return (E) elementData[index];
}

可以看到,在 return 之前,会根据泛型变量进行强制类型转换。假设泛型实际类型参数为 String,虽然泛型类型参数会被擦除,但是会将(E) elementData[index],编译为 (String)elementData[index]。所以我们不用自己进行强制类型转换。

这一条严格来说不是限制,只是一种说明

5.2 无法创建形式类型参数的实例

我们无法创建形式类型参数的实例。例如,如下代码会导致编译时错误:

public static <E> void append(List<E> list) {
E elem = new E(); // compile-time error
list.add(elem);
}

作为解决方法,我们可以通过反射创建类型参数的对象:

public static <E> void append(List<E> list, Class<E> cls) throws Exception {
E elem = cls.newInstance(); // OK
list.add(elem);
}

我们可以按如下方式调用 append 方法:

List<String> ls = new ArrayList<>();
append(ls, String.class);

5.3 泛型实际类型参数不能是基本数据类型

泛型实际类型参数不能是基本数据类型,我们可以通过使用基本包装类型来避开这个限制。考虑以下参数化类型:

class Pair<K, V> {
private K key;
private V value;
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
// ...
}

创建 Pair 对象时,不能用原始类型替换类型参数 K 或 V:

Pair<int, char> p = new Pair<>(8, 'a');  // compile-time error

我们可以使用基本包装类型替换类型参数 K 和 V:

Pair<Integer, Character> p = new Pair<>(8, 'a');

5.4 不能对参数化的类型使用 Casts 或 instanceof

因为 Java 编译器会擦除泛型代码中的所有类型参数,所以运行时我们无法验证泛型类型正在使用的是哪个参数化的类型:

public static <E> void rtti(List<E> list) {
if (list instanceof ArrayList<Integer>) { // compile-time error
// ...
}
}

运行时不跟踪类型参数,因此无法区分 ArrayList<Integer>ArrayList<String> 之间的区别。我们最多可以使用无界通配符来验证 List 是否为 ArrayList:

public static void rtti(List<?> list) {
if (list instanceof ArrayList<?>) { // OK; instanceof requires a reifiable type
// ...
}
}

5.5 不能声明类型为类型参数的静态字段

类的静态字段是类的所有非静态对象共享的类级变量。因此,不允许使用类型参数的静态字段。考虑以下类:

public class MobileDevice<T> {
private static T os;
// ...
}

如果允许类型参数的静态字段,那么下面的代码会被混淆:

MobileDevice<Smartphone> phone = new MobileDevice<>();
MobileDevice<Pager> pager = new MobileDevice<>();
MobileDevice<TabletPC> pc = new MobileDevice<>();

因为静态字段 os 是 phone、pager、pc 共享的,那么 os 的实际类型是什么?不能同时是 Smartphone、Pager 和 TabletPC。因此,我们不能创建类型参数的静态字段。

5.6 无法创建参数化类型的数组

我们不能创建参数化类型的数组。例如,如下代码无法编译:

List<Integer>[] arrayOfLists = new List<Integer>[2];  // compile-time error

如下代码说明了将不同类型插入数组时会发生什么:

Object[] strings = new String[2];
strings[0] = "hi"; // OK
strings[1] = 100; // An ArrayStoreException is thrown.

如果你用泛型 List 尝试同样的事情,就会出现问题:

Object[] stringLists = new List<String>[2];  // compiler error, but pretend it's allowed
stringLists[0] = new ArrayList<String>(); // OK
stringLists[1] = new ArrayList<Integer>(); // An ArrayStoreException should be thrown,
// but the runtime can't detect it.

如果允许参数化 List 的数组,则上面的代码无法抛出所需的 ArrayStoreException。

5.7 无法创建、捕获或抛出参数化类型的对象

泛型类不能直接或间接扩展 Throwable 类。例如,如下类将无法编译:

// Extends Throwable indirectly
class MathException<T> extends Exception { /* ... */ } // compile-time error

// Extends Throwable directly
class QueueFullException<T> extends Throwable { /* ... */ // compile-time error

方法无法捕获类型参数的实例:

public static <T extends Exception, J> void execute(List<J> jobs) {
try {
for (J job : jobs)
// ...
} catch (T e) { // compile-time error
// ...
}
}

但是,我们可以在 throws 子句中使用类型参数:

class Parser<T extends Exception> {
public void parse(File file) throws T { // OK
// ...
}
}

欢迎关注我的公众号和博客:

参考:

赏几毛白!