Java泛型的历史背景与限制局限性

发布时间 2023-11-22 17:52:38作者: EisenJi

Java泛型的语法

简要提一下一些众所周知的泛型语法和类型擦除特性。

泛型类

  • 泛型类中,类型变量用尖括号括起来,放在类名的后面,可以有多个类型变量。public class Pair<T, U> {...}

  • 类型变量在整个类定义中用于指定方法的返回类型以及字段和局部变量的类型。

  • 可以用具体的类型替换类型变量来实例化泛型类型,即Pair<String>这样子。

  • 类型变量的限定,可以像public static <T extends Comparable> T min(T[] a)这样来限制T只能是实现了Comparable接口的类。

泛型方法

public static <T> T getMiddle(T... a) {
  return a[a.length / 2];
}
  • 类型变量<T>放在修饰符public static后面,并且放在返回类型T的前面。

  • 调用泛型方法时,可以把具体类型包围在尖括号中,放在方法名前面:xxxClass.<String>getMiddle("Eisen", "Ji", "Haha");

  • 在大多数情况下,编译器有足够的信息推断出你想要的方法,可以直接调用:xxxClass.getMiddle("Eisen", "Ji", "Haha");

double middle = xxxClass.getMiddle(3.14, 159,26535);这样是会报错的,显示Required type: double, Provided: Number & Comparable<? extends Number & Comparable<?>>,这是因为编译器把参数自动装箱为1个Double和2个Integer对象,然后寻找这些类共同的超类型,找到Number和Comparable接口。

类型擦除

虚拟机没有泛型类型对象,所有对象都属于普通类。

类型变量会被“擦除”,被替换为它的限定类型,如果没有限定就替换为Object,擦除了以后,泛型类就是一个普通类了。

例如:

  • public class Pair<T> {private T first;} -> public class Pair {private Object first;}

  • 如果是<T extends Comparable & Serializable>,所有的T就会变成Comparable;

  • 而如果是<T extends Serializable & Comparable>,所有的T就会变成Serializable。如果这样,当T类型的变量使用了compareTo方法时,编译器有时候就会需要强制类型转换了。所以为了提高效率应该将没有方法的接口(标签tagging接口)放在限定列表的末尾。

  • Pair<String>或者Pair<LocalDate>等也会被转化为原始的Pair类型。如果对Pair<String>这样的类型调用某个方法,编译器是两条虚拟机指令:对原始方法调用,将返回的Object类型强制转换为String类型。

桥方法

有这样的问题,例如,下段代码的大意是一个日期区间是一对LocalDate对象,需要保证第二个LocalDate永远要不小于第一个:

class DateInterval extends Pair<LocalDate> {
  public void setSecond(LocalDate second) {
    if (second.compareTo(getFirst()) >= 0) {
      super.setSecond(second);
    }
  }
}

类型擦除后:

class DateInterval extends Pair {
  public void setSecond(LocalDate second) {...}
}
class Pair {
  public void setSecond(Object second) {...}
}

这两个setSecond方法就不同了,它们有不同类型的参数,一个是Object,另一个是LocalDate。如果有下面这样的语句序列:

DateInterval interval = new DateInterval(...);
Pair<LocalDate> pair = interval;
pair.setSecond(aDate);

显然这样类型擦除与多态就会发生冲突。编译器解决这个问题是在DateInterval类中生成一个桥方法:public void setSecond(Object second) {setSecond((LocalDate) second);},在里面加上了强制类型转换就能调用正确的方法了。

另外,如果DateInterval类也覆盖了getSecond方法,在DateInterval类中就有返回类型为LocalDate和Object的两个getSecond方法了,这样显然不合理,但是在虚拟机中,会由参数类型和返回类型共同指定一个方法,编译器可以为两个返回类型不同的方法生成字节码,虚拟机能够正确处理这种情况。

Java泛型的历史背景

Martin Odersky

实际上,泛型并不是只有这种实现方式的,Java这样实现泛型也是妥协的结果。

