前言

用一篇文章记录泛型的内容显得有点多,所以分成了两部分,本章接着上篇的内容继续整理,文章的内容来源还是 Java 核心技术 卷1 基础知识。

正文

类型擦除

Java 虚拟机中没有泛型的概念,编译器在将 .java 文件编译成 .class 文件的时候,会将代码中的泛型内容擦除。即擦除类型变量,替换为限定类型(无限定的变量替换为 Object)。

例如,泛型类 Pair,类型擦除后的原始类型如下所示:

  1. public class Pair {
  2. private Object first;
  3. private Object second;
  4. public Pair(Object first, Object second) {
  5. this.first = first;
  6. this.second = second;
  7. }
  8. public Object getFirst() {
  9. return first;
  10. }
  11. public Object getSecond() {
  12. return second;
  13. }
  14. public void setFirst(Object first) {
  15. first = first;
  16. }
  17. public void setSecond(Object second) {
  18. second = second;
  19. }
  20. }

因为 T 是一个无限定的变量,所以直接用 Object 替换。类型擦除后,Pair 就是一个普通的类。

如果泛型类使用了类型变量的限定,则原始类型用第一个限定的类型变量来替换。例如,声明了如下泛型类:

  1. public class Interval<T extends Comparable & Serializable> implements Serializable {
  2. private T lower;
  3. private T upper;
  4. ......
  5. public Interval(T first, T second) {
  6. if (first.compareTo(second) <= 0) { lower = first; upper = second; }
  7. else { lower = second; upper = first; }
  8. }
  9. }

Interval 类型擦除后的内容如下所示:

  1. public class Interval implements Serializable {
  2. private Comparable lower;
  3. private Comparable upper;
  4. public Interval(Comparable first, Comparable second) { ...... }
  5. }

这里有一个思考:如果我将 Interval 类中的限定调换一下,class Interval 会发生什么?

如果这样做,原始类型用 Serializable 替换 T,而编译器在必要时要向 Comparable 插入强制类型转换。所以,为了提高效率,建议将标签接口(即没有方法的接口)放在限定列表的末尾。

类型擦除对字节码的影响

先来看第一个情况,当程序调用泛型方法时,如果擦除返回类型,编译器需要插入强制类型转换。例如,看下面的调用代码:

  1. Pair<Employee> buddies = ...;
  2. Employee buddy = buddies.getFirst();

擦除 getFirst 的返回类型后将返回 Object 类型。编译器自动插人 Employee 的强制类型转换。也就是说,编译器把这个方法调用翻译为两条虚拟机指令:

  • 对原始方法 Pair.getFirst 的调用。
  • 将返回的 Object 类型强制转换为 Employee 类型。

第二种情况,通过在类中自动生成一个桥方法(bridge method),以保证擦除类型后的代码仍然具有泛型的“多态性”。例如,DateInterval 继承 Pair,重写 setSecond 和 getSecond 方法:

  1. public class DateInterval extends Pair<LocalDate> {
  2. @Override
  3. public void setSecond(LocalDate second) {
  4. if (second.compareTo(getFirst()) >= 0)
  5. super.setSecond(second);
  6. }
  7. @Override
  8. public LocalDate getSecond() {
  9. return super.getSecond();
  10. }
  11. }

DateInterval 类型擦除后:

  1. public class DateInterval extends Pair {
  2. @Override
  3. public void setSecond(LocalDate second) {
  4. ......
  5. }
  6. public LocalDate getSecond() {
  7. ......
  8. }
  9. }

Pair 类型擦除后,setSecond 方法的参数类型是 Object,getSecond 方法的返回类型是 Object,而子类 DateInterval 的参数和返回类型是 LocalDate。所以,DateInterval 类中有两个 setSecond 方法,一个是自己的,一个是从 Pair 继承的。

在某些场景中,调用 DateInterval 类中的方法的时候,有可能会调用到从父类继承的那个 setSecond 方法。

我们的本意是重写父类的方法,可是类型擦除后,变成了重载,这样类型擦除和多态就有了冲突。

为了解决这个问题,编译器在 DateInterval 类中自动生成桥方法(bridge method)。我们来看下 DateInterval.class 反编译后的内容:

  1. package test8;
  2. import java.time.LocalDate;
  3. import java.time.chrono.ChronoLocalDate;
  4. // Referenced classes of package test8:
  5. // Pair
  6. public class DateInterval extends Pair
  7. {
  8. public DateInterval()
  9. {
  10. }
  11. public void setSecond(LocalDate second)
  12. {
  13. if(second.compareTo((ChronoLocalDate)getFirst()) >= 0)
  14. super.setSecond(second);
  15. }
  16. public LocalDate getSecond()
  17. {
  18. return (LocalDate)super.getSecond();
  19. }
  20. public volatile void setSecond(Object obj)
  21. {
  22. setSecond((LocalDate)obj);
  23. }
  24. public volatile Object getSecond()
  25. {
  26. return getSecond();
  27. }
  28. }

