Java 从版本5起开始引入泛型(generics)机制。我们知道,Java 的泛型类型如同java.lang.String,java.io.File 一样,属于普通的 Java 类型。比方说,下面两个变量的类型就是互不相同的:
List
虽然 String 是 Object 的子类,但是 List
在下面的代码中,我们会看到将具有List
import java.util.ArrayList;
import java.util.List;
public class GenericsTypeTest {
public static void testMtd(List
}
public static void main(String[] args) {
List
// above line will cause a compile error
testMtd(testList);
}
}
如果我们希望 testMtd 能够接受任意泛型类型的参数,那么我们应该使用 ? 通配符来满足这个要求。List>任意类型的对象的数组这么一个泛型的类型。我们可以把以上代码改成:
public static void testMtd(List> l) {
}
但是这种情况下,testMtd 的参数可以接受类型可能对于程序员设计的意图而言太广泛了一点。
因为我们可能只是希望 testMtd 可以接受 AbstractList 及其子类的类型的变量,而不接受 AbstractSet 甚至Random、Locale 等类型的变量。我们要对通配符有所限制。
幸运的是,Java 5 的泛型机制已经考虑到了这一点,我们可以使用边界通配符(bounded wildcard)形式来满足这个要求。
我们将 testMtd 再修改一下:
Public static void testMtd(List Extends AbstractList>) {
}
这样,List
除了上边界通配符(upper bounded wildcard)以外,我们还可以使用下边界通配符(lower bounded wildcard),例如 List super AbstractList>。
最后总结一下使用通配符的泛型类型的三种形式:
GenericType>
GenericType extends upperBoundType>
GenericType super lowerBoundType>
置换原则
结合Java本身的一些面向对象的特性,我们很容易理解这么一个置换原则:
一个指定类型的变量可以被赋值为该类型的任何子类;
一个指定某种类型参数的方法可以通过传入该类型的子类来进行调用。
总的来说,就是说我们使用的任何类型变量都可以用该类型的子类型来替换。
泛型中一种错误的继承关系
在泛型的编程中,我们考虑到子类型关系的时候,容易把一种关系给弄混淆,并错误的采用置换原则。
比如说:
List
ints.add(1);
ints.add(2);
List
在这段代码中,我们看到类型参数Integer是Number的子类型,就容易想当然的认为List
有点时候,我们觉得,这样的转换看似不能用到一个好处,就是利用对象之间继承的关系。
要是我们能有一个列表,它既能处理某种类型的数据,还能处理该类型的所有子类型的数据,这样岂不是既能用到泛型的好处又可以用到对象关系的好处么?
于是在这里就引出了通配符(wildcard)。
通配符(Wildcard)
在Java类库中Collection接口定义中有一个用到通配符的方法:
interface Collection
...
public boolean addAll(Collection extends E> c);
...
}
在addAll方法的描述里,可以接受Collection类型的参数。其中Collection中的类型参数可以为任何继承E的子类型。
因此,我们可以在实际代码中这么使用:
List
List
List
nums.addAll(ints);
nums.addAll(dbls);
在代码中我们可以看到,List
通配符使用限制1:
使用通配符的泛型数据类型比较有意思,既然前面我们可以将其作为方法声明的参数,那么是否可以将它作为一个变量类型来直接创建变量呢?
看如下代码:
List extends Number> nums = new ArrayList
实际上上面这段代码是编译通不过的。
通配符使用限制2:
既然不能用来直接创建变量对象,那么再看下面这段代码:
List
ints.add(1);
ints.add(2);
List extends Number> nums = ints;
nums.add(3.14); // compile error
这段代码的第5行会导致编译错误。在第4行代码中,我们将ints赋值给nums,表面上nums声明为一个 List
为什么第5行代码会出错呢?表面上看来,既然nums类型可以接受继承自 Number的所有参数,那加一个Double类型的数据应该是没问题的。实际上我们再考虑一下这样会带来的问题:
nums本来引用的是一个继承自该类型的List
因此,这段代码也说明了一个问题,就是在? extends E这种通配符引用的数据类型中,如果向其中增加数据操作的话会有问题。所以向其中增加数据是不允许的。但是我们可以从其中来读取数据。
总结:
1:通配符修饰的泛型不能用来直接创建变量对象。
2:通配符修饰相当于声明了一种变量,它可以作为参数在方法中传递。这么做带来的好处就是我们可以将应用于包含某些数据类型的列表的方法也应用到包含其子类型的列表中。相当于可以在列表中用到一些面向对象的特性。
自从泛型被添加到 JDK 5 语言以来,它一直都是一个颇具争议的话题。一部分人认为泛型简化了编程,扩展了类型系统从而使编译器能够检验类型安全;另外一些人认为泛型添加了很多不必要的复杂性。对于泛型我们都经历过一些痛苦的回忆,但毫无疑问通配符是最棘手的部分。
通配符基本介绍
泛型是一种表示类或方法行为对于未知类型的类型约束的方法,比如 “不管这个方法的参数 x
和 y
是哪种类型,它们必须是相同的类型”,“必须为这些方法提供同一类型的参数” 或者 “foo()
的返回值和 bar()
的参数是同一类型的”。
通配符 — 使用一个奇怪的问号表示类型参数 — 是一种表示未知类型的类型约束的方法。通配符并不包含在最初的泛型设计中(起源于 Generic Java(GJ)项目),从形成 JSR 14 到发布其最终版本之间的五年多时间内完成设计过程并被添加到了泛型中。
通配符在类型系统中具有重要的意义,它们为一个泛型类所指定的类型集合提供了一个有用的类型范围。对泛型类 ArrayList
而言,对于任意(引用)类型 T
,ArrayList>
类型是 ArrayList
的超类型(类似原始类型 ArrayList
和根类型 Object
,但是这些超类型在执行类型推断方面不是很有用)。
通配符类型 List>
与原始类型 List
和具体类型 List
都不相同。如果说变量 x
具有 List>
类型,这表示存在一些 T
类型,其中 x
是 List
类型,x
具有相同的结构,尽管我们不知道其元素的具体类型。这并不表示它可以具有任意内容,而是指我们并不了解内容的类型限制是什么 — 但我们知道存在 某种限制。另一方面,原始类型 List
是异构的,我们不能对其元素有任何类型限制,具体类型 List
表示我们明确地知道它能包含任何对象(当然,泛型的类型系统没有 “列表内容” 的概念,但可以从 List
之类的集合类型轻松地理解泛型)。
通配符在类型系统中的作用部分来自其不会发生协变(covariant)这一特性。数组是协变的,因为 Integer
是 Number
的子类型,数组类型 Integer[]
是 Number[]
的子类型,因此在任何需要 Number[]
值的地方都可以提供一个 Integer[]
值。另一方面,泛型不是协变的, List
不是 List
的子类型,试图在要求 List
的位置提供 List
是一个类型错误。这不算很严重的问题 — 也不是所有人都认为的错误 — 但泛型和数组的不同行为的确引起了许多混乱。
我已使用了一个通配符 — 接下来呢?
清单 1 展示了一个简单的容器(container)类型 Box
,它支持 put
和 get
操作。 Box
由类型参数 T
参数化,该参数表示 Box 内容的类型, Box
只能包含 String
类型的元素。
public interface Box |
通配符的一个好处是允许编写可以操作泛型类型变量的代码,并且不需要了解其具体类型。例如,假设有一个 Box>
类型的变量,比如清单 2 unbox()
方法中的 box
参数。unbox()
如何处理已传递的 box?
public void unbox(Box> box) { System.out.println(box.get()); } |
事实证明 Unbox 方法能做许多工作:它能调用 get()
方法,并且能调用任何从 Object
继承而来的方法(比如 hashCode()
)。它惟一不能做的事是调用 put()
方法,这是因为在不知道该 Box
实例的类型参数 T
的情况下它不能检验这个操作的安全性。由于 box
是一个Box>
而不是一个原始的 Box
,编译器知道存在一些 T
充当 box
的类型参数,但由于不知道 T
具体是什么,您不能调用 put()
因为不能检验这么做不会违反 Box
的类型安全限制(实际上,您可以在一个特殊的情况下调用 put()
:当您传递 null
字母时。我们可能不知道 T
类型代表什么,但我们知道 null
字母对任何引用类型而言是一个空值)。
关于 box.get()
的返回类型,unbox()
了解哪些内容呢?它知道 box.get()
是某些未知 T
的 T
,因此它可以推断出 get()
的返回类型是 T
的擦除(erasure),对于一个无上限的通配符就是 Object
。因此清单 2 中的表达式 box.get()
具有 Object
类型。
通配符捕获
清单 3 展示了一些似乎应该 可以工作的代码,但实际上不能。它包含一个泛型 Box
、提取它的值并试图将值放回同一个 Box
。
public void rebox(Box> box) { box.put(box.get()); } Rebox.java:8: put(capture#337 of ?) in Box |
这个代码看起来应该可以工作,因为取出值的类型符合放回值的类型,然而,编译器生成(令人困惑的)关于 “capture#337 of ?” 与Object
不兼容的错误消息。
“capture#337 of ?” 表示什么?当编译器遇到一个在其类型中带有通配符的变量,比如 rebox()
的 box
参数,它认识到必然有一些 T
,对这些 T
而言 box
是 Box
。它不知道 T
代表什么类型,但它可以为该类型创建一个占位符来指代 T
的类型。占位符被称为这个特殊通配符的捕获(capture)。这种情况下,编译器将名称 “capture#337 of ?” 以 box
类型分配给通配符。每个变量声明中每出现一个通配符都将获得一个不同的捕获,因此在泛型声明 foo(Pair,?> x, Pair,?> y)
中,编译器将给每四个通配符的捕获分配一个不同的名称,因为任意未知的类型参数之间没有关系。
错误消息告诉我们不能调用 put()
,因为它不能检验 put()
的实参类型与其形参类型是否兼容 — 因为形参的类型是未知的。 在这种情况下,由于 ?
实际表示 “?extends Object” ,编译器已经推断出 box.get()
的类型是 Object
,而不是 “capture#337 of ?”。它不能静态地检验对由占位符 “capture#337 of ?” 所识别的类型而言 Object
是否是一个可接受的值。助手
虽然编译器似乎丢弃了一些有用的信息,我们可以使用一个技巧来使编译器重构这些信息,即对未知的通配符类型命名。清单 4 展示了 rebox()
的实现和一个实现这种技巧的泛型助手方法(helper):4. “捕获助手” 方法
public void rebox(Box> box) { reboxHelper(box); } private |
助手方法 reboxHelper()
是一个泛型方法, 泛型方法引入了额外的类型参数(位于返回类型之前的尖括号中),这些参数用于表示参数和/或方法的返回值之间的类型约束。然而就 reboxHelper()
来说,泛型方法并不使用类型参数指定类型约束,它允许编译器(通过类型接口)对 box 类型的类型参数命名。
捕获助手技巧允许我们在处理通配符时绕开编译器的限制。当 rebox()
调用 reboxHelper()
时,它知道这么做是安全的,因为它自身的 box
参数对一些未知的 T
而言一定是 Box
。因为类型参数 V
被引入到方法签名中并且没有绑定到其他任何类型参数,它也可以表示任何未知类型,因此,某些未知 T
的 Box
也可能是某些未知 V
的 Box
(这和 lambda 积分中的 α 减法原则相似,允许重命名边界变量)。现在 reboxHelper()
中的表达式 box.get()
不再具有 Object
类型,它具有 V
类型 — 并允许将 V
传递给 Box
。
我们本来可以将 rebox()
声明为一个泛型方法,类似 reboxHelper()
,但这被认为是一种糟糕的 API 设计样式。此处的主要设计原则是 “如果以后绝不会按名称引用,则不要进行命名”。就泛型方法来说,如果一个类型参数在方法签名中只出现一次,它很有可能是一个通配符而不是一个命名的类型参数。一般来说,带有通配符的 API 比带有泛型方法的 API 更简单,在更复杂的方法声明中类型名称的增多会降低声明的可读性。因为在需要时始终可以通过专有的捕获助手恢复名称,这个方法让您能够保持 API 整洁,同时不会删除有用的信息。型推断
捕获助手技巧涉及多个因素:类型推断和捕获转换。Java 编译器在很多情况下都不能执行类型推断,但是可以为泛型方法推断类型参数(其他语言更加依赖类型推断,将来我们可以看到 Java 语言中会添加更多的类型推断特性)。如果愿意,您可以指定类型参数的值,但只有当您能够命名该类型时才可以这样做 — 并且不能够表示捕获类型。因此要使用这种技巧,要求编译器能够为您推断类型。捕获转换允许编译器为已捕获的通配符产生一个占位符类型名,以便对它进行类型推断。
当解析一个泛型方法的调用时,编译器将设法推断类型参数它能达到的最具体类型。 例如,对于下面这个泛型方法:
public static |
和它的调用:
Integer i = 3; System.out.println(identity(i)); |
编译器能够推断 T
是 Integer
、Number
、 Serializable 或 Object
,但它选择 Integer
作为满足约束的最具体类型。
当构造泛型实例时,可以使用类型推断减少冗余。例如,使用 Box
类创建 Box
要求您指定两次类型参数 String
:
Box |
即使可以使用 IDE 执行一些工作,也不要违背 DRY(Don't Repeat Yourself)原则。然而,如果实现类 BoxImpl
提供一个类似清单 5 的泛型工厂方法(这始终是个好主意),则可以减少客户机代码的冗余:单 5. 一个泛型工厂方法,可以避免不必要地指定类型参数
public class BoxImpl |
如果使用 BoxImpl.make()
工厂实例化一个 Box
,您只需要指定一次类型参数:
Box |
泛型 make()
方法为一些类型 V
返回一个 Box
,返回值被用于需要 Box
的上下文中。编译器确定 String
是 V
能接受的满足类型约束的最具体类型,因此此处将 V
推断为 String
。您还可以手动地指定 V
的值:
Box |
除了减少一些键盘操作以外,此处演示的工厂方法技巧还提供了优于构造函数的其他优势:您能够为它们提高更具描述性的名称,它们能够返回命名返回类型的子类型,它们不需要为每次调用创建新的实例,从而能够共享不可变的实例(参见 参考资料 中的 Effective Java, Item #1,了解有关静态工厂的更多优点)。
通配符无疑非常复杂:由 Java 编译器产生的一些令人困惑的错误消息都与通配符有关,Java 语言规范中最复杂的部分也与通配符有关。然而如果使用适当,通配符可以提供强大的功能。此处列举的两个技巧 — 捕获助手技巧和泛型工厂技巧 — 都利用了泛型方法和类型推断,如果使用恰当,它们能显著降低复杂性。