泛型与 Java 8

泛型与 Java 8,Java 1.5 引入了泛型(generics)的概念。遗憾的是,大部分 Java 开发人员对于泛型的了解仅停留在完成工作所需的层面上。随着 Java 8 的兴起,Javadoc 中出现了不少使用泛型的方法签名。

泛型与 Java 8 – 背景

Java 1.5 引入了泛型(generics)的概念。遗憾的是,大部分 Java 开发人员对于泛型的了解仅停留在完成工作所需的层面上。随着 Java 8 的兴起,Javadoc 中出现了不少使用泛型的方法签名。以 java.util.Map.Entry 接口的 comparingByKey 方法为例:

static <K extends Comparable<? super K>,V> Comparator<Map.Entry<K,V>>
    comparingByKey()

以及 java.util.Comparator 接口的 comparing 方法:

static <T,U extends Comparable<? super U>> Comparator<T> comparing(
    Function<? super T,? extends U> keyExtractor)

甚至 java.util.stream.Collectors 类的 groupingBy 方法:

static <T,K,D,A,M extends Map<K, D>> Collector<T,?,M> groupingBy(
    Function<? super T,? extends K> classifier,  Supplier<M> mapFactory,
    Collector<? super T,A,D> downstream)

显然,对泛型仅有最低限度的了解是远远不够的。本附录旨在分析这些签名的结构,以帮助读者在开发中更有效地应用 API。

泛型与 Java 8 – 众所周知的事实

在使用 ListSet 这样的集合时,可以将元素的类名置于尖括号中,以声明所包含元素的类型:

List<String> strings = new ArrayList<String>();
Set<Employee> employees = new HashSet<Employee>();

通过在之后的示例代码中引入钻石运算符(diamond operator),Java 7 能在一定程度上简化语法。由于等号左侧的引用已经声明了集合以及所包含的类型(如 List<String>List<Integer>),等号右侧的实例化无须再次声明。我们可以将其简写为 new ArrayList<>(),而不必将类型置于尖括号中。

声明集合的数据类型可以实现两个目的:

  • 能避免不慎将错误的类型置于集合中
  • 无须再将检索到的值强制转换为合适的类型

如例 A-1 所示,在声明 strings 变量之后,就只能向集合添加 String 实例,并在检索到某项时自动获得一个 String

例 A-1 简单的泛型示例

List<String> strings = new ArrayList<>();
strings.add("Hello");
strings.add("World");
// strings.add(new Date());    ➊
// Integer i = strings.get(0); ➊

for (String s : strings) {     ➋
    System.out.printf("%s has length %d%n", s, s.length());
}

❶ 无法编译
❷ for-each 循环了解所包含的数据类型为 String
对插入过程应用类型安全(type safety)很方便,但开发人员很少会犯这个错误。不过,如果不必首先强制转换就可以处理检索类型,能极大简化代码。1
1在整个职业生涯中,我从未不慎将错误的类型添加到列表中。不过即便只是考虑到糟糕的语法,去掉强制转换过程也是值得的。

另一个众所周知的事实是,无法为泛型集合添加基本数据类型(primitive type)。换言之,目前尚无法定义 List<int>List<double>2 幸运的是,Java 1.5 在引入泛型的同时也引入了自动装箱和拆箱。因此,如果希望在泛型类型(generic type)中储存基本数据类型,可以通过包装类(wrapper class)声明该类型,如例 A-2 所示。
2Java 10(Valhalla 项目)已提出将基本数据类型添加到集合中。

例 A-2 在泛型集合中使用基本数据类型

List<Integer> ints = new ArrayList<>();
ints.add(3); ints.add(1); ints.add(4);
ints.add(1); ints.add(9); ints.add(2);
System.out.println(ints);

for (int i : ints) {
    System.out.println(i);
}

