美文网首页
第三十一条:利用有限制通配符来提升API的灵活性

第三十一条:利用有限制通配符来提升API的灵活性

作者: Js_Gavin | 来源:发表于2021-02-15 18:20 被阅读0次

    如第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)。还要记住,所有比较对象和比较器都是消费者

    相关文章

      网友评论

          本文标题:第三十一条:利用有限制通配符来提升API的灵活性

          本文链接:https://www.haomeiwen.com/subject/udspxltx.html