泛型程序设计

对于简单的情况,你会发现实现泛型代码很容易。不过,在更高级的情况下,对于实现者来说这会相当复杂。其目标是提供让其他程序员可以轻松使用的类和方法而不出现意外

为什么要使用泛型程序设计

泛型程序设计 generic programming 意味着编写的代码可以对多种不同类型的对象重用。

类型参数的好处

泛型可以使得代码具有更好的可读性

var files = new ArrayList<String>();

如果用一个明确的类型而不是 var 声明一个变量,则可以通过使用"菱形"语法省略构造器中的参数

ArrayList<String> files = new ArrayList<>();

定义简单泛型类

泛型类 generic class 就是有一个或多个类型变量的类,本章使用一个简单的 Pair 类作为例子。

public class Pair<T>
{
	private T first;
    private T second;

    public Pair() { first=null; second = null; }
}

类型变量使用大写字母,而且很简短。Java 库使用变量 E 表示集合的元素类型,K 和 V 分别表示表的键和值的类型

泛型类相当于普通类的工厂

泛型方法

还可以定义一个带有类型参数的方法

class ArrayAlg {
    public static <T> T getMiddle(T ... a) {
        return a[a.length / 2];
    }
}

类型变量放在修饰符 public static 的后面,并在返回类型的前面。泛型方法可以在普通类中定义,也可以在泛型类中定义

在调用泛型方法时,可以把具体类型包围在尖括号中,放在方法名前面:

String middle = ArrayAlg.<String>getMiddle("John", "Q.", "Public");

在这种情况下,方法调用可以省略 <String> 类型参数编译器有足够的信息推断出你想要的方法。也就是说,可以简单地调用

String middle = ArrayAlg.getMiddle("John", "Q.", "Public")

如果想知道编译器对一个泛型方法调用最终推断出哪种类型,可以故意引入一个错误,然后研究所产生的错误消息

类型变量的限定

有时,类或方法需要对类型变量加以约束。可以通过对类型变量设定一个限定 bound 来实现这一点:

public static <T extends Comparable> T min(T[] a)

此处 <T extends BoundingType> 表示 T 应该是限定类型 bounding type 的子类型 subtypeT 和限定类型可以是类,也可以是接口。选择关键字 extends 的原因是它更接近子类型的概念

一个类型变量或通配符可以有多个限定,例如

T extends Comparable & Serializable

在 Java 的继承中,可以根据需要拥有多个接口超类型,但最多有一个限定可以是类,如果有一个类作为限定,它必须是限定列表中的第一个限定

泛型代码和虚拟机

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

类型擦除

无论何时定义一个泛型类型,都会自动提供一个相应的原始类型 raw type 。这个原始类型的名字就是去掉类型参数后的泛型类型名。类型变量会被擦除 erased ,并替换为限定类型

比如

public class Pair<T> {
    private T first;
    private T second;
    public T getFirst() {return first;}
}

// 原始类型为
public class Pair {
    private Object first;
    private Object second;
    public Object getFirst() {return first;}
}

因为 T 是一个无限定的变量,所以直接用 Object 替换。

原始类型用第一个限定来替换类型变量,或者,如果没有给限定,就替换为 Object

为了提高效率,应该将标签 tagging 接口(即没有方法的接口)放在限定列表的末尾

转换泛型表达式

编写一个泛型方法调用时,如果擦除了返回类型,编译器会插入强制类型转换。例如,对于下面这个语句序列

pair<Employee> buddies = ...;
Employee buddy = buddies.getFirst();

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

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

转换泛型方法

类型擦除也会出现在泛型方法中。程序员通常认为类似下面的泛型方法

public static <T extend Comparable> T min(T[] a)

是整个一组方法,而擦除类型之后,只剩下一个方法:

public static Comparable min(Comparable[] a)

方法的擦除带来了两个复杂问题,看一看下面这个示例:

class DateInterval extends Pair<LocalDate> {
    public void setSecond(LocalDate second) {
        // ...
    }
}

类型擦除后变为

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

此时,还有另外一个从 pair 继承来的 setSecond 方法,即

public void setSecond(Object second)

此时,考虑下面的语句序列:

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

此时我们希望 setSecond 调用具有多态性,会调用最合适的那个方法。问题在于类型擦除与多态产生了冲突。为了解决这个问题,编译器在 DateInterval 类中生成一个桥方法 bridge method

public void setSecond(Object second) { setSecond((LocalDate) second); }

在虚拟机中,会由参数类型和返回类型共同指定一个方法,因此,编译器可以为两个仅返回类型不同的方法生成字节码,虚拟机能够正确地处理这种情况

总之,对于 Java 泛型的转换,需要记住以下几个事实:

  • 虚拟机中没有泛型,只有普通的类和方法
  • 所有的类型参数都会替换为它们的限定类型
  • 会合成桥方法来保持多态
  • 为保持类型安全性,必要时会插入强制类型转换

限制与局限性

大多数限制都是由类型擦除导致的

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

不能用基本类型代替类型参数。因此,没有 Pair<double> ,只有 Pair<Double> 。原因就在于类型擦除,擦除之后, Pair 类含有 Object 类型的字段,而 Object 不能存储 double 值。

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

虚拟机中的对象总有一个特定的非泛型类型。因此,所有的类型查询指产生原始类型。比如

if(a instanceof Pair<String>) // ERROR

下面的测试同样如此

if(a instanceof Pair<T>) // ERROR

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

Pair<String> stringPair = ...;
Pair<Employee> employeePair = ...;
if (stringPair.getClass() === employeePair.getClass()) // they are equal

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

不能实例化参数化类型的数组,例如:

var table = new Pair<String>[10]; // ERROR

如果需要收集参数化类型对象,简单使用 ArrayList:ArrayList<Pair<String>> 更安全、有效