可以看到,Java 在插入时将 int 值包装在 Integer 实例中,并在检索时从 Integer 实例中取出这些值。尽管装箱和拆箱的效率有待商榷,但代码确实很容易编写。
此外,Java 开发人员耳熟能详的一点是,如果一个类使用了泛型,那么类型本身采用尖括号中的大写字母表示。例如,Javadoc 对 java.util.List 接口的描述如下:

public interface List<E> extends Collection<E>

其中 E 是类型参数(type parameter),且接口中的方法使用相同的类型参数。例 A-3 显示了 List 接口声明的部分方法。

例 A-3 List 接口声明的部分方法

boolean add(E e)                           ➊
boolean addAll(Collection<? extends E> c)  ➋
void    clear()                            ➌
boolean contains(Object o)                 ➌
boolean containsAll(Collection<?> c)       ➍
E       get(int index)                     ➊

❶ 类型参数 E 用作参数或返回类型
有界通配符
❸ 与类型本身无关的方法
未知类型
可以看到,某些方法使用声明的泛型类型 E 作为参数或返回类型,某些方法(特别是 clearcontains)完全不使用类型,还有部分方法使用问号作为通配符。
请注意,在非泛型类中声明泛型方法是合法的。这种情况下,泛型参数被声明为方法签名的一部分。以工具类 java.util.Collections 为例,它定义了以下静态方法:

static <T>   List<T>    emptyList()
static <K,V> Map<K,V>   emptyMap()
static <T>   boolean    addAll(Collection<? super T> c, T... elements)
static <T extends Object & Comparable<? super T>>
    T min(Collection<? extends T> coll)

如上所示,emptyListaddAllmin 这三种方法声明了泛型参数 TemptyList 方法通过 T 来指定 List 中包含的类型,而 emptyMap 方法在泛型映射中使用 KV 来表示键的类与值的类。
addAll 方法声明了泛型类型 T,并使用 Collection<? super T> c 作为方法的第一个参数,T 类型的可变参数列表作为第二个参数。? super T 是一种有界通配符(bounded wildcard),稍后将对此做讨论。
min 方法可以看出泛型类型是如何提供安全性的,但其签名结构或许不那么一目了然。后面将详细讨论该方法的签名,目前不妨这样理解:T 是有界的,它既是 Object 的子类,又实现了 Comparable 接口,其中 Comparable 定义为 TT 的任何父类。min 方法的参数与 TT 的任何子类的 Collection 有关。
最后,通配符将众所周知的语法以我们不那么熟悉的形式表现出来。例如,某些语法看似继承,但实际上根本不是。

泛型与 Java 8 – 容易忽略的事实

许多开发人员或许惊讶于 ArrayList<String>ArrayList<Object> 并无实质性的关联。如例 A-4 所示,可以将 Object 的子类添加到 Object 集合中。

例 A-4 List<Object> 的应用

List<Object> objects = new ArrayList<Object>();
objects.add("Hello");
objects.add(LocalDate.now());
objects.add(3);
System.out.println(objects);

很好!由于 StringObject 的子类,可以将 String 引用赋给 Object 引用。读者可能认为,在声明字符串列表之后就能为其添加对象,但实际情况并非如此,如例 A-5 所示。

例 A-5 List<String> 与对象一起使用

List<String> strings = new ArrayList<>();
String s = "abc";
Object o = s;                          ➊
// strings.add(o);                     ➋

// List<Object> moreObjects = strings; ➌
// moreObjects.add(new Date());
// String s = moreObjects.get(0);      ➍