泛型思想早在C++语言的Template功能中就有了。

在Java中加入泛型的首次尝试是1996年。Martin Odersky(后来Scala语言的缔造者)当时是德国卡尔斯鲁厄大学的一名教授,与好友Phil Wadler一起进行编程理论方面的研究。1995年的某一天,Martin从Phil的朋友口中得知在大西洋彼岸的加州,有一门叫Java的新语言正在Alpha版本阶段。在了解了Java的字节码技术、跨平台运行特点、垃圾回收机制等优势之后,Martin与Phil马上意识到这个Java很好,于是他们提取了函数式编程的一些特点,如:泛型、高阶函数以及模式匹配,将它们与Java结合。经过努力,他们设计出了一门新的编程语言,命名为Pizza,并于1996年公开发布。事实表明,Pizza是非常成功的,它使得JVM平台与函数式编程语言结合变成现实。

Martin的研究引起了Sun公司Java开发团队人员的关注,他们与Martin联系并一道合作编写了泛型Java(Generic Java,简称GJ),把Pizza语言的泛型单独拎出来移植到Java语言上。而期间Martin还独立为GJ编写了编译器,Sun公司在对比Java原有的编译器与GJ编译器性能后,决定将GJ编译器作为标准的Javac编译器。

Java与C#

2004年,SUN公司发布Java 5,代号为Tiger。同年C#也更新了一个重要的大版本C#2.0。两门语言都不约而同地添加了泛型的语法特性,不过两门语言对泛型的实现方式截然不同。Java选择的是“类型擦除式泛型”(Type Erasure Generics),C#选择的是“具现化式泛型”(Reified Generics)。

前者的泛型只在程序源码中存在,在编译后的字节码文件中全部泛型被替换为原始类型(raw type,又称裸类型、原生态类型,有些地方把primitive type翻译成原始类型,我这里叫做基本类型)。在后者中,List<int>List<String>是两个不同的类型,他们由系统在运行期生成,有着自己独立的虚方法表和类型数据。

C#2.0引入了泛型之后,带来的显著优势之一是对比起Java在执行性能上的提高,在使用List<T>这样的容器类型时,无须像Java那样拆箱和装箱,如果在Java中要避免这种损失,就需要构造一个与数据类型相关的容器类(比如,IntFloatHashMap这样子)。

Java的类型擦除式泛型唯一的优势是在于实现时只需要在Javac编译器上做出改进即可,不需要改动字节码,也不需要改动虚拟机,保证了以前没有使用泛型的库可以直接运行在Java5.0上。

Java泛型怎么设计的

原地泛型化

移植过程中并不是一开始就朝着类型擦除式泛型去的。Martin在实现Java泛型时认为最难的约束在于要保证“二进制向后兼容性”,即完全向后兼容无泛型的Java,这是《Java语言规范》的承诺。

没有泛型的时代,Java中的数组是支持协变的(现在也支持),下面这样编译期没问题,运行时会报错:

Object[] array = new String[10];
array[0] = 10;

集合类也可以存入不同类型的元素,下面这样在编译和运行时都不会报错,也因此不推荐使用原始类型:

ArrayList list = new ArrayList();
list.add("String");
list.add(1);

为了保证兼容这些编译出来的Class文件,大体有两个办法:

  1. 需要泛型化的类型,以前有的保持不变,另外加一套泛型化版本的新类型。C#选了这一种,添加了一组System.Collections.Generic新容器,以前的System.Collections和System.Collections.Specialized容器类型继续存在。C#开发人员唯一的不适时许多.NET的标准库把老容器类型当作方法的返回值或者参数使用。
  2. 直接把已有的类型泛型化。Java选择这一种,理由是.NET才问世两年,Java已经有快10年的历史了。但是选择这一种也不一定只能用类型擦除来实现。

原始类型/类型擦除