编译器在 DateInterval 类中新增了两个桥方法:

  • public volatile void setSecond(Object obj)
  • public volatile Object getSecond()

桥方法的内部其实是调用了我们自己的 setSecond 方法,

这样在 DateInterval 类中就有两个 getSecond 方法,它们都没有参数,唯一的不同是返回类型。在 Java 中,具有相同参数类型的两个方法是不合法的,但是在虚拟机中,可以用参数类型和返回类型确定一个方法。因此,编译器可能产生两个仅返回类型不同的方法字节码,虚拟机能够正确地处理这一情况。

约束与局限性

不能用基本类型实例化类型参数

创建 Pair 对象时,不能用基本类型替换类型参数 T:

  1. Pair<int> pair = new Pair<>(6, 8);

只能用非基本类型替换类型参数 T:

  1. Pair<Integer> pair = new Pair<>(6, 8);

Java 编译器会对 6 和 8 自动装箱:

  1. Pair pair = new Pair(Integer.valueOf(6), Integer.valueOf(8));

该限制的原因是,类型擦除之后,Pair 类中的 first,second 字段是 Object 类型,而 Object 不能存储 int 值。

泛型类型不能使用 instanceof 关键字和强制类型转换

因为编译器会擦除代码中的所有类型变量,所以运行时无法验证泛型类型。例如:

  1. if (a instanceof Pair<String>) // Error
  2. if (a instanceof Pair<T>) // Error
  3. Pair<String> p = (Pair<String>) a; // Warning--can only test that a is a Pair

试图查询一个对象是否属于某个泛型类型时,倘若使用 instanceof 会得到一个编译器错误,如果使用强制类型转换会得到一个警告。

同样的道理,getClass 方法总是返回原始类型。例如:

  1. Pair<String> stringPair = new Pair<>();
  2. Pair<Employee> employeePair = new Pair<>();
  3. System.out.println(stringPair.getClass() == employeePair.getClass());

打印 true,这是因为两次调用 getClass 都将返回 Pair.class。

不能创建参数化类型的数组

例如,以下代码编译错误:

  1. Pair<String>[] table = new Pair<String>[10]; // Error

数组有一个特性,即使把数组转换为 Object[],它仍然会记住元素的类型,如果试图存储其他类型的元素,就会抛出一个 ArrayStoreException 异常:

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

不过对于泛型类型,擦除会使这种机制无效。我们假设可以创建参数化类型的数组:

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

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

需要说明的是,只是不允许创建这些数组,而声明类型为 Pair[] 的变量仍是合法的,不过不能用 new Pair[10] 初始化这个变量。

Varargs 警告

上面我们已经了解到,Java 不支持泛型类型的数组。这一节中我们再来讨论一个相关的问题:向参数个数可变的方法传递一个泛型类型的实例。

例如,下面方法的参数个数是可变的:

  1. public static <T> void addAll(Collection<T> coll, T... ts) {
  2. for (T t : ts) coll.add(t);
  3. }

实际上参数 ts 是一个数组,包含提供的所有实参。

执行如下代码,调用该方法:

  1. Collection<Pair<String>> table = new ArrayList<>();
  2. Pair<String> pair1 = new Pair<>();
  3. Pair<String> pair2 = new Pair<>();
  4. addAll(table, pair1, pair2);

为了调用这个方法,Java 虚拟机必须建立一个 Pair 数组,这就违反了前面的规则。不过,对于这种情况,规则有所放松,你只会得到一个警告,而不是错误。
image.png
可以采用两种方法来抑制这个警告。一种方法是为包含 addAll 调用的方法增加注解 @SuppressWarnings(“unchecked”)。或者在 Java SE 7 中,还可以用 @SafeVarargs 直接标注 addAll 方法:

  1. @SafeVarargs
  2. public static <T> void addAll(Collection<T> coll, T... ts)

不能实例化类型变量

不能使用像 new T(…),new T[…] 或 T.class 这样的表达式中的类型变量。

例如,下面的 Pair 构造器就是非法的:

  1. public Pair() { first = new T(); second = new T(); } // Error