❶ 合法
❷ 不合法
❸ 同样不合法,但假设其合法
❹ 损坏的集合
由于 StringObject 的子类,我们可以将 String 引用赋给 Object 引用,但无法将 Object 引用添加到 List<String>。这似乎有些奇怪,原因在于 List<String> 并非 List<Object> 的子类。在声明类型时,可以添加的唯一实例就是所声明的类型,使用子类或超类实例均不合法。换言之,参数化类型(parameterized type)具有不变性(invariance)。
在本例中,从注释掉的语句不难看出为何 List<String> 不是 List<Object> 的子类。假设可以将 List<String> 赋给 List<Object>,那么通过对象引用列表就能将非字符串的内容添加到列表中。这样一来,采用字符串列表的原始引用检索时会导致强制转换异常,编译器将无法判断转换是否有效。
不过,如果定义了一个数字列表,应该就可以为列表添加整数、浮点数与双精度浮点数。为此,我们需要在类型边界(type bound)中使用通配符。

泛型与 Java 8 – 通配符与PECS

通配符是一种使用问号(?)的类型参数,可能存在(也可能不存在)上界或下界。

无界通配符

没有边界的类型参数很有用,不过也存在一定局限性。如例 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<E> 接口声明的 containsAll 方法(例 A-3):

boolean containsAll(Collection<?> c)

只有当前列表包含指定集合的所有元素时,containsAll 方法才返回 true。由于方法参数使用的是无界通配符,实现仅限于以下两类方法:

  • Collection 接口定义的、不需要包含类型的方法
  • Object 类定义的方法

对于 containsAll 方法,上述条件完全符合。引用实现中的默认实现(AbstractCollection 类)通过 iterator 方法遍历参数,并调用 contains 方法检查其中的所有元素是否也在原始列表中。iteratorcontains 方法定义在 Collection 接口中,而 equals 方法定义在 Object 类中。contains 实现委托给 Object 类的 equalshashCode 方法,它们可能已经在包含的类型中被重写。就 containsAll 方法而言,它需要的所有方法都是可用的,因此无界通配符不会对该方法的使用造成影响。
问号是设置类型边界的利器,其用法相当多样化。

上界通配符

上界通配符(upper bounded wildcard)使用关键字 extends 来设置超类限制。例 A-8 定义了一个支持 intlongdouble 甚至 BigDecimal 实例的数字列表。

即便采用接口(而不是类)作为上界,也可以使用关键字 extends,如 List<? extends Comparable>
例 A-8 具有上界的 List

List<? extends Number> numbers = new ArrayList<>();
//        numbers.add(3);                    ➊
//        numbers.add(3.14159);
//        numbers.add(new BigDecimal("3"));

➊ 仍然无法添加值
上述代码看似不错,不过虽然可以使用上界通配符定义列表,但仍然无法为列表添加值。原因在于检索值时,编译器并不清楚列表的类型,只知道它继承了 Number
尽管如此,我们可以定义一个传入 List<? extends Number> 的方法参数,然后通过不同的列表类型调用方法,如例 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 所带来的好处,但只有基本类型流 IntStreamLongStreamDoubleStream 包括 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 引用总是正确的。

下界通配符

下界通配符(lower bounded wildcard)表示类的任何父类均满足条件,关键字 super 和通配符用于指定下界。以 List<? super Number> 为例,引用既可以代表 List<Number>,也可以代表 List<Object>
我们通过上界指定变量必须符合的类型,以便方法实现能正常工作。对数字求和时,需要确保变量有一个定义在 Number 中的 doubleValue 方法。通过直接或重写的形式,Number 的所有子类也会包含 doubleValue 方法,这就是将输入类型指定为 List<? extends Number> 的原因。
而在下界通配符中,我们从列表中取出项目,并添加到不同的集合。目标集合既可以是 List<Number>,也可以是 List<Object>,因为单个 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<? super Integer>,提供的列表就可以是 List<Integer>List<Number> 甚至 List<Object> 类型,如例 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 原则。

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)表示只能使用原始指定的类型。除非使用 extendssuper,否则 Java 中的所有参数化类型都具有不变性。换言之,如果某个方法要求 List<Employee>,就必须提供 List<Employee>,而不能提供 List<Object>List<Salaried>4
4协变、逆变与不变性的定义如下。如果 XY 表示类型,≤表示子类型关系,f(?) 表示类型转换,那么:当 XY 时,f(X) ≤ f(Y) 成立,则称 f(?) 具有协变性;当 XY 时,f(Y) ≤ f(X) 成立,则称 f(?) 具有逆变性;如果上述两种关系均不成立,则称 f(?) 具有不变性。——译者注