Java要让ArrayList原地泛型化后变为ArrayList<T>,而且保证以前直接用ArrayList的代码在新版本中继续用同一个容器,这就需要让ArrayList<Integer>或者ArrayList<String这种全部都自动成为ArrayList的子类型。由此就引出了“原始类型”(raw type)的概念。原始类型被视为所有该类型泛型化实例的共同父类型。

如何实现原始类型,两种选择:

  1. 运行期有Java虚拟机构造出ArrayList<Integer>这样的类型,并且自动实现从ArrayList<Integer>派生自ArrayList的继承关系来满足原始类型的定义。
  2. 在编译时把ArrayList<Integer>还原回ArrayList,只在元素访问、修改时自动插入一些强制类型转换和检查指令。

Java选择了第二种。于是大致有这些限制与局限性,具体总结出来的在下面这一部分

  • 类型擦除后,到要强制转换类型代码的地方就没办法了,int、long和Object之间没办法强制转换,于是java索性简单粗暴地不支持原生类型的泛型了,int和long就使用ArrayList<Integer>ArrayList<Long>,并且遇到原生类型时就装箱拆箱。这个决定后面导致了无数构造包装类的装箱拆箱的开销,成为Java泛型慢的重要原因,也是Valhalla项目要重点解决的问题。

  • 运行期无法取到泛型类型信息,会让一些代码变得相当啰嗦,写一个泛型版本的列表转化为数组的方法时,不能从List中得到参数化类型T,所以不得不再传入一个数组的组件类型进去:

    public static <T> T[] convert(List<T> list, Class<T> componentType) {
      T[] array = (T[])Array.newInstance(componentType, list.size());
    }
    
  • 遇到重载时,两个特征签名完全一样的方法,返回值不同,也能重载成功。返回值时不参与重载选择的,之所以能编译和执行成功,是因为在Class文件格式之中,只要描述符不是完全一致的两个方法就可以共存。这个在JDK 6的javac才能编译成功,其他版本或者ECJ编译器可能拒绝编译。

2014年,Oracle建立了一个名为Valhalla的语言改进项目,希望改进Java语言留下的各种缺陷。项目首页:https://openjdk.org/projects/valhalla/

由于类型擦除带来的限制与局限性

类型擦除会带来很多符合直觉但是微妙的局限性。

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

即没有Pair<double>,只有Pair<Double>。因为类型擦除之后Object不能存储double值。

2. 运行时类型查询只适用于原始类型

  • a instanceof Pair<String>,这样实际上仅仅是在测试a是否为任意类型的一个Pair,编译器为了提醒这一风险会报错“Illegal generic type for instanceof”。
  • 同样的道理,getClass也返回的是原始类型,即Pair<String> stringPairPair<Employee> employeePair,二者.getClass()相等。

3. 不能创建参数化类型的数组。

Pair<String>[] a = new Pair<String>[10]这样会报错“Generic array creation”。这是由于数组会记住它的元素类型,如果试图存储其他类型的元素,就会抛出ArrayStoreExceptioin异常。

可以Pair<String>[] a = (Pair<String>[]) new Pair<?>[10];这样使用强制类型转换来创建数组,但是这样类型不安全。

如果需要收集参数化类型对象,应该使用列表ArrayList:ArrayList<Pair>.

4. Varargs警告

考虑向参数个数可变的方法传递一个泛型类型的实例。

为了调用这样的方法,虚拟机必须建立一个参数化类型的数组,不过此时只会得到一个警告。可以用@SuppressWarnings("unchecked")来抑制这个警告,或者使用@SafeVarargs

于是就可以创建出参数化类型的数组了:

@SuppressWarnings("unchecked")
public static <E> E[] array(E... array) {return array;}

Pair<String> pair1 = new Pair<>("Hello", "Hi");
Pair<String> pair2 = new Pair<>("Fine", "Thanks");
Pair<String>[] a = array(pair1, pair2);

但是这样有风险,下面代码就会有风险,下面代码能顺利运行,但是在处理table[0]的时候会在别处得到一个异常:

