使用收集器作为终止操作
使用收集器收集流元素
您已经使用了一种非常有用的模式来收集流处理的元素,该模式存储在 List
中:collect(Collectors.toList())
。这个 collect()
方法是 Stream
接口中定义的终止方法,它接受类型为 Collector
的对象作为参数。这个 Collector
接口定义了自己的 API,您可以使用它来创建任何类型的内存结构来存储流处理的数据。收集可以在 Collection
或 Map
的任何实例中进行,它可以用于创建字符字符串,并且您可以创建 Collector
接口的自定义实例来将您自己的结构添加到此列表中。
您将使用的大多数收集器都可以使用 Collectors
工厂类的工厂方法之一来创建。这就是您在编写 Collectors.toList()
或 Collectors.toSet()
时所做的。使用这些方法创建的一些收集器可以组合在一起,从而产生更多收集器。本教程将涵盖所有这些要点。
如果您在该工厂类中找不到所需的内容,那么您可以选择通过实现 Collector
接口来创建自己的收集器。本教程还将介绍如何实现此接口。
收集器 API 在 Stream
接口和专门的数字流中处理方式不同:IntStream
、LongStream
和 DoubleStream
。 Stream
接口有两个 collect()
方法的重载,而数字流只有一个。缺少的那个正是接受收集器对象作为参数的那个。因此,您不能将收集器对象与专门的数字流一起使用。
在集合中收集
Collectors
工厂类为您提供了三种方法,用于将流的元素收集到 Collection
接口的实例中。
toList()
将它们收集到List
对象中。toSet()
将它们收集到Set
对象中。- 如果您需要任何其他
Collection
实现,您可以使用toCollection(supplier)
,其中supplier
参数将用于创建您需要的Collection
对象。如果您需要将数据收集到LinkedList
的实例中,则应使用此方法。
您的代码不应依赖于这些方法当前返回的 List
或 Set
的确切实现,因为它不是规范的一部分。
您还可以使用两个方法 toUnmodifiableList()
和 toUnmodifiableSet()
获取 List
和 Set
的不可变实现。
以下示例展示了这种模式的实际应用。首先,让我们在普通的 List
实例中进行收集。
List<Integer> numbers =
IntStream.range(0, 10)
.boxed()
.collect(Collectors.toList());
System.out.println("numbers = " + numbers);
此代码使用 boxed()
中间方法从 IntStream
创建 Stream<Integer>
,该 IntStream
由 IntStream.range()
创建,方法是将该流的所有元素装箱。运行此代码将打印以下内容。
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
第二个示例使用 HashSet
创建一个仅包含偶数且没有重复项的集合。
Set<Integer> evenNumbers =
IntStream.range(0, 10)
.map(number -> number / 2)
.boxed()
.collect(Collectors.toSet());
System.out.println("evenNumbers = " + evenNumbers);
运行此代码将为您提供以下结果。
evenNumbers = [0, 1, 2, 3, 4]
最后一个示例使用 Supplier
对象来创建用于收集流元素的 LinkedList
实例。
LinkedList<Integer> linkedList =
IntStream.range(0, 10)
.boxed()
.collect(Collectors.toCollection(LinkedList::new));
System.out.println("linked listS = " + linkedList);
运行此代码将为您提供以下结果。
linked list = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
使用收集器进行计数
Collectors
工厂类为您提供了多种方法来创建收集器,这些收集器执行与普通终止方法相同的操作。 Collectors.counting()
工厂方法就是这种情况,它执行的操作与在流上调用 count()
相同。
值得注意的是,您可能想知道为什么同一个功能使用两种不同的模式实现了两次。这个问题将在下一节关于在映射中收集的章节中得到解答,您将在其中组合收集器以创建更多收集器。
目前,编写以下两行代码会导致相同的结果。
Collection<String> strings = List.of("one", "two", "three");
long count = strings.stream().count();
long countWithACollector = strings.stream().collect(Collectors.counting());
System.out.println("count = " + count);
System.out.println("countWithACollector = " + countWithACollector);
运行此代码将为您提供以下结果。
count = 3
countWithACollector = 3
在字符字符串中收集
Collectors
工厂类提供的另一个非常有用的收集器是 joining()
收集器。此收集器仅适用于字符字符串流,并将该流中的元素连接到单个字符串中。它有几个重载。
- 第一个接受一个分隔符作为参数。
- 第二个接受一个分隔符、一个前缀和一个后缀作为参数。
让我们看看这个收集器在实际中的应用。
String joined =
IntStream.range(0, 10)
.boxed()
.map(Object::toString)
.collect(Collectors.joining());
System.out.println("joined = " + joined);
运行这段代码会产生以下结果。
joined = 0123456789
您可以使用以下代码将分隔符添加到此字符串中。
String joined =
IntStream.range(0, 10)
.boxed()
.map(Object::toString)
.collect(Collectors.joining(", "));
System.out.println("joined = " + joined);
结果如下。
joined = 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
让我们看看最后一个重载在实际中的应用,它接受一个分隔符、一个前缀和一个后缀。
String joined =
IntStream.range(0, 10)
.boxed()
.map(Object::toString)
.collect(Collectors.joining(", ", "{"), "}");
System.out.println("joined = " + joined);
结果如下。
joined = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
请注意,此收集器可以正确处理流为空或仅处理单个元素的极端情况。
当您需要生成这种字符字符串时,此收集器非常方便。您可能很想使用它,即使您的数据最初不在集合中,或者只有几个元素。如果是这种情况,也许使用 String.join()
工厂类或 StringJoiner
对象会同样有效,而无需支付创建流的开销。
使用谓词对元素进行分区
Collector API 提供了三种模式来从流的元素创建映射。我们首先介绍的是使用 partitionningBy()
工厂方法创建的具有布尔键的映射。
流中的所有元素都将绑定到 true
或 false
布尔值。映射将存储绑定到每个值的元素列表。因此,如果将此收集器应用于 Stream
,它将生成一个具有以下类型的映射:Map<Boolean, List<T>>
。
通过使用谓词测试给定元素来决定是否应将其绑定到 true
或 false
,该谓词作为收集器的参数提供。
以下示例展示了此收集器在实际中的应用。
Collection<String> strings =
List.of("one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
"ten", "eleven", "twelve");
Map<Boolean, List<String>> map =
strings.stream()
.collect(Collectors.partitioningBy(s -> s.length() > 4));
map.forEach((key, value) -> System.out.println(key + " :: " + value));
运行这段代码会产生以下结果。
false :: [one, two, four, five, six, nine, ten]
true :: [three, seven, eight, eleven, twelve]
此工厂方法有一个重载,它接受一个收集器作为另一个参数。此收集器称为下游收集器。我们将在本教程的下一段中介绍这些下游收集器,届时我们将介绍 groupingBy()
收集器。
在映射中收集,按分组
我们介绍的第二个收集器非常重要,因为它允许您创建直方图。
将流的元素分组到映射中
您可以用来创建直方图的收集器是使用 Collectors.groupingBy()
方法创建的。此方法有几个重载。
收集器创建一个映射。通过将 Function
的实例应用于流的每个元素来计算一个键。此函数作为 groupingBy()
方法的参数提供。在 Collector API 中,它被称为分类器。
除了不应该返回 null 之外,此函数没有其他限制。
应用此函数可能会为流中的多个元素返回相同的键。groupingBy()
收集器支持这一点,并将所有这些元素收集到一个列表中,绑定到该键。
因此,如果您正在处理 Stream
并使用 Function<T, K>
作为分类器,groupingBy()
收集器将创建一个 Map<K, List<T>>
。
让我们检查以下示例。
Collection<String> strings =
List.of("one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
"ten", "eleven", "twelve");
Map<Integer, List<String>> map =
strings.stream()
.collect(Collectors.groupingBy(String::length));
map.forEach((key, value) -> System.out.println(key + " :: " + value));
此示例中使用的分类器是一个函数,它返回该流中每个字符串的长度。因此,映射按字符串的长度将字符串分组到列表中。它的类型为 Map<Integer, List<String>>
。
运行这段代码会打印以下内容。
3 :: [one, two, six, ten]
4 :: [four, five, nine]
5 :: [three, seven, eight]
6 :: [eleven, twelve]
使用分组进行值的后处理
计算值列表
groupingBy()
方法还接受另一个参数,它是一个其他收集器。此收集器在 Collector API 中被称为下游收集器,但它只是一个普通的收集器。使它成为下游收集器的原因是它作为另一个收集器创建的参数传递。
此下游收集器用于收集 groupingBy()
收集器创建的映射的值。
在前面的示例中,groupingBy()
收集器创建了一个映射,其值为字符串列表。如果您将下游收集器传递给 groupingBy()
方法,API 将逐个流式传输这些列表,并使用您的下游收集器收集这些流。
假设您将 Collectors.counting()
作为下游收集器传递。将计算以下内容。
[one, two, six, ten] .stream().collect(Collectors.counting()) -> 4L
[four, five, nine] .stream().collect(Collectors.counting()) -> 3L
[three, seven, eight] .stream().collect(Collectors.counting()) -> 3L
[eleven, twelve] .stream().collect(Collectors.counting()) -> 2L
此代码不是 Java 代码,因此您无法执行它。它只是为了解释如何使用此下游收集器。
现在将创建的映射取决于您提供の下游收集器。键不会修改,但值可能会修改。在 Collectors.counting()
的情况下,值将转换为 Long
。然后映射的类型变为 Map<Integer, Long>
。
前面的示例变为以下内容。
Collection<String> strings =
List.of("one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
"ten", "eleven", "twelve");
Map<Integer, Long> map =
strings.stream()
.collect(
Collectors.groupingBy(
String::length,
Collectors.counting()));
map.forEach((key, value) -> System.out.println(key + " :: " + value));
运行这段代码会打印以下结果。它给出了每个长度的字符串数量,即字符串按长度的直方图。
3 :: 4
4 :: 3
5 :: 3
6 :: 2
连接值列表
您还可以将 Collectors.joining()
收集器作为下游收集器传递,因为此映射的值是字符串列表。请记住,此收集器只能用于字符字符串流。这将创建一个 Map<Integer, String>
实例:值将采用此收集器创建的类型。您可以将前面的示例更改为以下内容。
Collection<String> strings =
List.of("one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
"ten", "eleven", "twelve");
Map<Integer, String> map =
strings.stream()
.collect(
Collectors.groupingBy(
String::length,
Collectors.joining(", ")));
map.forEach((key, value) -> System.out.println(key + " :: " + value));
运行这段代码会产生以下结果。
3 :: one, two, six, ten
4 :: four, five, nine
5 :: three, seven, eight
6 :: eleven, twelve
控制映射实例
此 groupingBy()
方法的最后一个重载接受 Supplier
实例作为参数,让您控制需要此收集器创建的 Map
实例。
您的代码不应该依赖于 groupingBy()
收集器返回的映射的确切类型,因为它不是规范的一部分。
在映射中收集,使用 To Map
Collector API 提供了第二种模式来创建映射:Collectors.toMap()
模式。此模式使用两个函数,这两个函数都应用于流的元素。
- 第一个称为键映射器,用于创建键。
- 第二个称为值映射器,用于创建值。
此收集器与 Collectors.groupingBy()
的使用情况不同。特别是,它不处理流中的多个元素生成相同键的情况。在这种情况下,默认情况下,将引发 IllegalStateException
。
此收集器非常适合创建缓存。假设您有一个 User
类,它具有类型为 Long
的 primaryKey
属性。您可以使用以下代码创建 User
对象的缓存。
List<User> users = ...;
Map<Long, User> userCache =
users.stream()
.collect(Collectors.toMap(
User::getPrimaryKey,
Function.idendity()));
使用 Function.identity()
工厂方法只是告诉收集器不要转换流的元素。
如果您期望流中的多个元素生成相同的键,那么您可以向toMap()
方法传递另一个参数。此参数的类型为BinaryOperator
。当检测到冲突元素时,它将由实现应用于这些元素。然后,您的二元运算符将生成一个结果,该结果将被放入映射中以代替先前值。
以下显示了如何使用此收集器处理冲突值。这里的值将使用分隔符连接在一起。
Collection<String> strings =
List.of("one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
"ten", "eleven", "twelve");
Map<Integer, String> map =
strings.stream()
.collect(
Collectors.toMap(
element -> element.length(),
element -> element,
(element1, element2) -> element1 + ", " + element2));
map.forEach((key, value) -> System.out.println(key + " :: " + value));
在本例中,传递给toMap()
方法的三个参数如下
element -> element.length()
是键映射器。element -> element
是值映射器。(element1, element2) -> element1 + ", " + element2)
是合并函数,它使用生成相同键的两个元素进行调用。
运行这段代码会产生以下结果。
3 :: one, two, six, ten
4 :: four, five, nine
5 :: three, seven, eight
6 :: eleven, twelve
至于groupingBy()
收集器,您可以将供应商作为参数传递给toMap()
方法,以控制此收集器将使用的Map
接口的实例。
toMap()
收集器有一个孪生方法,toConcurrentMap()
,它将收集您的数据到一个并发映射中。映射的确切类型不受实现保证。
从直方图中提取最大值
groupingBy()
收集器是您在需要分析的数据上计算直方图的最佳模式。让我们检查一个完整的示例,您将在其中构建直方图,然后尝试根据特定条件在其中找到最大值。
提取非歧义最大值
您将要分析的直方图如下所示。它看起来像我们在前面的示例中使用的那个。
Collection<String> strings =
List.of("one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
"ten", "eleven", "twelve");
Map<Integer, Long> histogram =
strings.stream()
.collect(
Collectors.groupingBy(
String::length,
Collectors.counting()));
histogram.forEach((key, value) -> System.out.println(key + " :: " + value));
打印此直方图将为您提供以下结果。
3 :: 4
4 :: 3
5 :: 3
6 :: 2
从该直方图中提取最大值应为您提供以下结果:3 :: 4
。Stream API 拥有提取最大值所需的所有工具。不幸的是,Map
接口上没有stream()
方法。因此,要在映射上创建流,您首先需要获取可以从映射中获取的集合之一。
- 使用
entrySet()
方法获取的条目集。 - 使用
keySet()
方法获取的键集。 - 或者使用
values()
方法获取的值集合。
这里您需要键和最大值,因此正确的选择是流化entrySet()
返回的集合。
您需要的代码如下所示。
Map.Entry<Integer, Long> maxValue =
histogram.entrySet().stream()
.max(Map.Entry.comparingByValue())
.orElseThrow();
System.out.println("maxValue = " + maxValue);
您可以注意到,此代码使用了Stream
接口的max()
方法,该方法接受比较器作为参数。事实证明,Map.Entry
接口有几个工厂方法来创建这样的比较器。我们在本例中使用的那个创建了一个比较器,它可以比较Map.Entry
实例,使用这些键值对的值来比较它们。此比较仅在值实现Comparable
接口时才有效。
这种代码模式非常通用,只要映射具有可比较的值,就可以在任何映射上使用。由于 Java SE 16 中引入了记录,我们可以使其不那么通用,更易读。
让我们创建一个记录来模拟此映射的键值对。创建记录是一行代码。由于语言允许使用局部记录,因此您可以在任何方法中复制这些行。
record NumberOfLength(int length, long number) {
static NumberOfLength fromEntry(Map.Entry<Integer, Long> entry) {
return new NumberOfLength(entry.getKey(), entry.getValue());
}
static Comparator<NumberOfLength> comparingByLength() {
return Comparator.comparing(NumberOfLength::length);
}
}
有了这个记录,之前的模式就变成了以下内容。
NumberOfLength maxNumberOfLength =
histogram.entrySet().stream()
.map(NumberOfLength::fromEntry)
.max(NumberOfLength.comparingByLength())
.orElseThrow();
System.out.println("maxNumberOfLength = " + maxNumberOfLength);
运行此示例将打印出以下内容。
maxNumberOfLength = NumberOfLength[length=3, number=4]
您可以看到,此记录看起来像Map.Entry
接口。它有一个用于键值对映射的工厂方法,以及一个用于创建所需比较器的工厂方法。您对直方图的分析变得更加易读和易于理解。
提取歧义最大值
前面的示例是一个很好的示例,因为您的列表中只有一个最大值。不幸的是,现实生活中的情况往往并不那么美好,您可能有多个键值对匹配最大值。
让我们从前面的示例的集合中删除一个元素。
Collection<String> strings =
List.of("two", "three", "four", "five", "six", "seven", "eight", "nine",
"ten", "eleven", "twelve");
Map<Integer, Long> histogram =
strings.stream()
.collect(
Collectors.groupingBy(
String::length,
Collectors.counting()));
histogram.forEach((key, value) -> System.out.println(key + " :: " + value));
打印此直方图将为您提供以下结果。
3 :: 3
4 :: 3
5 :: 3
6 :: 2
现在我们有三个键值对对应最大值。如果您使用之前的代码模式来提取它,这三个中的一个将被选中并返回,隐藏另外两个。
解决此问题的解决方案是创建一个新的映射,其中键是具有给定长度的字符串数量,值是匹配此数量的长度。换句话说:您需要反转此映射。这是groupingBy()
收集器的良好用例。此示例将在本部分的后面介绍,因为我们需要另一个元素才能编写此代码。
使用中间收集器
到目前为止,我们介绍的收集器是计数、连接以及收集到列表或映射中。它们都是对终端操作进行建模。Collector API 提供了其他进行中间操作的收集器:映射、过滤和扁平化映射。您可能想知道拥有一个对中间操作进行建模的终端方法collect()
有什么意义。事实上,这些特殊的收集器不能单独创建。您可以用来创建它们的工厂方法都需要一个下游收集器作为第二个参数。
因此,您可以使用这些方法创建的整体收集器是中间操作和终端操作的组合。
使用收集器进行映射
我们可以检查的第一个中间操作是映射操作。映射收集器是使用Collectors.mapping()
工厂方法创建的。它接受一个常规映射函数作为第一个参数,并接受一个必需的下游收集器作为第二个参数。
在以下示例中,我们将映射与映射元素在列表中的收集相结合。
Collection<String> strings =
List.of("one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
"ten", "eleven", "twelve");
List<String> result =
strings.stream()
.collect(
Collectors.mapping(String::toUpperCase, Collectors.toList()));
System.out.println("result = " + result);
Collectors.mappping()
工厂方法创建一个常规收集器。您可以将此收集器作为下游收集器传递给任何接受一个收集器的函数,包括例如groupingBy()
或toMap()
。您可能还记得“提取歧义最大值”一节中,我们留下了一个关于反转映射的悬而未决的问题。让我们使用此映射收集器来解决此问题。
在这个示例中,您创建了一个直方图。现在您需要使用 groupingBy()
反转此直方图以查找所有最大值。
以下代码创建了这样的映射。
Map<Integer, Long> histogram = ...;
var map =
histogram.entrySet().stream()
.map(NumberOfLength::fromEntry)
.collect(
Collectors.groupingBy(NumberOfLength::number));
让我们检查一下这段代码,并确定构建的映射的精确类型。
此映射的键是每个长度在原始流中出现的次数。它是 NumberOfLength
记录的 number
组件,即 Long
。
值是此流的元素,收集到列表中。因此,值是 NumberOfLength
对象的列表。此映射的精确类型是 Map<Long, NumberOfLength>
。
事实证明,这并不完全是您需要的。您只需要字符串的长度,而不是记录的两个组件。从记录中提取组件只是一个映射。您需要的是将这些 NumberOfLength
实例映射到它们的 length
组件。现在我们已经涵盖了映射收集器,解决这一点就成为可能。您只需要在 groupingBy()
调用中添加正确的下游收集器。
代码变为如下。
Map<Integer, Long> histogram = ...;
var map =
histogram.entrySet().stream()
.map(NumberOfLength::fromEntry)
.collect(
Collectors.groupingBy(
NumberOfLength::number,
Collectors.mapping(NumberOfLength::length, Collectors.toList())));
现在,构建的映射的值是使用 NumberOfLength::length
映射器映射的 NumberOfLength
对象的列表。此映射的类型为 Map<Long, List<Integer>>
,这正是您需要的。
要获取所有最大值,您可以应用与之前相同的模式,使用键获取最大值而不是值。
来自直方图的完整代码,包括最大值提取如下。
Map<Long, List<Integer>> map =
histogram.entrySet().stream()
.map(NumberOfLength::fromEntry)
.collect(
Collectors.groupingBy(
NumberOfLength::number,
Collectors.mapping(NumberOfLength::length, Collectors.toList())));
Map.Entry<Long, List<Integer>> result =
map.entrySet().stream()
.max(Map.Entry.comparingByKey())
.orElseThrow();
System.out.println("result = " + result);
运行此代码将产生以下结果。
result = 3=[3, 4, 5]
这意味着在这个流中,有三个字符串长度在流中出现了三次:3、4 和 5。
此示例展示了嵌套在两个更多收集器中的收集器,这在您使用此 API 时经常发生。乍一看可能令人生畏,但这只是使用此下游收集器机制组合收集器。
您可以看到为什么拥有这些中间收集器很有趣。能够使用收集器对中间操作进行建模,使您能够为几乎任何类型的处理创建下游收集器,您可以使用它来对映射的值进行后处理。
使用收集器进行过滤和扁平化映射
过滤收集器遵循与映射收集器相同的模式。它使用 Collectors.filtering()
工厂方法创建,该方法接受一个用于过滤数据的常规谓词和一个强制性的下游收集器。
扁平化映射收集器也是如此,它由 Collectors.flatMapping()
工厂方法创建,该方法接受一个扁平化映射函数(返回流的函数)和一个强制性的下游收集器。
使用终端收集器
Collector API 还提供了一些终端操作,这些操作对应于 Stream API 上可用的终端操作。
maxBy()
和minBy()
。这两个方法都接受一个比较器作为参数,并返回一个可选对象,如果处理的流本身为空,则该对象为空。summingInt()
、summingLong()
和summingDouble()
。这三种方法都接受一个映射函数作为参数,将流的元素分别映射到int
、long
和double
,然后对它们求和。averagingInt()
、averagingLong()
和averagingDouble()
。这三种方法也接受一个映射函数作为参数,将流的元素分别映射到int
、long
和double
,然后计算平均值。这些收集器的工作方式与average()
方法不同,这些方法在IntStream
、LongStream
和DoubleStream
中定义。它们都返回一个Double
实例,并为空流返回 0。average()
方法返回一个可选对象,该对象为空流为空。
上次更新: 2021 年 9 月 14 日