PECS 是对形式规则(formal rule)的一种重述,即类型构造函数在输入类型中是逆变的,在输出类型中是协变的。某些情况下,也可以将 PECS 原则表述为“读取时使用 extends,写入时使用 super”(be liberal in what you accept and conservative in what you produce)。

多重边界

在讨论 Java 8 API 中的示例之前,我们先来介绍多重边界(multiple bound)。类型参数可以有多重边界,边界之间通过“&”符号隔开:

T extends Runnable & AutoCloseable

接口边界的数量并无限制,但只能有一个类边界。如果采用某个类作为边界,它必须在所有边界中居于首位。

泛型与 Java 8 – Java 8 API示例

接下来,我们将讨论 Java 8 引入的一些新方法。

Stream.max方法

java.util.stream.Stream 接口中,max 方法的签名如下:

Optional<T> max(Comparator<? super T> comparator)

注意 Comparator 中使用的下界通配符。通过应用所提供的 Comparatormax 方法将返回流中最大的元素。由于流在为空时可能没有返回值,max 方法的返回类型为 Optional<T>。如果找到最大值,max 方法将其包装在 Optional,否则返回空 Optional
为简单起见,考虑例 A-13 显示的 Employee POJO。

例 A-13 简单的 Employee POJO

public class Employee {
    private int id;
    private String name;

    public Employee(int id, String name) {
        this.id = id;
        this.name = name;
    }

    // 其他方法
}

例 A-14 创建了一个员工集合并转换为 Stream,然后通过 max 方法查找具有最大 id 和最大 name(按字母顺序排序 5)的员工。实现采用匿名内部类来强调 Comparator 可以是 EmployeeObject 类型。
5严格来说是按字典序(lexicographical order)排序,即大写字母位于小写字母之前。

例 A-14 查找最大的 Employee

List<Employee> employees = Arrays.asList(
    new Employee(1, "Seth Curry"),
    new Employee(2, "Kevin Durant"),
    new Employee(3, "Draymond Green"),
    new Employee(4, "Klay Thompson"));

Employee maxId = employees.stream()
    .max(new Comparator<Employee>() {           ➊
        @Override
        public int compare(Employee e1, Employee e2) {
            return e1.getId() - e2.getId();
        }
    }).orElse(Employee.DEFAULT_EMPLOYEE);

Employee maxName = employees.stream()
    .max(new Comparator<Object>() {          ➋
        @Override
        public int compare(Object o1, Object o2) {
            return o1.toString().compareTo(o2.toString());
        }
    }).orElse(Employee.DEFAULT_EMPLOYEE);

System.out.println(maxId);   ➌
System.out.println(maxName); ➍

Comparator<Employee> 的匿名内部类实现
Comparator<Object> 的匿名内部类实现
❸ Klay Thompson(最大 ID 为 4)
❹ Seth Curry(最大姓名以字母 S 开头)
我们可以利用 Employee 类中的方法编写 Comparator,不过仅使用 Object 类定义的方法(如 toString)同样可行。由于 max 方法的定义中使用了通配符 superComparator<? super T> comparator)),Comparator 既可以是 Employee,也可以是 Object
然而,没有人会这样编写代码。符合 Java 8 习惯用法的实现如例 A-15 所示。

例 A-15 查找最大的 Employee(Java 8 习惯用法)

import static java.util.Comparator.comparing;
import static java.util.Comparator.comparingInt;

// 创建员工列表

Employee maxId = employees.stream()
    .max(comparingInt(Employee::getId))
    .orElse(Employee.DEFAULT_EMPLOYEE);