Pair<String>[] table = array(pair1, pair2);
Object[] objarray = table;
objarray[0] = new Pair<Employee>();

5. 不能实例化类型变量。

new T()这样是非法的。

考虑要实现一个Pair的构造器来初始化first和second都为一个空的T:

public Pair() {first = new T(); second = new T();}

这样显然不对,相当于在new Object()。解决方法有两种:

一是Pair<String> p = Pair.makePair(String::new);,让调用者提供一个构造器表达式,使用一个makePair方法来接受一个Supplier。

public static <T> Pair<T> makePair(Supplier<T> constr) {
  return new Pair<>(constr.get(), constr.get());
}

二是Pair<String> p = Pair.makePair(String.class);通过反射掉用Constructor.newInstance方法来构造泛型对象。

public static <T> Pair<T> makePair(Class<T> cl) {
  try {
    return new Pair<>(cl.getConstructor().newInstance(),
                      cl.getConstructor().newInstance());
  } catch (Exception e) {
    return null;
  }
}

6. 不能构造泛型数组

new T[2]这样是非法的。

如果数组仅仅作为一个类的私有字段,可以将这个数组的元素类型声明为擦除的类型并使用强制类型转换。ArrayList类中就是这样:

ArrayList类中有一个elementData字段还有一个elementData方法,Java中字段名和方法名是可以重名的,Java可以通过上下文和类型信息来确定一个名字的含义。elementData类是一个Object类型的数组,用来存放ArrayList中的元素,在elementData方法中进行强制类型转换。

transient Object[] elementData;

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

public E set(int index, E element) {
  rangeCheck(index);
  E oldValue = elementData(index);
  elementData[index] = element;
  return oldValue;
}

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

如果方法的返回值要是一个泛型数组,参考Arrays.copyOf,使用反射,调用Array.newInstance,这是比较老式的方法:

public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
    @SuppressWarnings("unchecked")
    T[] copy = ((Object)newType == (Object)Object[].class)
      ? (T[]) new Object[newLength]
      : (T[]) Array.newInstance(newType.getComponentType(), newLength);
    System.arraycopy(original, 0, copy, 0,
                     Math.min(original.length, newLength));
    return copy;
}

还有一个方法是使用IntFunction接口。比如下面方法是从可变参数数组中找到最小值和最大值,并返回包含这两个值的新数组:

public static <T extends Comparable> T[] minmax(IntFunction<T[]> constr, T...a) {
  	T[] result = constr.apply(2);
  	...// 省略中间比较大小找到最大最小值的代码
    return result;
}

// 在主函数中调用这个方法,比如给Integer的数组找到最大最小
Integer[] nums = {3, 5, 1, 7, 9, 2, 4, 6, 8};
Integer[] result = Solution.minmax(Integer[]::new, nums);

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

即不能在静态字段或在方法中引用泛型类的类型变量。

这句话的意思是,当你定义一个泛型类时,你不能在这个类的静态字段或静态方法中使用这个类的类型参数,因为这些类型参数是属于对象的,而不是属于类的,它们只有在创建对象时才能被确定,而静态字段或静态方法是不需要创建对象就可以直接访问的,所以它们不能引用类型参数。比如,不能这样写:

public class Pair<T> {
    private T first;
    private T second;
    public static T getFirst() { // 错误,不能在静态方法中引用类型参数T
        return first;
    }
    public static T second = null; // 错误,不能在静态字段中引用类型参数T
}

但是,可以在静态方法中使用泛型方法的类型参数,因为这些类型参数是属于方法的,而不是属于类的,它们只有在调用方法时才能被确定,而且它们和泛型类的类型参数是没有关系的,所以它们可以被静态方法引用。比如,可以这样写:

public class Pair<T> {
    private T first;
    private T second;
    public static <U> U getMiddle(U[] a) { // 正确,可以在静态方法中使用泛型方法的类型参数U
        return a[a.length / 2];
    }
}

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

