4.2 Java泛型
Java 平台的一大优势是它提供的标准库。标准库提供了大量有用的功能,特别是实现了健壮的通用数据结构。这些实现使用起来相当简单,而且文档编写良好。这些是 Java 集合库,第 8 章会使用大量篇幅介绍。更完整的介绍参阅 Maurice Naftalin 和 Philip Wadler 合著的 Java Generics and Collections(http://shop.oreilly.com/product/9780596527754.do, O'Reilly 出版)。
虽然这些库一直很有用,但在早期版本中有相当大的不足——数据结构(经常叫作容器)完全隐藏了存储其中的数据类型。
数据隐藏和封装是面向对象编程的重要原则,但在这种情况下,容器的不透明会为开发者带来很多问题。
本节先说明这个问题,然后介绍泛型是如何解决这个问题并让 Java 开发者的生活更轻松的。
4.2.1 介绍泛型
如果想构建一个由 Shape 实例组成的集合,可以把这个集合保存在一个 List 对象中,如下所示:
List shapes = new ArrayList(); // 创建一个List对象,保存形状// 指定中心点,创建一些形状,保存在这个列表中shapes.add(new CenteredCircle(1.0, 1.0, 1.0));// 这是合法的Java代码,但不是好的设计方式shapes.add(new CenteredSquare(2.5, 2, 3));// List::get()返回Object对象,所以要想得到CenteredCircle对象,必须校正CenteredCircle c = (CentredCircle)shapes.get(0);// 下面这行代码会导致运行时失败CenteredCircle c = (CentredCircle)shapes.get(1);
上述代码有个问题,为了取回有用的形状对象形式,必须校正,因为 List 不知道其中的对象是什么类型。不仅如此,其实可以把不同类型的对象放在同一个容器中,一切都能正常运行,但是如果做了不合法的校正,程序就会崩溃。
我们真正需要的是一种知道所含元素类型的 List。这样,如果把不合法的参数传给 List 的方法,javac 就能检测到,导致编译出错,而不用等到运行时才发现问题。
为了解决这个问题,Java 提供了一种句法,指明某种类型是一个容器,这个容器中保存着其他引用类型的实例。容器中保存的负载类型(payload type)在尖括号中指定:
// 创建一个由CenteredCircle对象组成的ListList<CenteredCircle> shapes = new ArrayList<CenteredCircle>();// 指定中心点,创建一些形状,保存在这个列表中shapes.add(new CenteredCircle(1.0, 1.0, 1.0));// 下面这行代码会导致编译出错shapes.add(new CenteredSquare(2.5, 2, 3));// List<CenteredCircle>::get()返回一个CenteredCircle对象,无需校正CenteredCircle c = shapes.get(0);
这种句法能让编译器捕获大量不安全的代码,根本不能靠近运行时。当然,这正是静态类型系统的关键所在——使用编译时信息协助排除大量运行时问题。
容器类型一般叫作泛型(generic type),使用下述方式声明:
interface Box<T> {void box(T t);T unbox();}
上述代码表明,Box 接口是通用结构,可以保存任意类型的负载。这不是一个完整的接口,更像是一系列接口的通用描述,每个接口对应的类型都能用在 T 的位置上。
4.2.2 泛型和类型参数
我们已经知道如何使用泛型增强程序的安全性——使用编译时信息避免简单的类型错误。本节深入介绍泛型的特性。
句法有个专门的名称——类型参数(type parameter)。因此,泛型还有一个名称——参数化类型(parameterized type)。这表明,容器类型(例如 List)由其他类型(负载类型)参数化。把类型写为 Map 时,我们就为类型参数指定了具体的值。
定义有参数的类型时,要使用一种不对类型参数做任何假设的方式指定具体的值。所以 List 类型使用通用的方式 List 声明,而且自始至终都使用类型参数 E 作占位符,代表程序员使用 List 数据结构时负载的真实类型。
类型参数始终代表引用类型。类型参数的值不能使用基本类型。
类型参数可以在方法的签名和主体中使用,就像是真正的类型一样,例如:
interface List<E> extends Collection<E> {boolean add(E e);E get(int index);// 其他方法省略了}
注意,类型参数 E 既可以作为返回类型的参数,也可以作为方法参数类型的参数。我们不假设负载类型有任何具体的特性,只对一致性做了基本假设,即存入的类型和后来取回的类型一致。
4.2.3 菱形句法
创建泛型的实例时,赋值语句的右侧会重复类型参数的值。一般情况下,这个信息是不必要的,因为编译器能推导出类型参数的值。在 Java 的现代版本中,可以使用菱形句法省略重复的类型值。
下面通过一个示例说明如何使用菱形句法,这个例子改自之前的示例:
// 使用菱形句法创建一个由CenteredCircle对象组成的ListList<CenteredCircle> shapes = new ArrayList<>();
对这种冗长的赋值语句来说,这是个小改进,能少输入几个字符。本章末尾介绍 lambda 表达式时会再次讨论类型推导。
4.2.4 类型擦除
4.1.5 节说过,Java 平台十分看重向后兼容性。Java 5 添加的泛型又是一个会导致向后兼容性问题的新语言特性。
问题的关键是,如何让类型系统既能使用旧的非泛型集合类又能使用新的泛型集合类。设计者选择的解决方式是使用校正:
List someThings = getSomeThings();// 这种校正不安全,但我们知道someThings的内容确实是字符串List<String> myStrings = (List<String>)someThings;
上述代码表明,作为类型,List 和 List 是兼容的,至少在某种程度上是兼容的。Java 通过类型擦除实现这种兼容性。这表明,泛型的类型参数只在编译时可见——javac 会去掉类型参数,而且在字节码中不体现出来。1
1会保留泛型的一些细微踪迹,在运行时通过反射能看到。
非泛型的
List一般叫作原始类型(raw type)。就算现在有泛型了,Java 也完全能处理类型的原始形式。不过,这么做几乎就表明代码的质量不高。
类型擦除机制扩大了 javac 和 JVM 使用的类型系统之间的区别,4.6 节会详细说明。
类型擦除还能禁止使用某些其他定义方式,如果没有这个机制,代码看起来是合法的。在下述代码中,我们想使用两个稍微不同的数据结构计算订单数量:
// 不会编译interface OrderCounter {// 把名称映射到由订单号组成的列表上int totalOrders(Map<String, List<String>> orders);// 把名称映射到目前已下订单的总数上int totalOrders(Map<String, Integer> orders);}
看起来这是完全合法的 Java 代码,但其实无法编译。问题是,这两个方法虽然看起来像是常规的重载,但擦除类型后,两个方法的签名都变成了:
int totalOrders(Map);
擦除类型后剩下的只有容器的原始类型,在这个例子中是 Map。运行时无法通过签名区分这两个方法,所以,Java 语言规范把这种句法列为不合法的句法。
4.2.5 通配符
参数化类型,例如 ArrayList,不能实例化,即不能创建这种类型的实例。这是因为 是类型参数,只是真实类型的占位符。只有为类型参数提供具体的值之后(例如 ArrayList),这个类型才算完整,才能创建这种类型的对象。
如果编译时不知道我们要使用什么类型,就会出现问题。幸好,Java 类型系统能调解这种问题。在 Java 中,有“未知类型”这个明确的概念,使用 表示。这是一种最简单的 Java 通配符类型(wildcard type)。
涉及未知类型的表达式可以这么写:
ArrayList<?> mysteryList = unknownList();Object o = mysteryList.get(0);
这是完全有效的 Java 代码——ArrayList 和 ArrayList 不一样,前者是变量可以使用的完整类型。我们对 mysteryList 的负载类型一无所知,但这对我们的代码来说不是问题。在用户的代码中使用未知类型时,有些限制。例如,下面的代码不会编译:
// 不会编译mysteryList.add(new Object());
原因很简单,我们不知道 mysteryList 的负载类型。例如,如果 mysteryList 是 ArrayList 类型的实例,那么就不能把 Object 对象存入其中。
始终可以存入容器的唯一一个值是 null,因为我们知道 null 可能是任何引用类型的值。但这没什么用,因此,Java 语言规范禁止实例化负载为未知类型的容器类型,例如:
// 不会编译List<?> unknowns = new ArrayList<?>();
使用未知类型时有必要问这么一个问题:“List 是 List 的子类型吗?”即,能否编写如下的代码:
// 这么写合法吗?List<Object> objects = new ArrayList<String>();
乍看起来,这么写完全可行,因为 String 是 Object 的子类,所以我们知道集合中的任何一个 String 类型元素都是有效的 Object 对象。不过,看看下述代码:
// 这么写合法吗?List<Object> objects = new ArrayList<String>();// 如果合法,那下面这行代码呢?objects.add(new Object());
既然 objects 的类型声明为 List,那么就能把 Object 实例存入其中。然而,这个实例保存的是字符串,尝试存入的 Object 对象与其不兼容,因此这个操作在运行时会失败。
上述问题的答案是,虽然下述代码是合法的(因为 String 类继承 Object 类):
Object o = new String("X");
但并不意味着泛型容器类型对应的语句也合法:
// 不会编译List<Object> objects = new ArrayList<String>();
换种方式说,即 List 不是 List 的子类型。如果想让容器的类型具有父子关系,需要使用未知类型:
// 完全合法List<?> objects = new ArrayList<String>();
这表明,List 是 List 的子类型。不过,使用上述这种赋值语句时,会丢失一些类型信息。例如,get() 方法的返回类型现在实际上是 Object。还要注意,不管 T 的值是什么,List 都不是 List 的子类型。
未知类型有时会让开发者困惑,问些引人深思的问题,例如:“为什么不使用 Object 代替未知类型?”不过,如前文所述,为了实现泛型之间的父子关系,必须有一种表示未知类型的方式。
1. 受限通配符
其实,Java 的通配符类型不止有未知类型一种,还有受限通配符(bounded wildcard)这个概念。受限通配符也叫类型参数约束条件,作用是限制类型参数的值能使用哪些类型。
受限通配符描述几乎不知道是什么类型的未知类型的层次结构,其实想表达的是这种意思:“我不知道到底是什么类型,但我知道这种类型实现了 List 接口。”在类型参数中,这句话表达的意思可以写成 ? extends List。这为程序员提供了一线希望,至少知道可以使用的类型要满足什么条件,而不是对类型一无所知。
不管限定使用的类型是类还是接口,都要使用
extends关键字。
这是类型变体(type variance)的一个示例。类型变体是容器类型之间的继承关系和负载类型的继承关系有所关联的理论基础。
- 类型协变
这表示容器类型之间和负载类型之间具有相同的关系。这种关系通过 extends 关键字表示。
- 类型逆变
这表示容器类型之间和负载类型之间具有相反的关系。这种关系通过 super 关键字表示。
容器类型作为类型的制造者或使用者时会体现这些原则。例如,如果 Cat 类扩展 Pet 类,那么 List 是 List 的子类型。这里,List 是 Cat 对象的制造者,应该使用关键字 extends。
如果容器类型只是某种类型实例的使用者,就应该使用 super 关键字。
Joshua Bloch 把这种用法总结成“Producer Extends, Consumer Super”原则(简称 PECS,“制造者使用
extends,使用者使用super”)。
第 8 章会看到,Java 集合库大量使用了协变和逆变。大量使用这两种变体的目的是确保泛型“做正确的事”,以及表现出的行为不会让开发者诧异。
2. 数组协变
在早期的 Java 版本中,集合库还没有出现,容器类型的类型变体问题在 Java 的数组中也有体现。没有类型变体,即使 sort() 这样简单的方法也很难使用有效的方式编写:
Arrays.sort(Object[] a);
基于这个原因,Java 的数组可以协变——尽管这么做让静态类型系统暴露出了缺陷,但在 Java 平台的早期阶段仍是必要之恶:
// 这样写完全合法String[] words = {"Hello World!"};Object[] objects = words;// 哦,天哪,运行时错误objects[0] = new Integer(42);
最近对现代开源项目的研究表明,数组协变极少使用,几乎可以断定为编程语言的设计缺陷。2 因此,编写新代码时,应该避免使用数组协变。
2Raoul-Gabriel Urma and Janina Voigt,“Using the OpenJDK to Investigate Covariance in Java”, Java Magazine (May/June 2012):44–47.
3. 泛型方法
泛型方法是参数可以使用任何引用类型实例的方法。
例如,下述方法模拟 C 语言中 ,(逗号)运算符的功能。这个运算符一般用来合并有副作用的表达式。
// 注意,这个类不是泛型类public class Utilspublic static <T> T comma(T a, T b) {return a;}}
虽然这个方法的定义中使用了类型参数,但所在的类不需要定义为泛型类。使用这种句法是为了表明这个方法可以自由使用,而且返回类型和参数的类型一样。
4. 使用和设计泛型
使用 Java 的泛型时,有时要从两方面思考问题。
- 使用者
使用者要使用现有的泛型库,还要编写一些相对简单的泛型类。对使用者来说,要理解 类型擦除的基本知识,因为如果不知道运行时对泛型的处理方式,会对几个 Java 句法感到困惑。
- 设计者
使用泛型开发新库时,设计者需要理解泛型的更多功能。规范中有一些难以理解的部分,例如要完全理解通配符和“capture-of”错误消息 3 等高级话题。
3指通配符类型导致的错误消息,例如 set(int,capture of ?) in java.util.List。——译者注
泛型是 Java 语言规范中最难理解的部分之一,潜藏很多极端情况,并不需要每个开发者都完全理解,至少初次接触 Java 的类型系统时没必要。
4.2.6 编译时和运行时类型
假设有如下的代码片段:
List<String> l = new ArrayList<>();System.out.println(l);
我们可以问这个问题:l 是什么类型?答案取决于在编译时(即 javac 看到的类型)还是运行时(JVM 看到的类型)问这个问题。
javac 把 l 看成 List-of-String 类型,而且会用这个类型信息仔细检查句法错误,例如不能使用 add() 方法添加不合法的类型。
而 JVM 把 l 看成 ArrayList 类型的对象,这一点可以从 println() 语句的输出中证实。因为要擦除类型,所以运行时 l 是原始类型。
因此,编译时和运行时的类型稍微有些不同。某种程度上,这个不同点是,运行时类型既比编译时类型精确,又没有编译时类型精确。
运行时类型没有编译时类型精确,因为没有负载类型的信息——这个信息被擦除了,得到的运行时类型只是原始类型。
编译时类型没有运行时类型精确,因为我们不知道 l 的具体类型到底是什么,只知道是一种和 List 兼容的类型。
数据隐藏和封装是面向对象编程的重要原则,但在这种情况下,容器的不透明会为开发者带来很多问题。