Employee maxName = employees.stream()
    .max(comparing(Object::toString))
    .orElse(Employee.DEFAULT_EMPLOYEE);

System.out.println(maxId);
System.out.println(maxName);

上述代码显然更为简洁,但它不像匿名内部类那样强调有界通配符。

Stream.map方法

Stream 接口还定义了一个名为 map 的方法,它传入 Function,包括两个参数,均使用通配符:

<R> Stream<R> map(Function<? super T,? extends R> mapper)

map 方法对流中的每个元素(T 类型)应用 mapper 函数,将其转换为 R 类型 6 的一个实例。因此,map 方法的返回类型为 Stream<R>
6Java API 使用 T 表示单个输入变量,或 TU 表示两个输入变量,以此类推。API 通常使用 R 表示返回变量。而对于映射,API 使用 K 表示键,V 表示值。

由于 Stream 被定义为具有类型参数 T 的泛型类(generic class),map 方法不必在签名中再定义变量 T,但需要使用另一个类型参数 R,以便在返回类型之前出现在签名中。如果 Stream 不是泛型类,map 方法将声明两个参数 TR
java.util.function.Function 接口定义了两个类型参数,第一个(输入参数)是从 Stream 消费的类型,第二个(输出参数)是函数产生的对象类型。通配符意味着在指定参数时,输入参数必须与 Stream 的类型相同或更高,而输出类型可以是返回流类型的任何子类型。

从 PECS 原则的角度来看,Function 接口的定义或许令人困惑,因为类型是反向的。不过只要记住 Function<T,R> 消费 T 并产生 R,就能理解为何 super 后跟 T,而 extends 后跟 R

map 方法的应用如例 A-16 所示。

例 A-16 将 List<Employee> 映射到 List<String>

List<String> names = employees.stream()
    .map(Employee::getName)
    .collect(toList());

List<String> strings = employees.stream()
    .map(Object::toString)
    .collect(toList());

可以看到,Function 声明了两个泛型变量,分别用于输入和输出。在第一个代码段中,方法引用 Employee::getName 使用流中的 Employee 作为输入,并返回 String 作为输出。
在第二个代码段中,由于通配符 super 的缘故,程序将输入变量作为 Object(而非 Employee)的方法处理。输出类型原则上可以是包含 String 子类的 List,但由于 String 被声明为 final,不存在任何子类。
接下来,我们讨论 Java 8 引入的部分方法签名。

Comparator.comparing方法

例 A-15 使用了 Comparator 接口定义的静态方法 comparingComparator 接口从 Java 1.0 起就已存在,开发人员或许惊讶于该接口目前包含的方法是如此之多。Java 8 将函数式接口定义为包含单一抽象方法(single abstract method)的接口。Comparator 属于函数式接口,所包含的单一抽象方法为 compare,它传入两个均为泛型类型 T 的参数。根据第一个参数小于、等于或大于第二个参数,compare 方法将分别返回负整数、0 或正整数 7
7有关比较器的讨论请参见范例利用比较器实现排序

comparing 方法的签名如下:

static <T,U extends Comparable<? super U>> Comparator<T> comparing(
    Function<? super T,? extends U> keyExtractor)

观察 comparing 方法的参数可以看到,其名称为 keyExtractor,类型为 Function。与之前类似,Function 定义了两个泛型类型,分别用于输入和输出。输入的下界由输入类型 T 指定,而输出的上界由输出类型 U 指定。参数名在这里作为键使用:函数采用某种方法提取出需要排序的属性,comparing 方法通过返回 Comparator 来完成这项工作。
我们希望使用给定属性 U 对流排序,因此 U 必须实现 Comparable。换言之,在声明 U 时,U 必须要继承 Comparable。当然,Comparable 本身是一种类型化接口(typed interface),其类型通常为 U,但也可以是 U 的任何超类。
comparing 方法最终返回的是 Comparator<T>,然后 Stream 接口定义的其他方法使用 Comparator<T> 对流排序,结果流与原始流的类型相同。
comparing 方法的用法请参见例 A-15。

