使用Collector来收集流元素
您已经使用了一个非常有用的模式List: collect(collections . tolist())来收集流处理的元素。这个collect()方法是在Stream接口中定义的一个终端方法,它接受Collector类型的对象作为参数。这个Collector接口定义了自己的API,您可以使用它来创建任何类型的内存结构来存储流处理的数据。收集可以在Collection或Map的任何实例中进行,它可以用于创建字符串,并且可以创建自己的Collector接口实例来将自己的数据结构添加到这个列表中。
您将使用的大多数收集器都可以使用collector工厂类的一个工厂方法创建。这就是在编写collections . tolist()或collections . toset()时所做的。使用这些方法创建的一些收集器可以组合在一起,从而产生更多的收集器。本教程将涵盖所有这些要点。
如果您在这个工厂类中找不到您需要的东西,那么您可以决定通过实现collector接口来创建自己的收集器。本教程还将介绍如何实现此接口。
在Stream接口和特殊的数字流(IntStream、LongStream和DoubleStream)中,收集器API的处理方式是不同的。Stream接口有两个collect()方法的重载,而数字流只有一个。缺少的就是将收集器对象作为参数的那个。因此,不能使用带有专用数字流的收集器对象。
在集合中收集
collections工厂类提供了三个方法来在Collection接口的实例中收集流的元素。
- toList():将它们收集到一个List对象中。
- toSet():在Set对象中收集它们。
- 如果您需要任何其他Collection实现,您可以使用toCollection(supplier),其中supplier参数将用于创建您需要的Collection对象。如果您需要在LinkedList的实例中收集数据,则应该使用这种方法。
您的代码不应该依赖于这些方法当前返回的List或Set的精确实现,因为它不是规范的一部分。
你也可以使用两个方法toounmodifiableelist()和toounmodifiableleset()来获得List和Set的不可变实现。
下面的示例展示了该模式的实际应用。首先,让我们收集一个普通的List实例。
List<Integer> numbers =
IntStream.range(0, 10)
.boxed()
.collect(Collectors.toList());
System.out.println("numbers = " + numbers);
这段代码,从IntStream.range()创建的IntStream中创建一个使用boxed()中间方法包装的Stream。运行此代码将输出以下内容。
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]
使用Collector计数
Collectors 工厂类提供了几个方法来创建Collectors ,这些收集器执行普通终端方法提供的相同功能。这就是Collectors.counting()工厂方法的情况,它执行与在流上调用count()相同的操作。
这一点值得注意,您可能想知道为什么这样的功能已经用两种不同的模式实现了两次。关于maps 收集的下一节将回答这个问题,您将结合收集器创建更多的收集器。
现在,编写下面两行代码会得到相同的结果。
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
以字符串形式收集
collector工厂类提供的另一个非常有用的收集器是join()收集器。此收集器仅对字符串流工作,并将该流的元素连接到一个字符串中。它有几个重载。
- 第一个函数以分隔符作为参数。
- 第二个函数以分隔符、前缀和后缀作为参数。
让我们看看这个collector的使用。
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.joining()工厂类或StringJoiner对象都可以工作,而无需付出创建流的开销。
使用Predicate分割元素
Collector API提供了三种模式来从流的元素创建映射。我们介绍的第一个方法是使用布尔键创建map。它是通过partitionningBy()工厂方法创建的。
流中的所有元素都将绑定到true或false布尔值。映射存储绑定到列表中每个值的所有元素。因此,如果这个收集器应用于Stream,它将生成一个具有以下类型的映射:Map<Boolean, List>。
通过使用Predicate的test() 方法来决定给定元素是否应该绑定到true或false,Predicate是提供给收集器的参数。
下面的示例显示了该收集器的运行情况。
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()收集器时介绍这些下游收集器。
在Map中使用Grouping By进行收集
我们提供的第二个收集器非常重要,因为它允许您创建直方图。
在Map中分组流的元素
您可以使用Collectors. groupingby()方法创建用于创建直方图的收集器。这个方法有几个重载。
收集器创建一个映射。通过对流中的每个元素应用Function实例来计算键。此函数作为groupingBy()方法的参数提供。在Collector API中,它被称为分类器。
除了不应该返回null之外,这个函数没有任何限制。
应用此函数可以为流中的多个元素返回相同的键。groupingBy()收集器支持这一点,并将所有这些元素聚集到一个列表中,绑定到那个键。
因此,如果您正在处理Stream并使用Function<T, K>作为分类器,groupingBy()收集器将创建Map<K, List>。
让我们看看下面的例子。
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));
本例中使用的分类器是一个function ,它返回该流中每个字符串的长度。因此,映射根据字符串的长度将字符串分组到列表中。类型为Map>。
输出:
3 :: [one, two, six, ten]
4 :: [four, five, nine]
5 :: [three, seven, eight]
6 :: [eleven, twelve]
后置处理Grouping By创建的Values
计数值的列表
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
控制Map的实例
这个groupingBy()方法的最后一个重载将一个Supplier实例作为参数,以便您控制需要此收集器创建哪个Map实例。
您的代码不应该依赖于groupingBy()收集器返回的映射的确切类型,因为它不是规范的一部分。
使用To Map收集Map
Collector API提供了创建映射的第二种模式:Collectors. tomap()模式。该模式使用两个函数,它们都应用于流的元素。
- 第一个称为键映射器,用于创建键。
- 第二个称为值映射器,用于创建值。
在与Collectors. groupingby()不同的情况下使用此收集器。特别是,它不处理流中的几个元素生成相同键的情况。在这种情况下,默认会引发IllegalStageException。
这个收集器非常便于创建缓存。假设您有一个User类,其primaryKey属性类型为Long。您可以使用以下代码创建User对象的缓存。
List<User> users = ...;
Map<Long, User> userCache =
users.stream()
.collect(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()收集器,可以将supplier 作为参数传递给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()方法。因此,要在Map上创建流,首先需要获取一个可以从Map获取的集合。
- 使用entrySet()方法的set集。
- 使用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进行比较。输入实例,使用这些键-值对的值来比较它们。只有当值实现Comparable接口时,此比较才能工作。
这种代码模式非常通用,可以在任何Map上使用,只要它有可比的值。由于在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()收集器的一个很好的用例。这个示例将在本部分的后面介绍,因为我们还需要一个元素来编写此代码。
使用中间Collectors
到目前为止,我们所介绍的收集器是计数、连接和收集到列表或地图。它们都是建模终端操作。Collector API提供了执行中间操作的其他收集器:映射、过滤和平面映射。您可能想知道使用终端方法collect()为中间操作建模有什么意义。事实上,这些特殊的收集器不能单独创建。可以用来创建它们的工厂方法都需要下游收集器作为第二个参数。
因此,您可以使用这些方法创建的总体收集器是中间操作和终端操作的组合。
使用Collector进行映射
我们可以研究的第一个中间操作是映射操作。使用Collectors.mapping()工厂方法创建映射收集器。它将常规映射function 作为第一个参数,将强制下游收集器作为第二个参数。
在下面的示例中,我们将一个映射与列表中映射元素的集合结合在一起。
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. mapping()工厂方法创建一个常规收集器。您可以将此收集器作为下游收集器传递给接受收集器的任何方法,例如,包括groupingBy()或toMap()。你可能还记得“提取一个模糊的最大值”这一节,我们留下了一个关于反转地图的开放性问题。让我们使用这个映射收集器来解决这个问题。
在本例中,您创建了一个直方图。现在需要使用groupingBy()来反转这个直方图,以找到所有的最大值。
下面的代码创建了这样一个映射。
Map<Integer, Long> histogram = ...;
var map =
histogram.entrySet().stream()
.map(NumberOfLength::fromEntry)
.collect(
Collectors.groupingBy(NumberOfLength::number));
让我们检查这段代码并确定所构建的映射的确切类型。
这个映射的键是每个长度在原始流中出现的次数。它是NumberOfLength记录的数字组件,即Long。
这些值是这个流的元素,被收集到列表中。因此,这些值是NumberOfLength对象的列表。这个映射的确切类型是map 。
事实证明,这并不是你真正需要的。您需要的只是字符串的长度,而不是记录的两个组成部分。从记录中提取组件只是一个映射。您需要的是将这些NumberOfLength实例映射到它们的长度组件。既然我们已经介绍了映射收集器,就有可能解决这一点。您所需要做的就是将正确的下游收集器添加到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>,这正是您所需要的。
要获得所有最大值,您可以应用与前面使用的模式相同的模式,使用键来获得最大值而不是值。
完整的代码从直方图,包括最大值提取如下。
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。
这个例子展示了一个嵌套在另外两个collectors中的collector ,当您使用这个API时,这种情况经常发生。乍一看可能有点吓人,但它只是使用这个下游收集器机制来组合collector 。
你可以看到为什么有这些中间collectors是有趣的。通过使用收集器建模中间操作,您可以为几乎任何类型的处理创建下游收集器,您可以使用它来后处理映射的值。
使用收集器进行过滤和Flatmapping
过滤收集器遵循与映射收集器相同的模式。它是用Collectors.filtering()工厂方法创建的,该方法接受一个常规predicate 来过滤数据和一个强制的下游收集器。
由Collectors. flatmapping()工厂方法创建的flatmapping收集器也是如此,它接受一个flatmapping函数(一个返回流的函数)和一个强制性的下游收集器。
使用终端Collectors
Collector API还提供了一些终端操作,与Stream API上可用的终端操作相对应。
- maxBy()和minBy()。这两个方法都接受一个比较器作为参数,如果处理的流本身为空,则返回一个空的可选对象。
- summingInt(), summingLong()和summingDouble()。这三个方法以映射函数作为参数,分别将流中的元素映射为int、long和double,然后对它们进行求和。
averagingInt(), averagingLong()和averagingDouble()。这三个方法还将一个映射函数作为参数,在计算平均值之前,将流中的元素分别映射为int、long和double。这些收集器的工作方式与在IntStream、LongStream和DoubleStream中定义的相应的average()方法不同。它们都返回一个Double实例,对于空流返回0。average()方法对于空流返回一个空的可选对象