第28条:利用有限通配符来提升API的灵活性
如第25条所述,参数化类型是不可变的(invariant)。换句话说,对于任何两个截然不同的Type1
和Type2
而言,List<Type1>
既不是List<Type2>
的子类型,也不是它的超类型。虽然List<String>
不是List<Object>
的子类型,这与直觉相悖,但是实际上很有意义。你可以将任何对象放进一个List<Object>
中,却只能将字符串放进List<String>
中。
有时候,我们需要的灵活性要比不可变类型所能提供的更多。考虑第26条中的堆栈,下面就是它的公共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<Number>();
Iterable<Integer> integers = ...;
numberStack.pushAll(integers);
但是,如果尝试这么做,就会得到下面的错误消息,因为如前所述,参数化类型是不可变的:
StackTest.java:7: pushAll(Iterable<Number>) in Stack<Number>
cannot be applied to (Iterable<Integer>)
numberStack.pushAll(integers);
^
幸运的是,有一种解决办法。Java提供了一种特殊的参数化类型,称作有限制的通配符类型(bounded wildcard type),来处理类似的情况。pushAll
的输入参数类型不应该为“E
的Iterable
接口”,而应该为“E
的某个子类型的Iterable
接口”,有一个通配符类型正符合此意:Iterable<? extends E>
。(使用关键字extends
有些误导:回忆一下第26条中的说法,确定了子类型(subtype)后,每个类型便都是自身的子类型,即便它没有将自己扩展。)我们修改一下pushAll
来使用这个类型:
// Wildcard type for 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
方法可能像下面这样:
// popAll method without weildcard type - deficient!
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
是它自身的一个超类型[JLS,4.10])。仍然有一个通配符正是符合此意: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 表示 producer-extends,consumer-super。
换句话说,如果参数化类型表示一个T
生产者,就是用<? extends T>
;如果它表示一个T
消费者,就是用<? super T>
。在我们的Stack
示例中,pushAll
的src
参数产生E
实例供Stack
使用,因此src
相应的类型为Iterable<? extends E>
;popAll
的dst
参数通过Stack
消费E
实例,因此dst
相应的类型为Collection<? super E>
。PECS这个助记符突出了使用通配符类型的基本原则。Naftalin 和 Wadler 称之为Get and Put Principle[Naftalin07, 2.4]。
记住这个助记符,我们下面来看一些之前的条目中提到过的方法声明。第25条中的reduce
方法就有这条声明:
static <E> E reduce(List<E>, list, Function<E> f, E initVal)
虽然列表既可以消费也可以产生值,reduce
方法还是只用它的list
参数作为E
生产者(producer),因此它的声明就应该使用一个extends E
的通配符类型。参数f
表示皆可以消费又可以产生E
实例的函数,因此通配符类型不适合它。得到的方法声明如下:
// Wildcard type for parameter that serves as an E producer
static <E> reduce(List<? extends E> list, Function<E> f,
E initVal)
这一变化实际上有什么区别吗?事实上,的确有区别。假设你有一个List<Integer>
,想通过Function<Number>
把它简化。它不能通过初始声明进行编译,但是一旦添加了有限制的通配符类型,就可以了。
现在让我们看看第27条中的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>
,不要用通配符类型作为返回类型。除了为用户提供额外的灵活性之外,它还会强制用户在客户端代码中使用通配符类型。
如果使用得当,通配符类型对于类的用户来说几乎是无形的。它们使方法能够接受它们应该接受的参数,并拒绝那些应该拒绝的参数。如果类的用户必须考虑通配符类型,类的API或许就会出错。
遗憾的是,类型推导(type inference)规则相当复杂,在语言规范中占了整整16页[JLS,15.12.2.7-8],而且它们并非总能完成需要它们完成的工作。看看修改过的union
声明,你可能会以为可以像这样编写:
Set<Integer> integers = ... ;
Set<Double> doubles = ... ;
Set<Number> numbers = union(integers, doubles);
但这么做会得到下面的错误消息:
Union.java:14: incompatible types
found : Set<Number & Comparable<? extends Number &
Comparable<?>>>
required: Set<Number>
Set<Number> numbers = union(integers, doubles);
^
幸运的是,有一种办法可以处理这个错误。如果编译器不能推断你希望它拥有的类型,可以通过一个显式的类型参数(explicit type parameter)来告诉它要使用哪种类型。这种情况不太经常发生,这是好事,因为显式的类型参数不太优雅。增加了这个显式的类型参数之后,程序可以正确无误地进行编译:
Set<Number> numbers = Union.<Number>union(integers, doubles);
接下来,我们把注意力转向第27条中的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
也一样,因此使用时始终应该是Comparable<? super T>
优先于Comparable<T>
。
修改过的max
声明可能是整本书中最复杂的方法声明了。所增加的复杂代码真的起作用了吗?是的,起作用了。下面是一个简单的列表示例,在初始的声明中不允许这样,修改过的版本则可以:
List<ScheduledFuture<?>> scheduledFutures = ... ;
不能将初始方法声明运用给这个列表的原因在于,java.util.concurrent.ScheduledFuture
没有实现Comparable<ScheduledFuture>
接口。相反,它是扩展Comparable<Delayed>
接口的Delayed
接口的子接口。换句话说,ScheduledFuture
示例并非只能与其他ScheduledFuture
示例相比较;它可以与任何Delayed
实例相比较,这就足以导致初始声明时就会被拒绝。
修改过的max
声明有一个小小的问题:它阻止方法进行编译。下面的方法包含了修改过的声明:
// Won't compile - wildcards can require change in method body!
public static <T extends Comparable<? super T>> T max(
List<? extends T> list) {
Iterator<T> i = list.iterator();
T result = i.next();
while (i.hasNext()) {
T t = i.next();
if (t.compareTo(result) > 0)
result = t;
}
return result;
}
以下是它编译时会产生的错误消息:
Max.java:7: incompatible types
found : Iterator<capture#591 of ? extends T>
required: Iterator<T>
Iterator<T> i = list.iterator();
^
这条错误消息意味着什么,我们又该如何修正这个问题呢?它意味着list
不是一个List<T>
,因此它的iterator
方法没有返回Iterator<T>
。它返回T
的某个子类型的一个iterator
,因此我们用它代替iterator
声明,它使用了一个有限制的通配符类型:
Iterator<? extends T> i = list.iterator();
这是必须对方法体所做的唯一修改。迭代器的next
方法返回的元素属于T
的某个子类型,因此它们可以被安全地保存在类型T
的一个变量中。
还有一个与通配符有关的话题值得探讨。类型参数和通配符之间具有双重性,许多方法都可以利用其中一个或者另一个进行声明。例如,下面是可能的两种静态方法声明,来交换列表中的两个被索引的项目。第一个使用无限制的类型参数(见第27条),第二个使用无限制的通配符:
// 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: set(int,capture#282 of ?) in List<capture#282 of ?>
cannot be applied to (int,Object)
list.set(i, list.set(j, list.get(i)));
^
不能讲元素放回到刚刚从中取出的列表中,这似乎不太对劲。问题在于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)。还要记住所有的comparable
和comparator
都是消费者。