Map.Entry.comparingByKeyMap.Entry.comparingByValue 方法

最后,我们编写程序将员工添加到 Map(键为员工 ID,值为员工姓名),并根据 ID 或姓名进行排序,然后打印结果。
第一步是将员工添加到 Map。借由静态方法 Collectors.toMap,只需一行代码就能实现:

// 使用ID作为键,将员工添加到映射
Map<Integer, Employee> employeeMap = employees.stream()
    .collect(Collectors.toMap(Employee::getId, Function.identity()));

toMap 方法的签名如下:

static <T, K, U> Collector<T, ?, Map<K, U>> toMap(
    Function<? super T,? extends K> keyMapper,
    Function<? super T,? extends U> valueMapper)

Collectors 是一种工具类(仅包含静态方法),提供 Collector 接口的实现。
toMap 方法的签名可以看到,它传入两个函数作为参数,一个用于生成键,另一个用于在输出映射中生成值。toMap 方法的返回类型为 Collector,它定义了三种泛型参数。
根据 Javadoc 的描述,Collector 接口的签名如下:

public interface Collector<T,A,R>

三种泛型类型的定义如下。

  • T:归约操作的输入元素类型;
  • A:归约操作的可变累加类型(通常隐藏为实现细节);
  • R:归约操作的结果。

Employee::getId 相当于 toMap 方法签名中的 keyMapper。换言之,TInteger;而结果 RMap 接口的实现,它使用 Integer 替换 KEmployee 替换 U
有意思的是,Collector 接口定义中的变量 AMap 接口的实际实现。它可能是 HashMap8,但我们不得而知,因为结果用作 toMap 方法的参数,无法被观察到。不过在 Collector 中,类型使用无界通配符 ?,这意味着类型在内部要么仅使用 Object 类中的方法,要么使用 Map 接口中不特定于类型的方法。实际上,在调用 keyMappervalueMapper 函数后,类型仅使用 Map 接口新增的默认方法 merge
8在引用实现中的确是 HashMap

为实现排序,Java 8 为 Map.Entry 接口引入了静态方法 comparingByKeycomparingByValue。如例 A-17 所示,程序根据键对映射元素排序,然后打印结果。

例 A-17 根据键对映射元素排序并打印结果

Map<Integer, Employee> employeeMap = employees.stream()
    .collect(Collectors.toMap(Employee::getId, Function.identity())); ➊

System.out.println("Sorted by key:");
employeeMap.entrySet().stream()
    .sorted(Map.Entry.comparingByKey())
    .forEach(entry -> {
        System.out.println(entry.getKey() + ": " + entry.getValue()); ➋
    });

❶ 使用 ID 作为键,将员工添加到 Map
❷ 根据 ID 对员工排序,然后打印结果
comparingByKey 方法的签名如下:

static <K extends Comparable<? super K>,V>
    Comparator<Map.Entry<K,V>> comparingByKey()

comparingByKey 方法不传入任何参数,它返回一个比较 Map.Entry 实例的 Comparator。由于我们根据键比较员工姓名,键 K 的声明泛型类型必须是 Comparable 的子类型,才能执行实际的比较操作。当然,Comparable 本身定义了泛型类型 KK 的某种父类型,这意味着 compareTo 方法可以使用 K 类(或更高)的属性。
根据键进行排序的结果如下:

Sorted by key:
1: Seth Curry
2: Kevin Durant
3: Draymond Green
4: Klay Thompson

根据值进行排序则有些复杂。如果不了解泛型类型的相关知识,就很难理解错误的成因。 comparingByValue 方法的签名如下:

static <K,V extends Comparable<? super V>> Comparator<Map.Entry<K,V>>
    comparingByValue()

