利用有限制通配符来提升API的灵活性。

来源:互联网 发布:全友家私 知乎 编辑:程序博客网 时间:2024/06/01 08:21

        参数化类型是不可变的(invariant)。换句话说,对于任何两个截然不同的类型Type1和Type2而言,List<Type1>既不是List<Type2>的子类型,也不是它的超类型。虽然List<String>不是List<Object>的子类型,这与直觉相挬,但是实际上很有意义。你可以将任何对象放进一个List<Object>中,却只能将字符串放进List<String>中。

        有时候,我们需要的灵活性要比不可变类型所能提供的更多。考虑堆栈下面就是他的公共API:

public class Stack<E> {

public Stack();

public void push(E e);

public E pop();

public boolean isEmpty();

}

        假设我们想要增加一个方法,让他按顺序将一系列的元素全部放到堆栈中。这是第一次尝试,如下:

// pushAll method without wildcard type - deficident!

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 有些误导:确定了子类型(subtype后,每个类型便都是自身的子类型,即便他没有将自身扩展。))我们修改一下pushAll来使用这个类型:

// wildcard type for parameter that serves as an E producer

public void pushAll(Iterable<? extend E> src) {

for (E e :src) push(e);

}

        这么修改了之后,不仅Stack可以正确无误的编译,没有通过初始的pushAll声明进行编译的客户端代码也一样可以。因为Stack及其客户端正确无误的进行了编译,你就知道一切都是类型安全的了。

        现在假设想要编写一个pushAll方法,使之与popAll方法相呼应。popAll方法从堆栈中弹出每个元素,并将这些元素添加到指定的集合中。初次尝试编写的popAll方法可能像下面这样:

// popAll method without wildcard 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是它自身的一个超类型)。仍然有一个通配符类型正是符合此意: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。

        记住这个助记符,我们下面来看一些之前的条目中提到过的方法声明。例如:

static <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> E reduce(List<? extends E> list , Function<E> f , E initVal)

        这一变化实际上有什么区别吗?事实上,的确有区别。假设你有一个List<Integer>,想通过Function<Number>把他简化。他不能通过初始声明进行编译,但是一旦添加了有限制的通配符类型,就可以了。

        现在我们看一个例子。下面是声明:

public static <E> Set<E> uniono(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页,而且他们并非总能完成需要他们完成的工作。看看修改过的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> numbers = union(integers ,doubles);

        幸运的是,有一种办法可以处理这种错误。如果编译器不能推断你希望他拥有的类型,可以通过一个显示的类型参数(explicit type parameter)来告诉他要使用哪种类型。这种情况不太经常发生,这是好事,因为显示的类型参数不太优雅。增加了这个显示的类型参数之后,程序可以正确无误的进行编译:

        Set<Number> numbers = Union.<Number>union(integers , doubles);

        接下来,我们看一下下面的例子。以下是初始的声明:

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接口的子接口。换句话说,ScheduleFuture实例并非只能与其他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> i = list.iterator();

        这条错误消息意味着什么,我们又该如何修正这个问题呢?它意味着list不是一个List<T>,因此它的iterator方法没有返回Iterator<T>。他返回T的某个子类型的一个iterator,因此我们用他代替iterator声明,它使用了一个有限制的通配符类型:

Iterator<? extends T> i = list.iterator();

        这是必须对方法体所做的唯一修改。迭代器的next方法返回的元素属于T的某个子类型,因此他们可以被安全的保存在类型T的一个变量中。

        还有一个与通配符有关的话题值得探讨。类型参数和通配符之间具有双重性,许多方法都可以利用其中一个或者另一个进行声明。例如,下面是可能的两种静态方法声明,来交换列表中的两个被索引的项目。第一个使用无限制的类型参数,第二个使用无限制的通配符:

// 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都是消费者。


阅读全文
0 0