item 31: 使用有界通配符来增加API的灵活性

2019-06-22  本文已影响0人  rabbittttt

ITEM 31: USE BOUNDED WILDCARDS TO INCREASE API FLEXIBILITY
  如 item 28 所述,参数化类型是不变的。换句话说,对于任何两种类型 Type1 和Type2, List<Type1> 既不是 List<Type2> 的子类型,也不是 List<Type2> 的超类型。虽然 List<String> 不是 List<Object> 的子类型,这似乎是违反直觉的,但它确实有意义。您可以将任何对象放入 List<Object>,但是您只能将字符串放入 List<String>。由于 List<String> 不能做 List<Object> 可以做的所有事情,所以它不是子类型(根据Liskov替换原则,item 10)。有时候,您需要比不变类型所能提供的更大的灵活性。考虑item 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语言提供了一种特殊的参数化类型,使用有界通配符类型来处理这种情况。pushAll 的输入参数类型不应该是“Iterable of E”,而应该是“E的某个子类型的 Iterable”,并且有一个通配符类型,它的确切含义是: Iterable<? extends E>。(关键字extends的使用有点误导人:回顾 item 29,定义了子类型,因此每个类型都是自身的子类型,即使它不扩展自己。)让我们修改 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和它的客户机编译得很干净,所以您知道所有东西都是类型安全的。
  现在假设您想编写一个 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> is not a subtype of 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 stands for producer-extends, consumer-super. "
  换句话说,如果参数化类型表示一个 T 生产者,那么使用 <? extends T>;如果它表示一个 T 消费者,使用 <? super T>。在我们的堆栈示例中,pushAll 的 src参数生成供堆栈使用的 E 实例,因此 src 的适当类型是 Iterable<? extends E>;popAll 的 dst参数使用堆栈中的 E 个实例,因此 dst 的类型应当是 Collection<? super E>。PECS助记符捕获了指导通配符类型使用的基本原则。Naftalin和Wadler将其称为Get and Put原则[Naftalin 07,2.4]。
  记住这个助记符之后,让我们看看本章前面几项中的一些方法和构造函数声明。
  item 28中的 Chooser 构造函数有如下声明:
public Chooser(Collection<T> choices)
  这个构造函数只使用集合选项来生成 T 类型的值(并将它们存储起来供以后使用),因此它的声明应该使用扩展T的通配符类型。下面是生成的构造函数声明:

// Wildcard type for parameter that serves as an T producer
public Chooser(Collection<? extends T> choices)

  这种改变在实践中会有什么不同吗?是的,它会。假设您有一个列表,您希望将它传递给选择器的构造函数。这将不会使用原始声明编译,但是一旦将有界通配符类型添加到声明中,就会编译。
  现在让我们看看item 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可能有问题。
  在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);

  接下来,让我们将注意力转向 item 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>,但是 T 的 Comparable 使用 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 实例进行比较;它可以与任何延迟的实例进行比较,这足以导致原始声明拒绝它。更一般地说,通配符需要支持不直接实现 Comparable(或Comparator)但扩展了实现 Comparable(或Comparator) 的类型。
  还有一个与通配符相关的主题值得讨论。类型参数和通配符之间存在对偶性,可以使用其中一个或另一个声明许多方法。例如,下面是静态方法的两种可能声明,用于交换列表中两个索引项。第一个使用无界类型参数(Item 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的类型lList<?>,您不能将除null之外的任何值放入列表。幸运的是,有一种方法可以实现这种方法,而不用使用不安全的强制转换或原始类型。其思想是编写一个私有helper方法来捕获通配符类型。为了捕获类型,helper方法必须是泛型方法。它是这样的:

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 method 的客户端不必面对更复杂的 swapHelper 声明,但它们确实从中受益。值得注意的是,helper 方法恰好具有我们认为对于公共方法过于复杂而忽略的签名。
  总之,在api中使用通配符类型虽然很棘手,但会使api更加灵活。如果您编写的库将被广泛使用,应该认为必须正确使用通配符类型。记住基本规则: producer-extends, consumer-super (PECS)。还要记住,所有比较对象和比较器都是消费者。

上一篇下一篇

猜你喜欢

热点阅读