comparingByKey 方法不同,在 comparingByValue 方法中,V 必须是 Comparable 的子类型。
根据值排序时,很容易写出下面这样的代码:

// 根据员工姓名排序,然后打印结果(无法编译)
employeeMap.entrySet().stream()
    .sorted(Map.Entry.comparingByValue())
    .forEach(entry -> {
        System.out.println(entry.getKey() + ": " + entry.getValue());
    });

不过代码无法编译,程序会提示错误:

Java: incompatible types: inference variable V has incompatible bounds
    equality constraints: generics.Employee
    upper bounds: java.lang.Comparable<? super V>

原因在于映射中的值是 Employee 的实例,但 Employee 并未实现 Comparable。好在 comparingByValue 方法还包括一种重载形式:

static <K,V> Comparator<Map.Entry<K,V>> comparingByValue(
    Comparator<? super V> cmp)

comparingByValue 方法传入 Comparator 作为参数,并返回一个新的 Comparator,它根据值比较各个 Map.Entry 元素。对映射值排序的正确方式如例 A-18 所示。

例 A-18 根据值对映射元素排序并打印结果

// 根据员工姓名排序,然后打印结果
System.out.println("Sorted by name:");
employeeMap.entrySet().stream()
    .sorted(Map.Entry.comparingByValue(Comparator.comparing(Employee::getName)))
    .forEach(entry -> {
        System.out.println(entry.getKey() + ": " + entry.getValue());
    });

通过为 comparing 方法提供方法引用 Employee::getName,就能实现按员工姓名的自然顺序排序:

Sorted by name:
3: Draymond Green
2: Kevin Durant
4: Klay Thompson
1: Seth Curry

希望上述示例能提供足够的背景知识,以免读者在阅读和使用 Java API 时对泛型感到困惑。

类型擦除

使用 Java 这样的语言开发时,如何保持长久以来的向后兼容性让人颇费脑筋,开发团队为此做了不少努力。以泛型为例,与泛型有关的信息将在编译阶段被删除,从而不会为参数化类型创建新的类,避免了可能出现的运行时错误。这称为类型擦除(type erasure)。
由于所有操作均在后台完成,开发人员真正需要了解的是在编译时:

  • 有界类型参数被替换为参数边界;
  • 无界类型参数被替换为 Object
  • 在需要时插入类型强制转换;
  • 生成桥接方法(bridge method)以保持多态(polymorphism)。

对类型而言,结果相当简单。Map 接口定义了两种泛型类型,其中 K 代表键,V 代表值。在实例化 Map<Integer,Employee> 时,编译器分别用 IntegerEmployee 替换 KV
Map.Entry.comparingByKey 方法的签名可以看到,键被声明为 K extends Comparable,这使得类中所有出现的 K 都会被替换为 Comparable
Function 接口定义了两种泛型类型 TR,所包含的单一抽象方法为:

R apply(T t)

Stream.map 方法的签名可以看到,其边界为 Function<? super T,? extends R>。观察例 A-16 中的 map 方法:

List<String> names = employees.stream()
    .map(Employee::getName)
    .collect(Collectors.toList());

Function 采用 Employee 替换 T(因为这是一个由员工构成的流),采用 String 替换 R(因为 getName 的返回类型为 String)。
关于类型擦除的讨论大致如此,但某些极端情况并未考虑在内。感兴趣的读者可以参考 Java 官方教程(Java Tutorials),不过类型擦除或许是所有技术中最简单的概念。

泛型与 Java 8 – 小结

Java 1.5 引入的泛型概念目前依然存在,不过随着 Java 8 的兴起,相应的方法签名变得更为复杂。在 Java 中,大部分函数式接口同时使用泛型类型和有界通配符以强化类型安全。希望读者能通过本附录了解泛型的基础知识,从而在实际开发中正确应用 API。

赞(1)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

Java 实例