不能抛出也不能捕获泛型类的对象,泛型类扩展Throwable都是不合法的(public class Problem<T> extends Exception 这样不合法)。

catch子句中不能使用类型变量,像catch(T e)这样子抛出一个泛型类型的异常是不合法的。这是因为java的异常处理机制是基于类型的,当一个异常被抛出时,编译器会根据它的类型来匹配合适的catch语句,如果没有找到匹配的catch语句,就会抛出一个未捕获的异常,由于类型擦除的存在,泛型类型在运行时会被擦除成它们的限定类型或者Object类型,这样就会导致编译器无法确定泛型类型的真实类型,也就无法匹配合适的catch语句,从而造成类型不安全的情况。

throw t这样子抛出一个泛型类型的异常时合法的,这是因为java的异常抛出机制是基于对象的。

9. 可以取消对检查型异常的检查

这个似乎非常实用。Java异常处理的一个基本原则是必须为所有检查型异常提供一个处理器。不过可以用泛型来取消这个机制。

@SuppressWarnings("unchecked")
static <T extends Throwable> void throwAs(Throwable t) throws T {
  throw (T) t;
}

假设这个方法在Task接口中,如果有一个检查型异常e,调用Task.<RuntimeException>throwAs(e),编译器就会认为e是一个非检查型异常。

利用这个技术可以解决这样的问题:要在一个线程中运行代码,需要把代码放在一个实现了Runnable接口的类的run方法中,不过这个方法不允许抛出检查型异常。

正常情况下,必须捕获一个Runnable的run方法中的所有检查型异常,把它们“包装”到非检查型异常中,因为run方法声明为不抛出任何检查型异常。

不过在这里并没有做这种“包装”,只是哄骗编译器,让它相信这不是一个检查型异常:

interface Task{
	void run() throws Exception;
  
	@SuppressWarnings ("unchecked")
	static <T extends Throwable> void throwAs (Throwable t) throws T {
		throw (T) t;
  }
  static Runnable asRunnable(Task task) {
		return () -> {
    	try {
        task.run();
      } catch (Exception e) {
        Task.<RuntimeException>throwAs(e);
      }
  	};
  }
}

// 下面运行一个线程,并抛出一个检查型异常
public class Test {
  public static void main(String[] args) {
    Thread thread = new Thread(Task.asRunnable(() -> {
      Thread.sleep(1000);
      System.out.println("hhhhh");
      throw new Exception("check this out hhhhh.");
    }));
    thread.start();
  }
}

10. 擦除后方法的冲突

比如,Pair类有这样的一个方法boolean equals(T value){..some implement...},这个方法擦除后就是boolean equals(Object)了,就与Object.equals方法发生了冲突。补救的方法是重命名。

泛型规范说明还有一个原则:“为了支持擦除转换,我们要施加一个限制:倘若两个接口类型是同一接口的不同参数化,一个类或类型变量就不能同时作为这两个接口类型的子类。” ,比如:class Employee implements Comparable<Employee>{...}就与class Manager extends Employee implements Comparable<Manager>{...}冲突了。

原因在于合成的桥方法产生了冲突,实现了Comparable<X>的类会获得一个桥方法:public int compareTo(Object other) {return compareTo((X) other);},对不同类型的X有两个这样的方法就冲突了。

总结

  • 不看历史演化就学习一个语言的特性就会很迷惑。不看一些术语的英语只看中文翻译也会很迷惑。
  • 编程理论很酷,学了可以迅速把握语言的要点,另外,研究编程理论的科学家真的很疯狂。
  • 最近面试老是被问到,就重新梳理一下。面试总是能有所收获,学习也要尽量严谨。
  • 书中对于语言特性带来的问题还有很多黑科技一般的编程小技巧,我这里没有记录,要学什么才能自然而然那样做呢,这超出了我的能力范围。

也发在我的博客:https://eisen.work