泛型与 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 简单的
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
可以是 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
接口的实际实现。它可能是 HashMap
8,但我们不得而知,因为结果用作 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。