泛型是我们需要的程序设计手段。使用泛型机制编写的程序代码要比那些杂乱地使用 Object 变量,然后再进行强制类型转换的代码具有更好的安全性和可读性。
至少在表面上看来,泛型很像 C++ 中的模板。与 Java —样,在 C++ 中,模板也是最先被添加到语言中支持强类型集合的。但是,多年之后人们发现模板还有其他的用武之地。学习完本章的内容可以发现 Java 中的泛型在程序中也有新的用途。
为什么要使用泛型程序设计
泛型程序设计(Generic programming)意味着编写的代码可以被很多不同类型的对象所重用。例如,我们并不希望为聚集 String 和 File 对象分别设计不同的类。实际上,也不需要这样做,因为一个 ArrayList 类可以聚集任何类型的对象。这是一个泛型程序设计的实例。
实际上,在 Java 增加泛型类之前已经有一个 ArrayList 类。下面来研究泛型程序设计的机制是如何演变的,另外还会讲解这对于用户和实现者来说意味着什么。
类型参数的好处
在 Java 中增加范型类之前,泛型程序设计是用继承实现的。实现时使用通用类型(如 Object 或 Comparable 接口),在使用时进行强制类型转换。
泛型对于集合类尤其有用,ArrayList 就是一个无处不在的集合类。ArrayList 类维护一个 Object 类型的数组(Object 类是所有类的父类):- // before generic classes
- public class ArrayList {
- private Object[] elementData;
- // ...
- public Object get(int i) { ... }
- public void add(Object o) { ... }
- }
复制代码 这种方法有两个问题。
- 当获取一个值时,必须进行强制类型转换。
- 此外,这里没有错误检査。可以向数组列表中添加任何类的对象。对于 files.add(new File("..."); 这个调用,编译和运行都不会出错。然而在其他地方,如果将 get() 的结果强制类型转换为 String 类型, 就会产生一个错误。
- ArrayList files = new ArrayList();
- String filename = (String) files.get(0);
复制代码 泛型提供了一个更好的解决方案:类型参数(type parameters)。ArrayList 类有一个类型参数用来指示元素的类型:ArrayList files = new ArrayList();这使得代码具有更好的可读性。人们一看就知道这个数组列表中包含的是 String 对象。
在 Java7 及以后的版本中,构造函数中可以省略泛型类型:ArrayList files = new ArrayList();省略的类型可以从变量的类型推断得出。
编译器也可以很好地利用这个信息。当调用 get() 方法的时候,不需要进行强制类型转换,编译器就知道返回值类型为 String,而不是 Object:String filename = files.get(0);
编译器还知道 ArrayList 中 add() 方法有一个类型为 String 的参数。现在, 编译器可以进行检査,避免插入错误类型的对象。例如下面的代码是无法通过编译的。这将比使用 Object 类型的参数安全一些,出现编译错误比类在运行时出现类的强制类型转换异常要好得多。- files,add(new File("...")); // can only add String objects to an ArrayList<String>
复制代码 类型参数的魅力在于:使得程序具有更好的可读性和安全性。
谁想成为泛型程序员
使用像 ArrayList 这样的泛型类很容易。大多数 Java 程序员都使用 ArrayList 这样的类型,就好像它们已经构建在语言之中,像 String[] 数组一样。(当然, 数组列表比数组要好一些,因为数组列表可以自动扩容。)
但是,实现一个泛型类并没有那么容易。对于类型参数,使用这段代码的程序员可能想要内置(plugin)所有的类。他们希望在没有过多的限制以及混乱的错误消息的状态下,做所有的事情。因此,一个泛型程序员的任务就是预测出所用类的未来可能有的所有用途。
这一任务难到什么程度呢?下面是标准类库的设计者们肯定产生争议的一个典型问题。ArrayList 类的 addAll() 方法用来添加另一个集合的全部元素。程序员可能想要将 ArrayList 中的所有元素添加到 ArrayList 中去(Manager 类是 Employee 类的子类)。然而,反过来就不行了。如何设计才能只允许前一个调用,而不允许后一个调用呢?Java 语言的设计者发明了一个具有独创性的新概念,通配符类型(wildcard type),它解决了这个问题。通配符类型非常抽象,然而,它们能让库的构建者编写出尽可能灵活的方法。
泛型程序设计划分为 3 个能力级别。基本级别是,仅仅使用泛型类:典型的是像 ArrayList 这样的集合,不必考虑它们的工作方式与原因。大多数应用程序员将会停留在这一级别上,直到出现了什么问题。当把不同的泛型类混合在一起时,或是在与对类型参数一无所知的遗留的代码进行衔接时,可能会看到含混不清的错误消息。如果这样的话,就需要系统地学习 Java 泛型来解决这些问题,而不要胡乱地猜测。当然,最终可能想要实现自己的泛型类与泛型方法。
应用程序员很可能不喜欢编写太多的泛型代码。JDK 开发人员已经做出了很大的努力,为所有的集合类提供了类型参数。凭经验来说,那些原本涉及许多来自通用类型(如 Object 或 Comparable 接口)的强制类型转换的代码一定会因使用类型参数而受益。
本章介绍实现自己的泛型代码需要了解的各种知识。希望大多数读者可以利用这些知识解决一些疑难问题,并满足对于参数化集合类的内部工作方式的好奇心。
泛型类
泛型类(generic class)就是具有一个或多个类型参数的类。
本章使用一个简单的 Pair 类作为例子。- public class Pair<T> {
- private T first;
- private T second;
-
- public Pair() { first = null ; second = null ; }
- public Pair(T first, T second) { this.first = first; this.second = second; }
-
- public T getFirst() { return first; }
- public T getSecond() { return second; }
-
- public void setFirst(T newValue) { first = newValue; }
- public void setSecond(T newValue) { second = newValue; }
- }
复制代码 Pair 类引入了一个类型参数 T,用尖括号(< >)括起来,并放在类名的后面。
泛型类可以有多个类型参数。如果有多个类型变量,多个类型变量之间用 “,”逗号分隔。例如,可以定义 Pair 类,其中第一个域和第二个域使用不同的类型:public class Pair { ... }
类定义中的类型参数指定方法的返回类型以及域和局部变量的类型。例如,private T first;
用具体的类型替换类型参数就可以实例化泛型类型,例如:Pair 可以将结果想象成带有构造器的普通类:
- Pair()
- Pair(String, String)
和方法:
- String getFirst()、String getSecond()
- void setFirst(String)、void setSecond(String)
换句话说,泛型类可看作普通类的工厂。
泛型方法
前面已经介绍了如何定义一个泛型类。实际上,还可以定义一个带有类型参数的简单方法。- class ArrayAlg {
- public static <T> T getMiddle(T... a) {
- return a[a.length / 2];
- }
- }
复制代码 这个方法是在普通类中定义的,而不是在泛型类中定义的。然而,这是一个泛型方法,可以从尖括号和类型参数看出这一点。注意,类型参数放在修饰符(这里是 public static)的后面,返回类型的前面。
泛型方法可以定义在普通类中,也可以定义在泛型类中。
当调用一个泛型方法时,在方法名前的尖括号中放入具体的类型:- String middle = ArrayAlg.<String>getMiddle("]ohn", "Q.", "Public");
复制代码 在这种情况(实际也是大多数情况)下,方法调用中可以省略 类型参数。编译器有足够的信息能够推断出所调用的方法。它用 names 的类型(即 String[])与泛型类型 T[] 进行匹配并推断出 T 一定是 String。也就是说,可以调用- String middle = ArrayAlg.<String>getMiddle("]ohn", "Q.", "Public");
复制代码 类型参数的限定
有时,类或方法需要对类型参数加以约束。下面是一个典型的例子。我们要计算数组中的最小元素:- class ArrayAIg {
- // almost correct
- public static <T> T min(T[] a) {
- if (a == null || a.length == 0) {
- return null;
- }
- T smallest = a[0];
- for (int i = 1; i < a.length; i++) {
- if (smallest.compareTo(a[i]) > 0) smallest = a[i];
- }
- return smallest;
- }
- }
复制代码 但是,这里有一个问题。请看一下 min() 方法的代码内部。变量 smallest 类型为 T,这意味着它可以是任何一个类的对象。怎么才能确信 T 所属的类有 compareTo() 方法呢?
解决这个问题的方案是将 T 限制为实现了 Comparable 接口(只含一个 compareTo() 方法的标准接口)的类。可以通过对类型参数 T 设置限定(bound)实现这一点:- public static <T extends Comparable> T min(T[] a) {}
复制代码 现在,泛型的 min() 方法只能被实现了 Comparable 接口的类(如 String、LocalDate 等)的数组调用。
T extends 绑定类型表示 T 应该是绑定类型的子类型(subtype)。T 和绑定类型可以是类,也可以是接口。
一个类型参数或通配符可以有多个限定,多个限定之间用 “ &” 分隔,例如:T extends Comparable & Serializable。在Java的限定中,可以根据需要拥有多个接口限定,但至多有一个类限定。如果用一个类作为限定,它必须放在限定列表中的第一个位置。- // ok
- T extends Object & Comparable & Serializable
- // error
- T extends Comparable & Serializable & Object
复制代码 泛型代码和虚拟机
类型擦除
类型擦除是Java泛型实现的一种方式。
类型擦除指的是:在编译时,将泛型类型擦除成其原始类型。
虚拟机没有泛型类型对象,所有对象都属于普通类。无论何时定义一个泛型类型,都自动提供了一个相应的原始类型(raw type)。类型参数用第一个限定的类型来替换,如果没有给定限定就用 Object 替换。例如:
- 类 Pair 中的类型参数没有显式的限定,因此,原始类型用 Object 替换 T。
- 类 Interval 中第一个限定的类型为 Comparable,因此,原始类型用 Comparable 替换 T。
Pair 的原始类型如下所示。结果是一个普通的类,就好像泛型引入 Java 语言之前已经实现的那样。在程序中可以包含不同类型的 Pair,例如,Pair 或 Pair。而擦除类型后就变成原始的 Pair 类型了。- public class Pair {
- private Object first;
- private Object second;
-
- public Pair(Object first, Object second) {
- this.first = first;
- this.second = second;
- }
-
- public Object getFirst() { return first; }
- public Object getSecond() { return second; }
-
- public void setFirst(Object newValue) { first = newValue; }
- public void setSecond(Object newValue) { second = newValue; }
- }
复制代码 翻译泛型表达式
当程序调用泛型方法时,如果擦除返回类型,编译器插入强制类型转换。例如,下面这个语句序列:- Pair<Employee> buddies = ...;
- Employee buddy = buddies.getFirst();
复制代码 擦除 getFirst() 方法的返回类型后,getFirst() 方法将返回 Object 类型。编译器自动插入 Employee 的强制类型转换。也就是说,编译器把这个方法调用翻译为两条虚拟机指令:
- 对 Pair#getFirst() 原始方法的调用。
- 将返回的 Object 类型强制转换为 Employee 类型。
当存取一个泛型域时也要插入强制类型转换。假设 Pair 类的 first 域和 second 域都是公有的,表达式:Employee buddy = buddies.first;也会在结果字节码中插入强制类型转换。
翻译泛型方法
类型擦除也会出现在泛型方法中。程序员通常认为下述的泛型方法 public static T min(T[] a)是一个完整的方法族,而擦除类型之后,只剩下一个方法:public static Comparable min(Comparable[] a)
注意,类型参数 T 已经被擦除了,只留下了限定类型 Comparable。
类型擦除带来了两个复杂问题。看一看下面这个示例:- class DateInterval extends Pair<LocalDate> {
- public void setSecond(LocalDate second) {
- if (second.compareTo(getFirst()) >= 0) {
- super.setSecond(second);
- }
- }
- ...
- }
复制代码 一个日期区间是一对 LocalDate 对象,并且需要覆盖这个方法来确保第二个值永远不小于第一个值。这个类擦除后变成- // after erasure
- class DateInterval extends Pair {
- public void setSecond(LocalDate second) { ... }
- ...
- }
复制代码 令人感到奇怪的是,存在另一个从 Pair 继承的 setSecond() 方法,即 public void setSecond(Object second)这显然是一个不同的方法,因为它有一个不同类型的参数 Object,而不是 LocalDate。然而,不应该不一样。考虑下面的语句序列:- DateInterval interval = new DateInterval(...);
- Pair<LocalDate> pair = interval; // OK assignment to superclass
- pair.setSecond(aDate);
复制代码 这里,希望对 setSecond() 的调用具有多态性,并调用最合适的那个方法。由于 pair 引用 Datelnterval 对象,所以应该调用 Datelnterval.setSecond()。问题在于类型擦除与多态发生了冲突。要解决这个问题,就需要编译器在 Datelnterval 类中生成一个桥方法(bridge method):- public void setSecond(Object second) { setSecond((Date) second); }
复制代码 有关泛型的事实
需要记住有关 Java 泛型转换的事实:
- 虚拟机中没有泛型,只有普通的类和方法。
- 所有的类型参数都用它们的限定类型替换。
- 桥方法被合成来保持多态。
- 为保持类型安全性,必要时插入强制类型转换。
类 A 是类 B 的子类,但是 G和 G 不具有继承关系,二者是并列关系。
<a rel="noopener"><b>- public static void printBuddies(Pair<Employee> p) { ... }
- // Manager 类是 Employee 类的子类
- Pair<Manager> pair = new Pair<>();
- // error(固定的泛型类型系统的局限,通配符类型解决了这个问题)
- printBuddies(pair);
复制代码 泛型一般有三种使用方式:泛型类、泛型方法、泛型接口。- // 泛型类
- public class Pair<T>
- // 实例化泛型类
- Pair<String> pair = new Pair<>();
- // 继承泛型类,指定类型
- class DateInterval extends Pair<LocalDate>
- // 泛型方法
- public static <T> T getMiddle(T... a)
- // 泛型接口
- public interface Generator<T>
- // 实现泛型接口,指定类型
- class GeneratorImpl implements Generator<String>
复制代码 通配符类型
固定的泛型类型系统使用起来并没有那么令人愉快,类型系统的研究人员知道这一点已经有一段时间了。Java 的设计者发明了一种巧妙的(仍然是安全的)“解决方案”:通配符类型。下面几小节会介绍如何处理通配符。
通配符概念
通配符类型中,允许类型参数变化。例如,通配符类型 Pair |