泛型与 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 – 众所周知的事实
在使用 List 或 Set 这样的集合时,可以将元素的类名置于尖括号中,以声明所包含元素的类型:
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 作为参数或返回类型,某些方法(特别是 clear 和 contains)完全不使用类型,还有部分方法使用问号作为通配符。
请注意,在非泛型类中声明泛型方法是合法的。这种情况下,泛型参数被声明为方法签名的一部分。以工具类 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)
如上所示,emptyList、addAll、min 这三种方法声明了泛型参数 T。emptyList 方法通过 T 来指定 List 中包含的类型,而 emptyMap 方法在泛型映射中使用 K 和 V 来表示键的类与值的类。
addAll 方法声明了泛型类型 T,并使用 Collection<? super T> c 作为方法的第一个参数,T 类型的可变参数列表作为第二个参数。? super T 是一种有界通配符(bounded wildcard),稍后将对此做讨论。
从 min 方法可以看出泛型类型是如何提供安全性的,但其签名结构或许不那么一目了然。后面将详细讨论该方法的签名,目前不妨这样理解:T 是有界的,它既是 Object 的子类,又实现了 Comparable 接口,其中 Comparable 定义为 T 或 T 的任何父类。min 方法的参数与 T 或 T 的任何子类的 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);
很好!由于 String 是 Object 的子类,可以将 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); ➍
❶ 合法
❷ 不合法
❸ 同样不合法,但假设其合法
❹ 损坏的集合
由于 String 是 Object 的子类,我们可以将 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 方法检查其中的所有元素是否也在原始列表中。iterator 和 contains 方法定义在 Collection 接口中,而 equals 方法定义在 Object 类中。contains 实现委托给 Object 类的 equals 和 hashCode 方法,它们可能已经在包含的类型中被重写。就 containsAll 方法而言,它需要的所有方法都是可用的,因此无界通配符不会对该方法的使用造成影响。
问号是设置类型边界的利器,其用法相当多样化。
上界通配符
上界通配符(upper bounded wildcard)使用关键字 extends 来设置超类限制。例 A-8 定义了一个支持 int、long、double 甚至 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 所带来的好处,但只有基本类型流 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 引用总是正确的。
下界通配符
下界通配符(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)表示只能使用原始指定的类型。除非使用 extends 或 super,否则 Java 中的所有参数化类型都具有不变性。换言之,如果某个方法要求 List<Employee>,就必须提供 List<Employee>,而不能提供 List<Object> 或 List<Salaried>4。
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)。
多重边界
在讨论 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 中使用的下界通配符。通过应用所提供的 Comparator,max 方法将返回流中最大的元素。由于流在为空时可能没有返回值,max 方法的返回类型为 Optional<T>。如果找到最大值,max 方法将其包装在 Optional,否则返回空 Optional。
为简单起见,考虑例 A-13 显示的 Employee POJO。
例 A-13 简单的
EmployeePOJO
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 可以是 Employee 或 Object 类型。
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 方法的定义中使用了通配符 super(Comparator<? 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 表示单个输入变量,或 T 和 U 表示两个输入变量,以此类推。API 通常使用 R 表示返回变量。而对于映射,API 使用 K 表示键,V 表示值。
由于 Stream 被定义为具有类型参数 T 的泛型类(generic class),map 方法不必在签名中再定义变量 T,但需要使用另一个类型参数 R,以便在返回类型之前出现在签名中。如果 Stream 不是泛型类,map 方法将声明两个参数 T 和 R。
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 接口定义的静态方法 comparing。Comparator 接口从 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.comparingByKey与Map.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。换言之,T 是 Integer;而结果 R 是 Map 接口的实现,它使用 Integer 替换 K,Employee 替换 U。
有意思的是,Collector 接口定义中的变量 A 是 Map 接口的实际实现。它可能是 HashMap8,但我们不得而知,因为结果用作 toMap 方法的参数,无法被观察到。不过在 Collector 中,类型使用无界通配符 ?,这意味着类型在内部要么仅使用 Object 类中的方法,要么使用 Map 接口中不特定于类型的方法。实际上,在调用 keyMapper 和 valueMapper 函数后,类型仅使用 Map 接口新增的默认方法 merge。
8在引用实现中的确是 HashMap。
为实现排序,Java 8 为 Map.Entry 接口引入了静态方法 comparingByKey 和 comparingByValue。如例 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 本身定义了泛型类型 K 或 K 的某种父类型,这意味着 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> 时,编译器分别用 Integer 和 Employee 替换 K 和 V。
从 Map.Entry.comparingByKey 方法的签名可以看到,键被声明为 K extends Comparable,这使得类中所有出现的 K 都会被替换为 Comparable。
Function 接口定义了两种泛型类型 T 和 R,所包含的单一抽象方法为:
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。
极客教程