如第28条所述,参数化类型是不变的(invariant)。换句话说,对于任何两个截然不同的类型Type1和Type2而言,List<Type1>既不是List<Type2>的子类型,也不是它的超类型。虽然List<String>不是List<Object>的子类型,这与直觉相悖,但是实际上很有意义。你可以将任何对象放进一个List<Object>中,却只能将字符串放进List<String>中。由于List<String>不能像List<Object>能做任何事情,它不是一个子类型(详见第10条)。
有时候,我们需要的灵活性要比不变类型所能够提供的更多。比如第29条中的堆栈。提醒一下,下面就是它的公用API:
public class Stack<E> {
public Stack();
public void push(E e);
public E pop();
public boolean isEmpty();
}
假设我们想要增加一个方法,让它按顺序将一系列的元素全部放在堆栈中。第一次尝试如下:
// pushAll method without wildcard type - deficient!
public void pushAll(Iterable<E> src) {
for (E e : src)
push(e);
}
这个方法编译时正确无误,但是并非尽如人意。如果Iterable的src元素类型与堆栈的完全匹配,就没有问题。但是假如有一个Stack<Number>,并且调用了push(intVal),这里的intVal就是Integer类型。这是可以的,因为Integer是Number的一个子类型。因此从逻辑上来说,下面这个方法应该可行:
Stack<Number> numberStack = new Stack<>();
Iterable<Integer> integers = ... ;
numberStack.pushAll(integers);
但是,如果尝试这么做,就会得到下面的错误消息,因为参数化类型是不可变的:
StackTest.java:7: error: incompatible types: Iterable<Integer>
cannot be converted to Iterable<Number>
numberStack.pushAll(integers);
幸运的是,有一种解决办法。Java提供了一种特殊的参数化类型,称作有限制的通配符类型(bounded wildcard type),它可以处理类似的情况。pushAll的输入参数类型不应该为“E的Iterable接口”,而应该为“E的某个子类型的Iterable接口”
通配符类型Iterable<? extends E>正是这个意思。(使用关键字extends有些误导:回忆一下第29条中的说法,确定了子类型(subtype)后,每个类型便都是自身的子类型,即便它没有将自身扩展。)我们修改以下pushAll来使用这个类型:
// Wildcard type for a parameter that serves as an E producer
public void pushAll(Iterable<? extends E> src) {
for (E e : src)
push(e);
}
修改之后,不仅Stack可以正确无误的编译,没有通过初始化pushAll声明进行编译的客户端代码也一样可以。因为Stack及其客户端正确无误的进行了编译,你就知道一切都是类型安全的了。
现在假设想要编写一个pushAll方法,使之与popAll方法相呼应。popAll方法从堆栈中弹出每个元素,并将这些元素添加到指定的集合中。初次尝试编写的popAll方法可能像下面这样:
public void popAll(Collection<E> dst) {
while (!isEmpty())
dst.add(pop());
}
此外,如果目标集合的元素类型与堆栈的完全匹配,这段代码编译时还是正确无误,并且运行良好。但是,也并不意味着尽如人意。假设你有一个Stack<Number>和Object类型的变量。如果从堆栈中弹出一个元素,并将它保存在该变量中,它的编译和运行都不会出错,那为何不能这么做呢?
Stack<Number> numberStack = new Stack<Number>();
Collection<Object> objects = ... ;
numberStack.popAll(objects);
如果试着用上述的popAll版本编译这段客户端代码,就会得到一个非常类似于第一次用pushAll时所得到的错误:Collection<Object>不是Collection<Number>的子类型。这一次通配符同样提供了一种解决办法。popAll的输入类型不应该为“E的集合”,而应该为“E的某种超类的集合“
:(这里的超类是确定的,因此E是它自身的一个超类型)。仍有一个通配符类型正符合此意:Collection<? super E>。让我们修改popAll来使用它:
// Wildcard type for parameter that serves as an E consumer
public void popAll(Collection<? super E> dst) {
while (!isEmpty())
dst.add(pop());
}
做了这个变动之后,Stack和客户端代码就可以正确无误的编译了。
结论很明显:为了获得最大限度的灵活性,要在表示生产者或者消费者的输入参数上使用通配符
。如果某个输入参数即是生产者,又是消费者,那么通配符类型对你就没有好处了:因为你需要的是严格的类型匹配,这是不用任何通配符而得到的。
下面的助记便于让你记住要使用哪种通配符类型:
PECS stands for producer-extends, consumer-super
换句话说,如果参数化类型表示一个生产者T,就使用<? extends T>
;如果它表示一个消费者T,就使用<? super T>
。在我们的Stack示例中,pushAll的src参数产生E实例供Stack使用,因此src相应的类型为Iterable<? extends E>;popAll的src参数通过Stack消费E实例,因此dst相应的类型为Colletion<? super E>。PECS这个助记符突出了使用通配符类型的基本原则。Naftalin和Wadler称之为Get and Put Principle。
记住这个助记符,下面我们来看一些之前的条目中提到过的方法声明。第28条中的reduce方法就有这条声明:
public Chooser(Collection<T> choices)
这个构造器只用choices集合来生成类型T的值(并把它们保存起来供后续使用),因此它的声明应该使用一个extends T的通配符类型。得到的构造器声明如下:
// Wildcard type for parameter that serves as an T producer
public Chooser(Collection<? extends T> choices)
这一变化实际上有什么区别呢?事实上,的确有区别。假设你有一个List<Integer>,想通过Function<Number>把它简化。它不能通过初始声明进行编译,但是一旦添加了有限制的通配符类型,就可以进行编译了。
现在让我们看看第30条中的union方法。声明如下:
public static <E> Set<E> union(Set<E> s1, Set<E> s2)
s1和s2这两个参数都是生产者E,因此根据PECS助记符,这个声明应该是:
public static <E> Set<E> union(Set<? extends E> s1, Set<? extends E> s2)
注意返回类型仍然是Set<E>。不要用通配符类型作为返回类型
。除了为用户提供额外的灵活性之外,它还会强制用户在客户端代码中使用通配符类型。修改了声明之后,这段代码就能正常编译了:
Set<Integer> integers = Set.of(1, 3, 5);
Set<Double> doubles = Set.of(2.0, 4.0, 6.0);
Set<Number> numbers = union(integers, doubles);
如果使用得当,通配符类型对于类的用户来说几乎是无形的。它们使方法能够接收它们应该接受的参数,并拒绝那些应该拒绝的参数。如果类的用户必须考虑通配符类型,类的API或许就会出错
。
在Java8之前,类型推导(type inference)规则还不够智能,它无法处理上述代码片段,还需要编译器使用通过上下文指定的返回类型(或者目标类型)来推断E的类型。前面出现过的union调用的目标类型是Set<Number>。如果试着在较早的Java版本中编译这个代码片段(使用Set.of工厂相应的代替方法),将会得到一条像下面这样的冗长、繁复的错误消息:
Union.java:14: error: incompatible types
Set<Number> numbers = union(integers, doubles);
required: Set<Number>
found: Set<INT#1>
where INT#1,INT#2 are intersection types:
INT#1 extends Number,Comparable<? extends INT#2> INT#2 extends Number,Comparable<?>
幸运的是,有一种办法可以处理这种错误。如果编译器不能推断出正确的类型,始终可以通过一个显式的类型参数(explicit type parameter)来告诉它要使用哪种类型。甚至在Java8中引入目标类型之前,这种情况不经常发生,这是好事,因为显式的类型参数不太优雅。增加了这个显式的类型参数之后,这个代码片段在Java8之前的版本中也能正确无误的进行编译了:
// Explicit type parameter - required prior to Java 8
Set<Number> numbers = Union.<Number>union(integers, doubles);
接下开我们把注意力转向第30条中的max方法。以下式初始的声明:
public static <T extends Comparable<T>> T max(List<T> list)
下面是修改过的使用通配符类型的声明:
public static <T extends Comparable<? super T>> T max(List<? extends T> list)
为了从初始化声明中得到修改后的版本,要应用PECS转换两次。最直接的是运用到参数list。它产生T实例,因此将类型从List<T>改成List<? extends T>。更加灵活运用到类型参数T。这是我们第一次见到将通配符运用到类型参数。最初T被指定用来扩展Comparable<T>,但是T的comparable消费T的实例
(并产生表示顺序关系的整值)。因此,参数化类型Comparable<T>被有限制通配符类型Comparable<? super T>取代。comparable始终是消费者,因此使用时始终应该是Comparable<? super T>优先于Comparable<T>
。对于comparator接口也一样,因此使用时始终应该是Comparator<? super T>优先于Comparator<T>
。
修改过的max声明可能是整本书中最复杂的方法声明了。所增加的复杂代码真的起作用了吗?是的,起作用了。下面是一个简单的列表示例,在初始化的声明中不允许这样,修改过的版本则可以:
List<ScheduledFuture<?>> scheduledFutures = ... ;
不能将初始化方法声明运用到这个列表的原因在于,java.util.concurrent.ScheduledFuture没有实现Comparable<ScheduledFuture>接口。相反,它是扩展Comparable<Delayed>接口的Delayed接口的子接口。换句话说,ScheduledFuture实例并非只能与其他ScheduledFuture实例相比较;它可以与任何Delayed实例相比较,这就足以导致初始声明时就会被拒绝。更加通俗的说,需要用通配符支持那些不直接实现Comparable(或者Comparator)而是扩展实现了该接口的类型。
还有一个与通配符有关的话题值得探讨。类型参数和通配符之间具有双重性,许多方法都可以利用其中一个或者另一个进行声明。例如,下面是可能的两种静态方法声明,来交换列表中的两个被索引的项目。第一个使用无限制的类型参数(详见第30条),第二个使用无限制的通配符:
// Two possible declarations for the swap method
public static <E> void swap(List<E> list, int i, int j);
public static void swap(List<?> list, int i, int j);
你更喜欢这两种声明中的哪一种呢?为什么?在公共API中,第二种更好一些,因为它更简单。将它传到一个列表中(任何列表)方法就会交换被索引的元素。不用担心类型参数。一般来说,如果类型参数只在方法声明中出现一次,就可以用通配符取代它
。如果是无限制的类型参数,就用无限制的通配符取代它;如果是有限制的类型参数,就用有限制的通配符取代它。
将第二种声明用于swap方法有一个问题。下面这个简单的实现不能编译:
public static void swap(List<?> list, int i, int j) {
list.set(i, list.set(j, list.get(i)));
}
试着编译时会产生这条没有什么用处的错误消息:
Swap.java:5: error: incompatible types: Object cannot be converted to CAP#1
list.set(i, list.set(j, list.get(i)));
where CAP#1 is a fresh type-variable: CAP#1 extends Object from capture of ?
不能将元素放回到刚刚从中取出的列表中,这似乎不太对劲。问题在于list的类型为List<?>,你不能把null之外的任何值放到List<?>中,幸运的是,有一种方式可以实现这个方法,无须求助于不安全的转换的转换或者原生态类型(raw type)。这种想法就是编写一个私有的辅助方法来捕捉通配符类型。为了捕捉类型,辅助方法必须是一个泛型方法,像下面这样:
public static void swap(List<?> list, int i, int j) {
swapHelper(list, i, j);
}
// Private helper method for wildcard capture
private static <E> void swapHelper(List<E> list, int i, int j) {
list.set(i, list.set(j, list.get(i)));
}
swapHelper方法知道list是一个List<E>。因此,它知道从这个列表中取出的任何值均为E类型,并且知道将E类型的任何值放进列表都是安全的。swap这个有些费解的实现编译起来却是正确无误的。它允许我们导出swap这个比较好的基于通配符的声明,同时在内部利用更加复杂的泛型方法。swap方法的客户端不一定要面对更加复杂的swapHelper声明,但是它们的确从中受益。值得一提的是,辅助方法中拥有的签名,正是我们在共有方法中因为它过于复杂而抛弃的。
总而言之,在API中使用通配符类型虽然比较需要技巧,但是会使API变得灵活得多。如果编写的是将被广泛使用的类库,则一定要适当利用通配符类型。记住基本的原则: producer-extends, consumer-super (PECS)。还要记住,所有比较对象和比较器都是消费者。
网友评论