Java的泛型

泛型是我们需要的程序设计手段。使用泛型机制编写的程序代码要比那些杂乱地使用 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<String> files = new ArrayList<String>();这使得代码具有更好的可读性。人们一看就知道这个数组列表中包含的是 String 对象。

在 Java7 及以后的版本中,构造函数中可以省略泛型类型:ArrayList<String> 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<T, U> { ... }

类定义中的类型参数指定方法的返回类型以及域和局部变量的类型。例如,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.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<T extends Comparable & Serializable> 中第一个限定的类型为 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 extends Comparable> 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 不具有继承关系,二者是并列关系。

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<? extends Employee> 表示任何泛型 Pair 类型,它的类型参数是 Employee 的子类,如 Pair,但不是 Pair

假设要编写一个打印雇员对的方法,像这样:

public static void printBuddies(Pair<Employee> p) {
    Employee first = p.getFirst();
    Employee second = p.getSecond();
    System.out.println(first.getName() + " and " + second.getName() + " are buddies.");
}

正如前面讲到的,不能将 Pair 传递给这个方法,这一点很受限制。解决的方法很简单:使用通配符类型:

public static void printBuddies(Pair<? extends Employee> p)

类型 Pair 是 Pair<? extends Employee> 的子类型(如图 8-3 所示)。


使用通配符会通过 Pair<? extends Employee> 的引用破坏 Pair 吗?

Pair<Manager> managerBuddies = new Pair<>(ceo, cfo);
Pair<? extends Employee> wildcardBuddies = managerBuddies; // OK
wildcardBuddies.setFirst(lowlyEmployee); // compile-time error

这可能不会引起破坏。对 setFirst() 的调用有一个类型错误。要了解其中的缘由,请仔细看一看类型 Pair<? extends Employee>。其方法似乎是这样的:

? extends Employee getFirst()
void setFirst(? extends Employee)

这样将不可能调用 setFirst() 方法。编译器只知道需要某个 Employee 的子类型,但不知道具体是什么类型。它拒绝传递任何特定的类型。毕竟 ? 不能用来匹配。

使用 getFirst() 就不存在这个问题:将 getFirst() 的返回值赋给一个 Employee 的引用完全合法。这就是引入有限定的通配符的关键之处。现在已经有办法区分安全的访问器方法和不安全的更改器方法了。

通配符的超类型限定

通配符限定与类型参数限定十分类似,但是,通配符限定还有一个附加的能力,即可以指定一个超类型限定(supertype bound),如下所示:? super Manager。

这个通配符限制为 Manager 的所有超类型。(已有的 super 关键字十分准确地描述了这种联系)

参考资料

《Java核心技术卷一:基础知识》(第10版)第 8 章:泛型程序设计

热门相关:总裁别再玩了   朕是红颜祸水   无限杀路   邻家名器女   朕是红颜祸水