可变缩减缩减获取一个值序列并将它缩减为单个值,比如数列的和或它的最大值。但是有时您不想要单个汇总值;您想将结果组织为类似 List 或 Map 的数据结构,或者将它缩减为多个汇总值。在这种情况下,您应该使用缩减 的可变类似方法,也称为收集。
考虑将元素累积到一个 List 中的简单情况。使用累加器反模式,您可以这样编写它:
1
2
3
| ArrayList<String> list = new ArrayList<>();
for (Person p : people)
list.add(p.toString());
|
当累加器变量是一个简单值时,缩减是累加的更好替代方法,与此类似,在累加器结果是更复杂的数据结构时,也有一种更好的替代方法。缩减的构建块是一个身份值和一种将两个值组合成新值的途径;可变缩减的类似方法包括:
- 一种生成空结果容器的途径
- 一种将新元素合并到结果容器中的途径
- 一种合并两个结果容器的途径
这些构建块可以轻松地表达为函数。这些函数中的第 3 个支持并行执行可变缩减:您可以对数据集进行分区,为每一部分生成一个中间累加结果,然后合并中间结果。Streams 库有一个 collect() 方法,它接受以下 3 个函数:
1
2
3
| <R> collect(Supplier<R> resultSupplier,
BiConsumer<R, T> accumulator,
BiConsumer<R, R> combiner)
|
在前一节中,您看到了一个使用缩减来计算字符串串联的示例。该代码会生成正确的结果,但是,因为 Java 中的字符串是不可变的,而且串联要求复制整个字符串,所以它还有 O(n2) 运行时(一些字符串将复制多次)。您可以通过将结果收集到 StringBuilder 中,更高效地表达字符串串联:
1
2
3
4
| StringBuilder concat = strings.stream()
.collect(() -> new StringBuilder(),
(sb, s) -> sb.append(s),
(sb, sb2) -> sb.append(sb2));
|
此方法使用 StringBuilder 作为结果容器。传递给 collect() 的 3 个函数使用默认构造函数创建了一个空容器,append(String) 方法将一个元素添加到容器中,append(StringBuilder) 方法将一个容器合并到另一个容器中。使用方法引用可能可以比拉姆达表达式更好地表达此代码:
1
2
3
4
| StringBuilder concat = strings.stream()
.collect(StringBuilder::new,
StringBuilder::append,
StringBuilder::append);
|
类似地,要将一个流收集到一个 HashSet 中,您可以这样做:
1
2
3
4
| Set<String> uniqueStrings = strings.stream()
.collect(HashSet::new,
HashSet::add,
HashSet::addAll);
|
在这个版本中,结果容器是一个 HashSet 而不是 StringBuilder,但方法是一样的:使用默认构造函数创建一个新的结果容器,使用 add() 方法将一个新元素合并到结果集中,使用 addAll() 方法合并两个结果集。很容易看到如何将此代码调整为其他任何类型的集合。
您可能会想,因为使用了可变结果容器(StringBuilder 或 HashSet),所以这也是累加器反模式的一个例子。但其实不然。累加器反模式在这种情况下采用的类似方法是:
1
2
| Set<String> set = new HashSet<>();
strings.stream().forEach(s -> set.add(s));
|
“可将收集器组合到一起来形成更复杂的聚合。”
就像只要组合函数是结合性的,且没有相互干扰的副作用,就可以安全地并行化缩减一样,如果满足一些简单的一致性要求(在 collect() 的规范中列出),就可以安全地并行化使用了 Stream.collect() 的可变缩减。关键区别在于,对于 forEach() 版本,多个线程会同时尝试访问一个结果容器,而对于并行 collect(),每个线程拥有自己的本地结果容器,会在以后合并其中的结果。
收集器传递给 collect() 的 3 个函数(创建、填充和合并结果容器)之间的关系非常重要,所以有必要提供它自己的抽象 Collector 和 collect() 的相应简化版本。字符串串联示例可重写为:
1
| String concat = strings.stream().collect(Collectors.joining());
|
收集到结果集的示例可重写为:
1
| Set<String> uniqueStrings = strings.stream().collect(Collectors.toSet());
|
Collectors 类包含许多常见聚合操作的因素,比如累加到集合中、字符串串联、缩减和其他汇总计算,以及创建汇总表(通过 groupingBy())。表 1 包含部分内置收集器的列表,而且如果它们不够用,编写您自己的收集器也很容易(请参阅 “” 部分)。
表 1. 内置收集器收集器行为toList() 将元素收集到一个 List 中。toSet() 将元素收集到一个 Set 中。toCollection(Supplier<Collection>)将元素收集到一个特定类型的 Collection 中。toMap(Function<T, K>, Function<T, V>)将元素收集到一个 Map 中,依据提供的映射函数将元素转换为键值。summingInt(ToIntFunction<T>)计算将提供的 int 值映射函数应用于每个元素(以及 long 和 double 版本)的结果的总和。summarizingInt(ToIntFunction<T>)计算将提供的 int 值映射函数应用于每个元素(以及 long 和 double 版本)的结果的 sum、min、max、count 和 average。reducing() 向元素应用缩减(通常用作下游收集器,比如用于 groupingBy)(各种版本)。 partitioningBy(Predicate<T>)将元素分为两组:为其保留了提供的预期的组和未保留预期的组。partitioningBy(Predicate<T>, Collector)将元素分区,使用指定的下游收集器处理每个分区。groupingBy(Function<T,U>) 将元素分组到一个 Map 中,其中的键是所提供的应用于流元素的函数,值是共享该键的元素列表。groupingBy(Function<T,U>, Collector)将元素分组,使用指定的下游收集器来处理与每个组有关联的值。minBy(BinaryOperator<T>) 计算元素的最小值(与 maxBy() 相同)。mapping(Function<T,U>, Collector)将提供的映射函数应用于每个元素,并使用指定的下游收集器(通常用作下游收集器本身,比如用于 groupingBy)进行处理。joining() 假设元素为 String 类型,将这些元素联结到一个字符串中(或许使用分隔符、前缀和后缀)。counting() 计算元素数量。(通常用作下游收集器。)
将收集器函数分组到 Collector 抽象中在语法上更简单,但实际收益来自您开始将收集器组合在一起时,比如您想要创建复杂的汇总结果(比如 groupingBy() 收集器创建的摘要)的时候,该收集器依据来自元素的一个键将元素收集到 Map 中。例如,要创建超过 1000 美元的交易的 Map,可以使用卖家作为键:
1
2
3
4
| Map<Seller, List<Txn>> bigTxnsBySeller =
txns.stream()
.filter(t -> t.getAmount() > 1000)
.collect(groupingBy(Txn::getSeller));
|
但是,假设您不想要每个卖家的交易 List,而想要来自每个卖家的最大交易。您仍希望使用卖家作为结果的键,但您希望进一步处理与卖家关联的交易,以便将它缩减为最大的交易。可以使用 groupingBy() 的替代版本,无需将每个键的元素收集到列表中,而是将它们提供给另一个收集器(downstream 收集器)。对于下游收集器,您可以选择 maxBy() 等缩减方法:
1
2
3
4
| Map<Seller, Txn> biggestTxnBySeller =
txns.stream()
.collect(groupingBy(Txn::getSeller,
maxBy(comparing(Txn::getAmount))));
|
在这里,您将交易分组到以卖家作为键的映射中,但该映射的值是使用 maxBy() 收集器收集该卖家的所有销售的结果。如果您不想要该卖家的最大交易,而想要总和,可以使用 summingInt() 收集器:
1
2
3
4
| Map<Seller, Integer> salesBySeller =
txns.stream()
.collect(groupingBy(Txn::getSeller,
summingInt(Txn::getAmount)));
|
要获得多级汇总结果,比如每个区域和卖家的销售,可以使用另一个 groupingBy 收集器作为下游收集器:
1
2
3
4
5
| Map<Region, Map<Seller, Integer>> salesByRegionAndSeller =
txns.stream()
.collect(groupingBy(Txn::getRegion,
groupingBy(Txn::getSeller,
summingInt(Txn::getAmount))));
|
举一个不同领域的例子:要计算一个文档中的词频直方图,可以使用 BufferedReader.lines() 将文档拆分为行,使用 Pattern.splitAsStream() 将它分解为一个单词流,然后使用 collect() 和 groupingBy() 创建一个 Map,后者的键是单词,值是这些单词的数量,如清单 3 所示。
清单 3. 使用 Streams 计算单词数量直方图1
2
3
4
5
6
| Pattern whitespace = Pattern.compile("\\s+");
Map<String, Integer> wordFrequencies =
reader.lines()
.flatMap(s -> whitespace.splitAsStream())
.collect(groupingBy(String::toLowerCase),
Collectors.counting());
|
|