A.4 通配符与PECS
通配符是一种使用问号(?)的类型参数,可能存在(也可能不存在)上界或下界。
A.4.1 无界通配符
没有边界的类型参数很有用,不过也存在一定局限性。如例 A-6 所示,对一个声明为无界类型的 List 而言,可以读取,但无法写入。
例 A-6 使用无界通配符的
List
- List<?> stuff = new ArrayList<>();
- // stuff.add("abc"); ➊
- // stuff.add(new Object());
- // stuff.add(3);
- int numElements = stuff.size(); ➋
❶ 不允许进行添加操作
❷ numElements 为 0
由于无法传入任何内容,上述代码的意义不大。不过,无界 List 的一种用途在于,所有传入 List 作为参数的方法都会在调用时接受任何列表,如例 A-7 所示。
例 A-7 无界
List作为方法参数
- private static void printList(List<?> list) {
- System.out.println(list);
- }
- public static void main(String[] args) {
- // 创建列表ints、strings与stuff
- printList(ints);
- printList(strings);
- printList(stuff);
- }
读者或许还记得 List 接口声明的 containsAll 方法(例 A-3):
- boolean containsAll(Collection<?> c)
只有当前列表包含指定集合的所有元素时,containsAll 方法才返回 true。由于方法参数使用的是无界通配符,实现仅限于以下两类方法:
Collection接口定义的、不需要包含类型的方法Object类定义的方法
对于 containsAll 方法,上述条件完全符合。引用实现中的默认实现(AbstractCollection 类)通过 iterator 方法遍历参数,并调用 contains 方法检查其中的所有元素是否也在原始列表中。iterator 和 contains 方法定义在 Collection 接口中,而 equals 方法定义在 Object 类中。contains 实现委托给 Object 类的 equals 和 hashCode 方法,它们可能已经在包含的类型中被重写。就 containsAll 方法而言,它需要的所有方法都是可用的,因此无界通配符不会对该方法的使用造成影响。
问号是设置类型边界的利器,其用法相当多样化。
A.4.2 上界通配符
上界通配符(upper bounded wildcard)使用关键字 extends 来设置超类限制。例 A-8 定义了一个支持 int、long、double 甚至 BigDecimal 实例的数字列表。
即便采用接口(而不是类)作为上界,也可以使用关键字
extends,如List。例 A-8 具有上界的
List
- List<? extends Number> numbers = new ArrayList<>();
- // numbers.add(3); ➊
- // numbers.add(3.14159);
- // numbers.add(new BigDecimal("3"));
➊ 仍然无法添加值
上述代码看似不错,不过虽然可以使用上界通配符定义列表,但仍然无法为列表添加值。原因在于检索值时,编译器并不清楚列表的类型,只知道它继承了 Number。
尽管如此,我们可以定义一个传入 List 的方法参数,然后通过不同的列表类型调用方法,如例 A-9 所示。
例 A-9 上界的应用
- private static double sumList(List<? extends Number> list) {
- return list.stream()
- .mapToDouble(Number::doubleValue)
- .sum();
- }
- public static void main(String[] args) {
- List<Integer> ints = Arrays.asList(1, 2, 3, 4, 5);
- List<Double> doubles = Arrays.asList(1.0, 2.0, 3.0, 4.0, 5.0);
- List<BigDecimal> bigDecimals = Arrays.asList(
- new BigDecimal("1.0"),
- new BigDecimal("2.0"),
- new BigDecimal("3.0"),
- new BigDecimal("4.0"),
- new BigDecimal("5.0")
- );
- System.out.printf("ints sum is %s%n", sumList(ints));
- System.out.printf("doubles sum is %s%n", sumList(doubles));
- System.out.printf("big decimals sum is %s%n", sumList(bigDecimals));
- }
可以看到,使用相应的 double 值对 BigDecimal 实例求和,会抵消首先使用 BigDecimal 所带来的好处,但只有基本类型流 IntStream、LongStream 与 DoubleStream 包括 sum 方法。不过这也说明,可以使用 Number 的任何子类型(subtype)的列表来调用方法。由于 Number 定义了 doubleValue 方法,代码成功编译并运行。
从具有上界的列表中访问某个元素时,结果肯定可以被赋给上界类型的引用,如例 A-10 所示。
例 A-10 从上界引用中提取值
- private static double sumList(List<? extends Number> list) {
- Number num = list.get(0);
- // 其余代码与例A-9相同
- }
调用方法时,列表元素要么是 Number,要么是它的某个子类,因此 Number 引用总是正确的。
A.4.3 下界通配符
下界通配符(lower bounded wildcard)表示类的任何父类均满足条件,关键字 super 和通配符用于指定下界。以 List 为例,引用既可以代表 List,也可以代表 List。
我们通过上界指定变量必须符合的类型,以便方法实现能正常工作。对数字求和时,需要确保变量有一个定义在 Number 中的 doubleValue 方法。通过直接或重写的形式,Number 的所有子类也会包含 doubleValue 方法,这就是将输入类型指定为 List 的原因。
而在下界通配符中,我们从列表中取出项目,并添加到不同的集合。目标集合既可以是 List,也可以是 List,因为单个 Object 引用可以被赋给一个 Number。
接下来,我们将讨论一个经常被引用的示例。尽管它并不符合真正的 Java 8 习惯用法(稍后将解释原因),但的确阐释了下界通配符的概念。
如例 A-11 所示,numsUpTo 方法传入两个参数,一个是整数,另一个是列表。采用所有数字填充列表,直至达到第一个参数指定的数字。
例 A-11
numsUpTo方法用于填充给定列表
- public void numsUpTo(Integer num, List<? super Integer> output) {
- IntStream.rangeClosed(1, num)
- .forEach(output::add);
- }
numsUpTo 方法之所以不符合 Java 8 的习惯用法,是因为它使用提供的列表作为输出变量。这实际上会带来副作用,因此不鼓励使用。尽管如此,通过将第二个参数的类型设置为 List,提供的列表就可以是 List、List 甚至 List 类型,如例 A-12 所示。
例 A-12
numsUpTo方法的应用
- ArrayList<Integer> integerList = new ArrayList<>();
- ArrayList<Number> numberList = new ArrayList<>();
- ArrayList<Object> objectList = new ArrayList<>();
- numsUpTo(5, integerList);
- numsUpTo(5, numberList);
- numsUpTo(5, objectList);
所有返回的列表均包含数字 1 到 5。使用下界通配符意味着列表将用于存储整数,但我们可以在任何超类型(supertype)的列表中使用引用。
在上界列表中,我们从列表中提取并使用值;在下界列表中,我们为列表提供值。二者的综合应用构成了所谓的 PECS 原则。
A.4.4 PECS原则
PECS 是“Producer Extends, Consumer Super”的缩写,这是 Joshua Bloch 在 Effective Java 一书 3 中引入的一个略显奇怪的术语,但有助于理解泛型的用法。换言之,参数化类型代表生产者(producer)则使用 extends,代表消费者(consumer)则使用 super。如果参数同时代表生产者和消费者则无须使用通配符,因为满足这两项要求的唯一类型就是显式类型(explicit type)自身。
3公认的经典 Java 教程,总结了 Java 程序设计中大量极具实用价值的规则,这些规则涵盖了开发中可能遇到的各种问题。——译者注
可以将 PECS 原则归纳如下:
- 仅从数据结构获取值时,使用
extends; - 仅向数据结构写入值时,使用
super; - 如果需要同时获取和写入值,使用显式类型。
对于本节讨论的某些概念,均有描述这些概念的正式术语,它们经常在 Scala 这样的语言中使用。
术语协变(covariance)表示可以使用比原始指定的派生类型更大的类型。在 Java 中,由于 String[] 是 Object[] 的子类型,数组是协变的;除非使用关键字 extends 和通配符,否则集合不是协变的。
术语逆变(contravariance)表示可以使用比原始指定的派生类型更小的类型。在 Java 中,通过关键字 super 和通配符引入逆变。
术语不变性(invariance)表示只能使用原始指定的类型。除非使用 extends 或 super,否则 Java 中的所有参数化类型都具有不变性。换言之,如果某个方法要求 List,就必须提供 List,而不能提供 List 或 List4。
4协变、逆变与不变性的定义如下。如果 X 和 Y 表示类型,≤表示子类型关系,f(?) 表示类型转换,那么:当 X ≤ Y 时,f(X) ≤ f(Y) 成立,则称 f(?) 具有协变性;当 X ≤ Y 时,f(Y) ≤ f(X) 成立,则称 f(?) 具有逆变性;如果上述两种关系均不成立,则称 f(?) 具有不变性。——译者注
PECS 是对形式规则(formal rule)的一种重述,即类型构造函数在输入类型中是逆变的,在输出类型中是协变的。某些情况下,也可以将 PECS 原则表述为“读取时使用 extends,写入时使用 super”(be liberal in what you accept and conservative in what you produce)。
A.4.5 多重边界
在讨论 Java 8 API 中的示例之前,我们先来介绍多重边界(multiple bound)。类型参数可以有多重边界,边界之间通过“&”符号隔开:
- T extends Runnable & AutoCloseable
接口边界的数量并无限制,但只能有一个类边界。如果采用某个类作为边界,它必须在所有边界中居于首位。
即便采用接口(而不是类)作为上界,也可以使用关键字 