类型擦除将 T 改变成 Object,我们的本意肯定不希望调用 new Object()。 在 Java SE 8 之后,最好的解决办法是让调用者提供一个构造器表达式。

  1. Pair<String> p = Pair.makePair(String::new);
  2. public static <T> Pair<T> makePair(Supplier<T> constr) {
  3. return new Pair<>(constr.get(), constr.get());
  4. }

makePair 方法接收一个 Supplier,这是一个函数式接口,表示一个无参数而且返回类型为 T 的函数。

比较传统的解决方法是通过反射调用 Class.newInstance 方法来构造泛型对象。另外,表达式 T.class 是不合法的,因为它会擦除为 Object.class,所以需要将 Class 对象作为实参传给 makePair 方法:

  1. Pair<String> p = Pair.makePair(String.class);
  2. public static <T> Pair<T> makePair(Class<T> cl) {
  3. try {
  4. return new Pair<>(cl.newInstance(), cl.newInstance());
  5. } catch (Exception ex) {
  6. return null;
  7. }
  8. }

不能构造泛型数组

就像不能实例化一个泛型实例一样,也不能实例化数组。比如下面的例子:

  1. public static <T extends Comparable> T[] minmax(T[] a) {
  2. T[] mm = new T[2]; // Error
  3. ......
  4. }

我们本意是根据传入的不同类型创建不同的数组,但是类型擦除会让这个方法永远只能构造 Comparable[2] 数组。

和上面的处理方法类似,提供一个数组构造器表达式:

  1. String[] ss = ArrayAlg.minmax(String[]::new, "Tom", "Dick", "Harry");

构造器表达式 String[]::new 指示一个函数,给定所需的长度,会构造一个指定长度的 String 数组。

  1. public static <T extends Comparable> T[] minmax(IntFunction<T[]> constr, T... a) {
  2. T[] mm = constr.apply(2);
  3. ......
  4. }

比较老式的方法是利用反射,调用 Array.newInstance:

  1. public static <T extends Comparable> T[] minmax(T... a) {
  2. T[] mm = (T[]) Array.newInstance(a.getClass().getComponentType(), 2);
  3. ......
  4. }

泛型类的静态上下文中类型变量无效

不能在静态域或方法中引用类型变量。例如,无法实现如下功能:

  1. public class Singleton<T> {
  2. private static T singleInstance; // Error
  3. public static T getSingleInstance() { // Error
  4. if (singleInstance == null) construct new instance of T
  5. return singleInstance;
  6. }
  7. }

这个限制只存在于泛型类中,假设允许在静态域或方法中引用类型变量,那么就需要考虑一个问题,我们如何为该类型变量指定一个真实类型?

泛型类是在实例化的时候指定真实类型的(Pair pair = new Pair<>();),而静态成员显然不能在实例化的时候才指定真实类型。

不能抛出或捕获泛型类的实例

既不能抛出也不能捕获泛型类对象。甚至泛型类扩展 Throwable 都是不合法的。例如,以下定义就不能正常编译:

  1. public class Problem<T> extends Exception { /* ... */ } // Error can't extend Throwable

catch 子句中不能使用类型变量。例如,以下方法将不能编译:

  1. public static <T extends Throwable> void doWork(Class<T> t) {
  2. try {
  3. // do work
  4. } catch (T e) { // Error -- can't catch type variable
  5. Logger.global.info(...)
  6. }
  7. }

不过,在异常规范中使用类型变量是允许的。以下方法是合法的:

  1. public static <T extends Throwable> void doWork(T t) throws T { // OK
  2. try {
  3. // do work
  4. } catch (Throwable realCause) {
  5. t.initCause(realCause);
  6. throw t;
  7. }
  8. }

注意擦除后的冲突

当泛型类型被擦除时,无法创建引发冲突的条件。例如,像下面这样将 equals 方法添加到 Pair 类中:

  1. public class Pair<T> {
  2. public boolean equals(T value) {
  3. return first.equals(value) && second.equals(value);
  4. }
  5. }

当定义成 Pair 的时候,理论上,它存在两个 equals 方法:

  • boolean equals(String) // defined in Pair
  • boolean equals(Object) // inherited from Object

其实,类型擦除后,boolean equals(T) 就是 boolean equals(Object),这时就与 Object.equals 方法发生冲突。补救的方法是重新命名引发错误的方法。

另外一个原则是:

参考

  • Java 核心技术 卷1 基础知识 第10版

作者:殷建卫 链接:https://www.yuque.com/yinjianwei/vyrvkf/oue6y2 来源:殷建卫 - Java 开发笔记 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。