Effective Java 第三版——31.使用限定通配符来增长API的灵活性

Tips
《Effective Java, Third Edition》一书英文版已经出版,这本书的第二版想必不少人都读过,号称Java四大名著之一,不过第二版2009年出版,到如今已经将近8年的时间,但随着Java 6,7,8,甚至9的发布,Java语言发生了深入的变化。
在这里第一时间翻译成中文版。供你们学习分享之用。java

Effective Java, Third Edition

31. 使用限定通配符来增长API的灵活性

如条目 28所述,参数化类型是不变的。换句话说,对于任何两个不一样类型的Type1TypeList <Type1>既不是List <Type2>子类型也不是其父类型。尽管List <String>不是List <Object>的子类型是违反直觉的,但它确实是有道理的。 能够将任何对象放入List <Object>中,可是只能将字符串放入List <String>中。 因为List <String>不能作List <Object>所能作的全部事情,因此它不是一个子类型(条目 10 中的里氏替代原则)。api

相对于提供的不可变的类型,有时你须要比此更多的灵活性。 考虑条目 29中的Stack类。下面是它的公共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);
}

这种方法能够干净地编译,但不彻底使人满意。 若是可遍历的src元素类型与栈的元素类型彻底匹配,那么它工做正常。 可是,假设有一个Stack <Number>,并调用push(intVal),其中intVal的类型是Integer。 这是由于IntegerNumber的子类型。 从逻辑上看,这彷佛也应该起做用:ui

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);

                            ^

幸运的是,有对应的解决方法。 该语言提供了一种特殊的参数化类型来调用一个限定通配符类型来处理这种状况。 pushAll的输入参数的类型不该该是“E的Iterable接口”,而应该是“E的某个子类型的Iterable接口”,而且有一个通配符类型,这意味着:Iterable <? extends E>。 (关键字extends的使用有点误导:回忆条目 29中,子类型被定义为每一个类型都是它本身的子类型,即便它自己没有继承。)让咱们修改pushAll来使用这个类型:code

// 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和它的客户端干净地编译,你知道一切都是类型安全的。对象

如今假设你想写一个popAll方法,与pushAll方法相对应。 popAll方法从栈中弹出每一个元素并将元素添加到给定的集合中。 如下是第一次尝试编写popAll方法的过程:blog

// 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是它本身的父类型[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实例,所以src的合适类型为Iterable<? extends E>popAll方法的dst参数消费Stack中的E实例,所以dst的合适类型是Collection <? super E>。 PECS助记符抓住了使用通配符类型的基本原则。 Naftalin和Wadler称之为获取和放置原则( Get and Put Principle )[Naftalin07,2.4]。

记住这个助记符以后,让咱们来看看本章中之前项目的一些方法和构造方法声明。 条目 28中的Chooser类构造方法有这样的声明:

public Chooser(Collection<T> choices)

这个构造方法只使用集合选择来生产类型T的值(并将它们存储起来以备后用),因此它的声明应该使用一个extends T的通配符类型。下面是获得的构造方法声明:

// Wildcard type for parameter that serves as an T producer

public Chooser(Collection<? extends T> choices)

这种改变在实践中会有什么不一样吗? 是的,会有不一样。 假你有一个List <Integer>,而且想把它传递给Chooser<Number>的构造方法。 这不会与原始声明一块儿编译,可是它只会将限定通配符类型添加到声明中。

如今看看条目 30中的union方法。下是声明:

public static <E> Set<E> union(Set<E> s1, Set<E> s2)

两个参数s1s2都是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可能有问题。

在Java 8以前,类型推断规则不够聪明,没法处理先前的代码片断,这要求编译器使用上下文指定的返回类型(或目标类型)来推断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<?>

幸运的是有办法来处理这种错误。 若是编译器不能推断出正确的类型,你能够随时告诉它使用什么类型的显式类型参数[JLS,15.12]。 甚至在Java 8中引入目标类型以前,这不是你必须常常作的事情,这很好,由于显式类型参数不是很漂亮。 经过添加显式类型参数,以下所示,代码片断在Java 8以前的版本中进行了干净编译:

// 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。首先直接的应用是参数列表。 它生成T实例,因此将类型从List <T>更改成List<? extends T>。 棘手的应用是类型参数T。这是咱们第一次看到通配符应用于类型参数。 最初,T被指定为继承Comparable <T>,但ComparableT消费T实例(并生成指示顺序关系的整数)。 所以,参数化类型Comparable <T>被替换为限定通配符类型Comparable<? super T>Comparable实例老是消费者,因此一般应该使用Comparable<? super T>优于Comparable <T>Comparator也是如此。所以,一般应该使用Comparator<? super T>优于Comparator<T>

修改后的max声明多是本书中最复杂的方法声明。 增长的复杂性是否真的起做用了吗? 一样,它的确如此。 这是一个列表的简单例子,它被原始声明排除,但在被修改后的版本里是容许的:

List<ScheduledFuture<?>> scheduledFutures = ... ;

没法将原始方法声明应用于此列表的缘由是ScheduledFuture不实现Comparable <ScheduledFuture>。 相反,它是Delayed的子接口,它继承了Comparable <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 <?>,而且不能将除null外的任何值放入List <?>中。 幸运的是,有一种方法能够在不使用不安全的转换或原始类型的状况下实现此方法。 这个想法是写一个私有辅助方法来捕捉通配符类型。 辅助方法必须是泛型方法才能捕获类型。 如下是它的定义:

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 <E>。 所以,它知道从这个列表中得到的任何值都是E类型,而且能够安全地将任何类型的E值放入列表中。 这个稍微复杂的swap的实现能够干净地编译。 它容许咱们导出基于通配符的漂亮声明,同时利用内部更复杂的泛型方法。 swap方法的客户端不须要面对更复杂的swapHelper声明,但他们从中受益。 辅助方法具备咱们认为对公共方法来讲过于复杂的签名。

总之,在你的API中使用通配符类型,虽然棘手,但使得API更加灵活。 若是编写一个将被普遍使用的类库,正确使用通配符类型应该被认为是强制性的。 记住基本规则: producer-extends, consumer-super(PECS)。 还要记住,全部ComparableComparator都是消费者。

相关文章
相关标签/搜索