本章内容
用
Collectors
类创建和使用收集器将数据流归约为一个值
汇总:归约的特殊情况
数据分组和分区
开发自己的自定义收集器
我们在前一章中学到,流可以用类似于数据库的操作帮助你处理集合。你可以把Java 8的流看作花哨又懒惰的数据集迭代器。它们支持两种类型的操作:中间操作(如filter
或map
)和终端操作(如count
、findFirst
、forEach
和reduce
)。中间操作可以链接起来,将一个流转换为另一个流。这些操作不会消耗流,其目的是建立一个流水线。与此相反,终端操作会消耗流,以产生一个最终结果,例如返回流中的最大元素。它们通常可以通过优化流水线来缩短计算时间。
我们已经在第4章和第5章中用过collect
终端操作了,当时主要是用来把Stream
中所有的元素结合成一个List
。在本章中,你会发现collect
是一个归约操作,就像reduce
一样可以接受各种做法作为参数,将流中的元素累积成一个汇总结果。具体的做法是通过定义新的Collector
接口来定义的,因此区分Collection
、Collector
和collect
是很重要的。
下面是一些查询的例子,看看你用collect
和收集器能够做什么。
对一个交易列表按货币分组,获得该货币的所有交易额总和(返回一个
Map<Currency, Integer>
)。将交易列表分成两组:贵的和不贵的(返回一个
Map<Boolean, List<Transaction>>
)。创建多级分组,比如按城市对交易分组,然后进一步按照贵或不贵分组(返回一个
Map<Boolean, List<Transaction>>
)。
激动吗?很好,我们先来看一个利用收集器的例子。想象一下,你有一个由Transaction
构成的List
,并且想按照名义货币进行分组。在没有Lambda的Java里,哪怕像这种简单的用例实现起来都很啰嗦,就像下面这样。
代码清单6-1 用指令式风格对交易按照货币分组
Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap<>; ←─建立累积交易分组的Mapfor (Transaction transaction : transactions) { ←─迭代Transaction的List Currency currency = transaction.getCurrency; ←─提取Transaction的货币 List<Transaction> transactionsForCurrency = transactionsByCurrencies.get(currency); if (transactionsForCurrency == null) { ←─如果分组Map中没有这种货币的条目,就创建一个 transactionsForCurrency = new ArrayList<>; transactionsByCurrencies .put(currency, transactionsForCurrency); } transactionsForCurrency.add(transaction); ←─将当前遍历的Transaction加入同一货币的Transaction的List}
如果你是一位经验丰富的Java程序员,写这种东西可能挺顺手的,不过你必须承认,做这么简单的一件事就得写很多代码。更糟糕的是,读起来比写起来更费劲!代码的目的并不容易看出来,尽管换作白话的话是很直截了当的:“把列表中的交易按货币分组。”你在本章中会学到,用Stream
中collect
方法的一个更通用的Collector
参数,你就可以用一句话实现完全相同的结果,而用不着使用上一章中那个toList
的特殊情况了:
Map<Currency, List<Transaction>> transactionsByCurrencies = transactions.stream.collect(groupingBy(Transaction::getCurrency));
这一比差得还真多,对吧?
6.1 收集器简介
前一个例子清楚地展示了函数式编程相对于指令式编程的一个主要优势:你只需指出希望的结果——“做什么”,而不用操心执行的步骤——“如何做”。在上一个例子里,传递给collect
方法的参数是Collector
接口的一个实现,也就是给Stream
中元素做汇总的方法。上一章里的toList
只是说“按顺序给每个元素生成一个列表”;在本例中,groupingBy
说的是“生成一个Map
,它的键是(货币)桶,值则是桶中那些元素的列表”。
要是做多级分组,指令式和函数式之间的区别就会更加明显:由于需要好多层嵌套循环和条件,指令式代码很快就变得更难阅读、更难维护、更难修改。相比之下,函数式版本只要再加上一个收集器就可以轻松地增强功能了,你会在6.3节中看到它。
6.1.1 收集器用作高级归约
刚刚的结论又引出了优秀的函数式API设计的另一个好处:更易复合和重用。收集器非常有用,因为用它可以简洁而灵活地定义collect
用来生成结果集合的标准。更具体地说,对流调用collect
方法将对流中的元素触发一个归约操作(由Collector
来参数化)。图6-1所示的归约操作所做的工作和代码清单6-1中的指令式代码一样。它遍历流中的每个元素,并让Collector
进行处理。
图 6-1 按货币对交易分组的归约过程
一般来说,Collector
会对元素应用一个转换函数(很多时候是不体现任何效果的恒等转换,例如toList
),并将结果累积在一个数据结构中,从而产生这一过程的最终输出。例如,在前面所示的交易分组的例子中,转换函数提取了每笔交易的货币,随后使用货币作为键,将交易本身累积在生成的Map
中。
如货币的例子中所示,Collector
接口中方法的实现决定了如何对流执行归约操作。我们会在6.5节和6.6节研究如何创建自定义收集器。但Collectors
实用类提供了很多静态工厂方法,可以方便地创建常见收集器的实例,只要拿来用就可以了。最直接和最常用的收集器是toList
静态方法,它会把流中所有的元素收集到一个List
中:
List<Transaction> transactions = transactionStream.collect(Collectors.toList);
6.1.2 预定义收集器
在本章剩下的部分中,我们主要探讨预定义收集器的功能,也就是那些可以从Collectors
类提供的工厂方法(例如groupingBy
)创建的收集器。它们主要提供了三大功能:
将流元素归约和汇总为一个值
元素分组
元素分区
我们先来看看可以进行归约和汇总的收集器。它们在很多场合下都很方便,比如前面例子中提到的求一系列交易的总交易额。
然后你将看到如何对流中的元素进行分组,同时把前一个例子推广到多层次分组,或把不同的收集器结合起来,对每个子组进行进一步归约操作。我们还将谈到分组的特殊情况“分区”,即使用谓词(返回一个布尔值的单参数函数)作为分组函数。
6.4节末有一张表,总结了本章中探讨的所有预定义收集器。在6.5节你将了解更多有关Collector
接口的内容。在6.6节中你会学到如何创建自己的自定义收集器,用于Collectors
类的工厂方法无效的情况。
6.2 归约和汇总
为了说明从Collectors
工厂类中能创建出多少种收集器实例,我们重用一下前一章的例子:包含一张佳肴列表的菜单!
就像你刚刚看到的,在需要将流项目重组成集合时,一般会使用收集器(Stream
方法collect
的参数)。再宽泛一点来说,但凡要把流中所有的项目合并成一个结果时就可以用。这个结果可以是任何类型,可以复杂如代表一棵树的多级映射,或是简单如一个整数——也许代表了菜单的热量总和。这两种结果类型我们都会讨论:6.2.2节讨论单个整数,6.3.1节讨论多级分组。
我们先来举一个简单的例子,利用counting
工厂方法返回的收集器,数一数菜单里有多少种菜:
long howManyDishes = menu.stream.collect(Collectors.counting);
这还可以写得更为直接:
long howManyDishes = menu.stream.count;
counting
收集器在和其他收集器联合使用的时候特别有用,后面会谈到这一点。
在本章后面的部分,我们假定你已导入了Collectors
类的所有静态工厂方法:
import static java.util.stream.Collectors.*;
这样你就可以写counting
而用不着写Collectors.counting
之类的了。
让我们来继续探讨简单的预定义收集器,看看如何找到流中的最大值和最小值。
6.2.1 查找流中的最大值和最小值
假设你想要找出菜单中热量最高的菜。你可以使用两个收集器,Collectors.maxBy
和Collectors.minBy
,来计算流中的最大或最小值。这两个收集器接收一个Comparator
参数来比较流中的元素。你可以创建一个Comparator
来根据所含热量对菜肴进行比较,并把它传递给Collectors.maxBy
:
Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);Optional<Dish> mostCalorieDish = menu.stream .collect(maxBy(dishCaloriesComparator));
你可能在想Optional<Dish>
是怎么回事。要回答这个问题,我们需要问“要是menu
为空怎么办”。那就没有要返回的菜了!Java 8引入了Optional
,它是一个容器,可以包含也可以不包含值。这里它完美地代表了可能也可能不返回菜肴的情况。我们在第5章讲findAny
方法的时候简要提到过它。现在不用担心,我们专门用第10章来研究Optional<T>
及其操作。
另一个常见的返回单个值的归约操作是对流中对象的一个数值字段求和。或者你可能想要求平均数。这种操作被称为汇总操作。让我们来看看如何使用收集器来表达汇总操作。
6.2.2 汇总
Collectors
类专门为汇总提供了一个工厂方法:Collectors.summingInt
。它可接受一个把对象映射为求和所需int
的函数,并返回一个收集器;该收集器在传递给普通的collect
方法后即执行我们需要的汇总操作。举个例子来说,你可以这样求出菜单列表的总热量:
int totalCalories = menu.stream.collect(summingInt(Dish::getCalories));
这里的收集过程如图6-2所示。在遍历流时,会把每一道菜都映射为其热量,然后把这个数字累加到一个累加器(这里的初始值0
)。
Collectors.summingLong
和Collectors.summingDouble
方法的作用完全一样,可以用于求和字段为long
或double
的情况。
图 6-2 summingInt
收集器的累积过程
但汇总不仅仅是求和;还有Collectors.averagingInt
,连同对应的averagingLong
和averagingDouble
可以计算数值的平均数:
double avgCalories = menu.stream.collect(averagingInt(Dish::getCalories));
到目前为止,你已经看到了如何使用收集器来给流中的元素计数,找到这些元素数值属性的最大值和最小值,以及计算其总和和平均值。不过很多时候,你可能想要得到两个或更多这样的结果,而且你希望只需一次操作就可以完成。在这种情况下,你可以使用summarizingInt
工厂方法返回的收集器。例如,通过一次summarizing
操作你可以就数出菜单中元素的个数,并得到菜肴热量总和、平均值、最大值和最小值:
IntSummaryStatistics menuStatistics = menu.stream.collect(summarizingInt(Dish::getCalories));
这个收集器会把所有这些信息收集到一个叫作IntSummaryStatistics
的类里,它提供了方便的取值(getter)方法来访问结果。打印menuStatisticobject
会得到以下输出:
IntSummaryStatistics{count=9, sum=4300, min=120, average=477.777778, max=800}
同样,相应的summarizingLong
和summarizingDouble
工厂方法有相关的LongSummaryStatistics
和DoubleSummaryStatistics
类型,适用于收集的属性是原始类型long
或double
的情况。
6.2.3 连接字符串
joining
工厂方法返回的收集器会把对流中每一个对象应用toString
方法得到的所有字符串连接成一个字符串。这意味着你把菜单中所有菜肴的名称连接起来,如下所示:
String shortMenu = menu.stream.map(Dish::getName).collect(joining);
请注意,joining
在内部使用了StringBuilder
来把生成的字符串逐个追加起来。此外还要注意,如果Dish
类有一个toString
方法来返回菜肴的名称,那你无需用提取每一道菜名称的函数来对原流做映射就能够得到相同的结果:
String shortMenu = menu.stream.collect(joining);
二者均可产生以下字符串:
porkbeefchickenfrench friesriceseason fruitpizzaprawnssalmon
但该字符串的可读性并不好。幸好,joining
工厂方法有一个重载版本可以接受元素之间的分界符,这样你就可以得到一个逗号分隔的菜肴名称列表:
String shortMenu = menu.stream.map(Dish::getName).collect(joining(", "));
正如我们预期的那样,它会生成:
pork, beef, chicken, french fries, rice, season fruit, pizza, prawns, salmon
到目前为止,我们已经探讨了各种将流归约到一个值的收集器。在下一节中,我们会展示为什么所有这种形式的归约过程,其实都是Collectors.reducing
工厂方法提供的更广义归约收集器的特殊情况。
6.2.4 广义的归约汇总
事实上,我们已经讨论的所有收集器,都是一个可以用reducing
工厂方法定义的归约过程的特殊情况而已。Collectors.reducing
工厂方法是所有这些特殊情况的一般化。可以说,先前讨论的案例仅仅是为了方便程序员而已。(但是,请记得方便程序员和可读性是头等大事!)例如,可以用reducing
方法创建的收集器来计算你菜单的总热量,如下所示:
int totalCalories = menu.stream.collect(reducing( 0, Dish::getCalories, (i, j) -> i + j));
它需要三个参数。
第一个参数是归约操作的起始值,也是流中没有元素时的返回值,所以很显然对于数值和而言
0
是一个合适的值。第二个参数就是你在6.2.2节中使用的函数,将菜肴转换成一个表示其所含热量的
int
。第三个参数是一个
BinaryOperator
,将两个项目累积成一个同类型的值。这里它就是对两个int
求和。
同样,你可以使用下面这样单参数形式的reducing
来找到热量最高的菜,如下所示:
Optional<Dish> mostCalorieDish = menu.stream.collect(reducing( (d1, d2) -> d1.getCalories > d2.getCalories ? d1 : d2));
你可以把单参数reducing
工厂方法创建的收集器看作三参数方法的特殊情况,它把流中的第一个项目作为起点,把恒等函数(即一个函数仅仅是返回其输入参数)作为一个转换函数。这也意味着,要是把单参数reducing
收集器传递给空流的collect
方法,收集器就没有起点;正如我们在6.2.1节中所解释的,它将因此而返回一个Optional<Dish>
对象。
收集与归约
在上一章和本章中讨论了很多有关归约的内容。你可能想知道,
Stream
接口的collect
和reduce
方法有何不同,因为两种方法通常会获得相同的结果。例如,你可以像下面这样使用reduce
方法来实现toListCollector
所做的工作:Stream<Integer> stream = Arrays.asList(1, 2, 3, 4, 5, 6).stream;List<Integer> numbers = stream.reduce( new ArrayList<Integer>, (List<Integer> l, Integer e) -> { l.add(e); return l; }, (List<Integer> l1, List<Integer> l2) -> { l1.addAll(l2); return l1; });
这个解决方案有两个问题:一个语义问题和一个实际问题。语义问题在于,
reduce
方法旨在把两个值结合起来生成一个新值,它是一个不可变的归约。与此相反,collect
方法的设计就是要改变容器,从而累积要输出的结果。这意味着,上面的代码片段是在滥用reduce
方法,因为它在原地改变了作为累加器的List
。你在下一章中会更详细地看到,以错误的语义使用reduce
方法还会造成一个实际问题:这个归约过程不能并行工作,因为由多个线程并发修改同一个数据结构可能会破坏List
本身。在这种情况下,如果你想要线程安全,就需要每次分配一个新的List
,而对象分配又会影响性能。这就是collect
方法特别适合表达可变容器上的归约的原因,更关键的是它适合并行操作,本章后面会谈到这一点。
1. 收集框架的灵活性:以不同的方法执行同样的操作
你还可以进一步简化前面使用reducing
收集器的求和例子——引用Integer
类的sum
方法,而不用去写一个表达同一操作的Lambda表达式。这会得到以下程序:
int totalCalories = menu.stream.collect(reducing(0, ←─初始值 Dish::getCalories, ←─转换函数 Integer::sum)); ←─累积函数
从逻辑上说,归约操作的工作原理如图6-3所示:利用累积函数,把一个初始化为起始值的累加器,和把转换函数应用到流中每个元素上得到的结果不断迭代合并起来。
图 6-3 计算菜单总热量的归约过程
在现实中,我们在6.2节开始时提到的counting
收集器也是类似地利用三参数reducing
工厂方法实现的。它把流中的每个元素都转换成一个值为1
的Long
型对象,然后再把它们相加:
public static <T> Collector<T, ?, Long> counting { return reducing(0L, e -> 1L, Long::sum);}
使用泛型
?
通配符在刚刚提到的代码片段中,你可能已经注意到了
?
通配符,它用作counting
工厂方法返回的收集器签名中的第二个泛型类型。对这种记法你应该已经很熟悉了,特别是如果你经常使用Java的集合框架的话。在这里,它仅仅意味着收集器的累加器类型未知,换句话说,累加器本身可以是任何类型。我们在这里原封不动地写出了Collectors
类中原始定义的方法签名,但在本章其余部分我们将避免使用任何通配符表示法,以使讨论尽可能简单。
我们在第5章已经注意到,还有另一种方法不使用收集器也能执行相同操作——将菜肴流映射为每一道菜的热量,然后用前一个版本中使用的方法引用来归约得到的流:
int totalCalories = menu.stream.map(Dish::getCalories).reduce(Integer::sum).get;
请注意,就像流的任何单参数reduce
操作一样,reduce(Integer::sum)
返回的不是int
而是Optional<Integer>
,以便在空流的情况下安全地执行归约操作。然后你只需用Optional
对象中的get
方法来提取里面的值就行了。请注意,在这种情况下使用get
方法是安全的,只是因为你已经确定菜肴流不为空。你在第10章还会进一步了解到,一般来说,使用允许提供默认值的方法,如orElse
或orElseGet
来解开Optional
中包含的值更为安全。最后,更简洁的方法是把流映射到一个IntStream
,然后调用sum
方法,你也可以得到相同的结果:
int totalCalories = menu.stream.mapToInt(Dish::getCalories).sum;
2. 根据情况选择最佳解决方案
这再次说明了,函数式编程(特别是Java 8的Collections
框架中加入的基于函数式风格原理设计的新API)通常提供了多种方法来执行同一个操作。这个例子还说明,收集器在某种程度上比Stream
接口上直接提供的方法用起来更复杂,但好处在于它们能提供更高水平的抽象和概括,也更容易重用和自定义。
我们的建议是,尽可能为手头的问题探索不同的解决方案,但在通用的方案里面,始终选择最专门化的一个。无论是从可读性还是性能上看,这一般都是最好的决定。例如,要计菜单的总热量,我们更倾向于最后一个解决方案(使用IntStream
),因为它最简明,也很可能最易读。同时,它也是性能最好的一个,因为IntStream
可以让我们避免自动拆箱操作,也就是从Integer
到int
的隐式转换,它在这里毫无用处。
接下来,请看看测验6.1,测试一下你对于reducing
作为其他收集器的概括的理解程度如何。
测验6.1:用
reducing
连接字符串以下哪一种
reducing
收集器的用法能够合法地替代joining
收集器(如6.2.3节用法)?String shortMenu = menu.stream.map(Dish::getName).collect(joining);
(1)
String shortMenu = menu.stream.map(Dish::getName) .collect( reducing ( (s1, s2) -> s1 + s2 ) ).get;(2)
String shortMenu = menu.stream.collect( reducing( (d1, d2) -> d1.getName + d2.getName ) ).get;(3)
String shortMenu = menu.stream.collect( reducing( "",Dish::getName, (s1, s2) -> s1 + s2 ) );答案:语句1和语句3是有效的,语句2无法编译。
(1) 这会将每道菜转换为菜名,就像原先使用
joining
收集器的语句一样。然后用一个String
作为累加器归约得到的字符串流,并将菜名逐个连接在它后面。(2) 这无法编译,因为
reducing
接受的参数是一个BinaryOperator<t>
,也就是一个BiFunction<T,T,T>
。这就意味着它需要的函数必须能接受两个参数,然后返回一个相同类型的值,但这里用的Lambda表达式接受的参数是两个菜,返回的却是一个字符串。(3) 这会把一个空字符串作为累加器来进行归约,在遍历菜肴流时,它会把每道菜转换成菜名,并追加到累加器上。请注意,我们前面讲过,
reducing
要返回一个Optional
并不需要三个参数,因为如果是空流的话,它的返回值更有意义——也就是作为累加器初始值的空字符串。请注意,虽然语句1和语句3都能够合法地替代
joining
收集器,它们在这里是用来展示我们为何可以(至少在概念上)把reducing
看作本章中讨论的所有其他收集器的概括。然而就实际应用而言,不管是从可读性还是性能方面考虑,我们始终建议使用joining
收集器。
6.3 分组
一个常见的数据库操作是根据一个或多个属性对集合中的项目进行分组。就像前面讲到按货币对交易进行分组的例子一样,如果用指令式风格来实现的话,这个操作可能会很麻烦、啰嗦而且容易出错。但是,如果用Java 8所推崇的函数式风格来重写的话,就很容易转化为一个非常容易看懂的语句。我们来看看这个功能的第二个例子:假设你要把菜单中的菜按照类型进行分类,有肉的放一组,有鱼的放一组,其他的都放另一组。用Collectors.groupingBy
工厂方法返回的收集器就可以轻松地完成这项任务,如下所示:
Map<Dish.Type, List<Dish>> dishesByType = menu.stream.collect(groupingBy(Dish::getType));
其结果是下面的Map
:
{FISH=[prawns, salmon], OTHER=[french fries, rice, season fruit, pizza],MEAT=[pork, beef, chicken]}
这里,你给groupingBy
方法传递了一个Function
(以方法引用的形式),它提取了流中每一道Dish
的Dish.Type
。我们把这个Function
叫作分类函数,因为它用来把流中的元素分成不同的组。如图6-4所示,分组操作的结果是一个Map
,把分组函数返回的值作为映射的键,把流中所有具有这个分类值的项目的列表作为对应的映射值。在菜单分类的例子中,键就是菜的类型,值就是包含所有对应类型的菜肴的列表。
图 6-4 在分组过程中对流中的项目进行分类
但是,分类函数不一定像方法引用那样可用,因为你想用以分类的条件可能比简单的属性访问器要复杂。例如,你可能想把热量不到400卡路里的菜划分为“低热量”(diet),热量400到700卡路里的菜划为“普通”(normal),高于700卡路里的划为“高热量”(fat)。由于Dish
类的作者没有把这个操作写成一个方法,你无法使用方法引用,但你可以把这个逻辑写成Lambda表达式:
public enum CaloricLevel { DIET, NORMAL, FAT }Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream.collect( groupingBy(dish -> { if (dish.getCalories <= 400) return CaloricLevel.DIET; else if (dish.getCalories <= 700) return CaloricLevel.NORMAL; else return CaloricLevel.FAT; } ));
现在,你已经看到了如何对菜单中的菜肴按照类型和热量进行分组,但要是想同时按照这两个标准分类怎么办呢?分组的强大之处就在于它可以有效地组合。让我们来看看怎么做。
6.3.1 多级分组
要实现多级分组,我们可以使用一个由双参数版本的 Collectors.groupingBy
工厂方法创建的收集器,它除了普通的分类函数之外,还可以接受collector
类型的第二个参数。那么要进行二级分组的话,我们可以把一个内层groupingBy
传递给外层groupingBy
,并定义一个为流中项目分类的二级标准,如代码清单6-2所示。
代码清单6-2 多级分组
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel = menu.stream.collect( groupingBy(Dish::getType, ←─一级分类函数 groupingBy(dish -> { ←─二级分类函数 if (dish.getCalories <= 400) return CaloricLevel.DIET; else if (dish.getCalories <= 700) return CaloricLevel.NORMAL; else return CaloricLevel.FAT; } ) ));
这个二级分组的结果就是像下面这样的两级Map
:
{MEAT={DIET=[chicken], NORMAL=[beef], FAT=[pork]}, FISH={DIET=[prawns], NORMAL=[salmon]}, OTHER={DIET=[rice, seasonal fruit], NORMAL=[french fries, pizza]}}
这里的外层Map
的键就是第一级分类函数生成的值:“fish, meat, other”,而这个Map
的值又是一个Map
,键是二级分类函数生成的值:“normal, diet, fat”。最后,第二级map
的值是流中元素构成的List
,是分别应用第一级和第二级分类函数所得到的对应第一级和第二级键的值:“salmon、pizza…” 这种多级分组操作可以扩展至任意层级,n 级分组就会得到一个代表 n 级树形结构的 n 级Map
。
图6-5显示了为什么结构相当于 n 维表格,并强调了分组操作的分类目的。
一般来说,把groupingBy
看作“桶”比较容易明白。第一个groupingBy
给每个键建立了一个桶。然后再用下游的收集器去收集每个桶中的元素,以此得到 n 级分组。
图 6-5 n 层嵌套映射和 n 维分类表之间的等价关系
6.3.2 按子组收集数据
在上一节中,我们看到可以把第二个groupingBy
收集器传递给外层收集器来实现多级分组。但进一步说,传递给第一个groupingBy
的第二个收集器可以是任何类型,而不一定是另一个groupingBy
。例如,要数一数菜单中每类菜有多少个,可以传递counting
收集器作为groupingBy
收集器的第二个参数:
Map<Dish.Type, Long> typesCount = menu.stream.collect( groupingBy(Dish::getType, counting));
其结果是下面的Map
:
{MEAT=3, FISH=2, OTHER=4}
还要注意,普通的单参数groupingBy(f)
(其中f
是分类函数)实际上是groupingBy(f, toList)
的简便写法。
再举一个例子,你可以把前面用于查找菜单中热量最高的菜肴的收集器改一改,按照菜的类型分类:
Map<Dish.Type, Optional<Dish>> mostCaloricByType = menu.stream .collect(groupingBy(Dish::getType, maxBy(comparingInt(Dish::getCalories))));
这个分组的结果显然是一个map
,以Dish
的类型作为键,以包装了该类型中热量最高的Dish
的Optional<Dish>
作为值:
{FISH=Optional[salmon], OTHER=Optional[pizza], MEAT=Optional[pork]}
注意 这个
Map
中的值是Optional
,因为这是maxBy
工厂方法生成的收集器的类型,但实际上,如果菜单中没有某一类型的Dish
,这个类型就不会对应一个Optional. empty
值,而且根本不会出现在Map
的键中。groupingBy
收集器只有在应用分组条件后,第一次在流中找到某个键对应的元素时才会把键加入分组Map
中。这意味着Optional
包装器在这里不是很有用,因为它不会仅仅因为它是归约收集器的返回类型而表达一个最终可能不存在却意外存在的值。
1. 把收集器的结果转换为另一种类型
因为分组操作的Map
结果中的每个值上包装的Optional
没什么用,所以你可能想要把它们去掉。要做到这一点,或者更一般地来说,把收集器返回的结果转换为另一种类型,你可以使用Collectors.collectingAndThen
工厂方法返回的收集器,如下所示。
代码清单6-3 查找每个子组中热量最高的Dish
Map<Dish.Type, Dish> mostCaloricByType = menu.stream .collect(groupingBy(Dish::getType, ←─分类函数 collectingAndThen( maxBy(comparingInt(Dish::getCalories)), ←─包装后的收集器 Optional::get))); ←─转换函数
这个工厂方法接受两个参数——要转换的收集器以及转换函数,并返回另一个收集器。这个收集器相当于旧收集器的一个包装,collect
操作的最后一步就是将返回值用转换函数做一个映射。在这里,被包起来的收集器就是用maxBy
建立的那个,而转换函数Optional::get
则把返回的Optional
中的值提取出来。前面已经说过,这个操作放在这里是安全的,因为reducing
收集器永远都不会返回Optional.empty
。其结果是下面的Map
:
{FISH=salmon, OTHER=pizza, MEAT=pork}
把好几个收集器嵌套起来很常见,它们之间到底发生了什么可能不那么明显。图6-6可以直观地展示它们是怎么工作的。从最外层开始逐层向里,注意以下几点。
收集器用虚线表示,因此
groupingBy
是最外层,根据菜肴的类型把菜单流分组,得到三个子流。groupingBy
收集器包裹着collectingAndThen
收集器,因此分组操作得到的每个子流都用这第二个收集器做进一步归约。collectingAndThen
收集器又包裹着第三个收集器maxBy
。随后由归约收集器进行子流的归约操作,然后包含它的
collectingAndThen
收集器会对其结果应用Optional:get
转换函数。对三个子流分别执行这一过程并转换而得到的三个值,也就是各个类型中热量最高的
Dish
,将成为groupingBy
收集器返回的Map
中与各个分类键(Dish
的类型)相关联的值。
2. 与groupingBy
联合使用的其他收集器的例子
一般来说,通过groupingBy
工厂方法的第二个参数传递的收集器将会对分到同一组中的所有流元素执行进一步归约操作。例如,你还重用求出所有菜肴热量总和的收集器,不过这次是对每一组Dish
求和:
Map<Dish.Type, Integer> totalCaloriesByType = menu.stream.collect(groupingBy(Dish::getType, summingInt(Dish::getCalories)));
然而常常和groupingBy
联合使用的另一个收集器是mapping
方法生成的。这个方法接受两个参数:一个函数对流中的元素做变换,另一个则将变换的结果对象收集起来。其目的是在累加之前对每个输入元素应用一个映射函数,这样就可以让接受特定类型元素的收集器适应不同类型的对象。我们来看一个使用这个收集器的实际例子。比方说你想要知道,对于每种类型的Dish
,菜单中都有哪些CaloricLevel
。我们可以把groupingBy
和mapping
收集器结合起来,如下所示:
Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType =menu.stream.collect( groupingBy(Dish::getType, mapping( dish -> { if (dish.getCalories <= 400) return CaloricLevel.DIET; else if (dish.getCalories <= 700) return CaloricLevel.NORMAL; else return CaloricLevel.FAT; }, toSet )));
图 6-6 嵌套收集器来获得多重效果
这里,就像我们前面见到过的,传递给映射方法的转换函数将Dish
映射成了它的CaloricLevel
:生成的CaloricLevel
流传递给一个toSet
收集器,它和toList
类似,不过是把流中的元素累积到一个Set
而不是List
中,以便仅保留各不相同的值。如先前的示例所示,这个映射收集器将会收集分组函数生成的各个子流中的元素,让你得到这样的Map
结果:
{OTHER=[DIET, NORMAL], MEAT=[DIET, NORMAL, FAT], FISH=[DIET, NORMAL]}
由此你就可以轻松地做出选择了。如果你想吃鱼并且在减肥,那很容易找到一道菜;同样,如果你饥肠辘辘,想要很多热量的话,菜单中肉类部分就可以满足你的饕餮之欲了。请注意在上一个示例中,对于返回的Set
是什么类型并没有任何保证。但通过使用toCollection
,你就可以有更多的控制。例如,你可以给它传递一个构造函数引用来要求HashSet
:
Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType =menu.stream.collect( groupingBy(Dish::getType, mapping( dish -> { if (dish.getCalories <= 400) return CaloricLevel.DIET; else if (dish.getCalories <= 700) return CaloricLevel.NORMAL; else return CaloricLevel.FAT; }, toCollection(HashSet::new) )));
6.4 分区
分区是分组的特殊情况:由一个谓词(返回一个布尔值的函数)作为分类函数,它称分区函数。分区函数返回一个布尔值,这意味着得到的分组Map
的键类型是Boolean
,于是它最多可以分为两组——true
是一组,false
是一组。例如,如果你是素食者或是请了一位素食的朋友来共进晚餐,可能会想要把菜单按照素食和非素食分开:
Map<Boolean, List<Dish>> partitionedMenu = menu.stream.collect(partitioningBy(Dish::isVegetarian)); ←─分区函数
这会返回下面的Map
:
{false=[pork, beef, chicken, prawns, salmon], true=[french fries, rice, season fruit, pizza]}
那么通过Map
中键为true
的值,就可以找出所有的素食菜肴了:
List<Dish> vegetarianDishes = partitionedMenu.get(true);
请注意,用同样的分区谓词,对菜单List
创建的流作筛选,然后把结果收集到另外一个List
中也可以获得相同的结果:
List<Dish> vegetarianDishes = menu.stream.filter(Dish::isVegetarian).collect(toList);
6.4.1 分区的优势
分区的好处在于保留了分区函数返回true
或false
的两套流元素列表。在上一个例子中,要得到非素食Dish
的List
,你可以使用两个筛选操作来访问partitionedMenu
这个Map
中false
键的值:一个利用谓词,一个利用该谓词的非。而且就像你在分组中看到的,partitioningBy
工厂方法有一个重载版本,可以像下面这样传递第二个收集器:
Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishesByType =menu.stream.collect( partitioningBy(Dish::isVegetarian, ←─分区函数 groupingBy(Dish::getType))); ←─第二个收集器
这将产生一个二级Map
:
{false={FISH=[prawns, salmon], MEAT=[pork, beef, chicken]}, true={OTHER=[french fries, rice, season fruit, pizza]}}
这里,对于分区产生的素食和非素食子流,分别按类型对菜肴分组,得到了一个二级Map
,和6.3.1节的二级分组得到的结果类似。再举一个例子,你可以重用前面的代码来找到素食和非素食中热量最高的菜:
Map<Boolean, Dish> mostCaloricPartitionedByVegetarian =menu.stream.collect( partitioningBy(Dish::isVegetarian, collectingAndThen( maxBy(comparingInt(Dish::getCalories)), Optional::get)));
这将产生以下结果:
{false=pork, true=pizza}
我们在本节开始时说过,你可以把分区看作分组一种特殊情况。groupingBy
和partitioningBy
收集器之间的相似之处并不止于此;你在下一个测验中会看到,还可以按照和6.3.1节中分组类似的方式进行多级分区。
测验6.2:使用
partitioningBy
我们已经看到,和
groupingBy
收集器类似,partitioningBy
收集器也可以结合其他收集器使用。尤其是它可以与第二个partitioningBy
收集器一起使用来实现多级分区。以下多级分区的结果会是什么呢?(1)
menu.stream.collect(partitioningBy(Dish::isVegetarian, partitioningBy (d -> d.getCalories > 500)));(2)menu.stream.collect(partitioningBy(Dish::isVegetarian, partitioningBy (Dish::getType)));(3)menu.stream.collect(partitioningBy(Dish::isVegetarian, counting));答案如下。
(1) 这是一个有效的多级分区,产生以下二级
Map
:{ false={false=[chicken, prawns, salmon], true=[pork, beef]}, true={false=[rice, season fruit], true=[french fries, pizza]}}
(2) 这无法编译,因为
partitioningBy
需要一个谓词,也就是返回一个布尔值的函数。方法引用Dish::getType
不能用作谓词。(3) 它会计算每个分区中项目的数目,得到以下
Map
:{false=5, true=4}
作为使用partitioningBy
收集器的最后一个例子,我们把菜单数据模型放在一边,来看一个更为复杂也更为有趣的例子:将数字分为质数和非质数。
6.4.2 将数字按质数和非质数分区
假设你要写一个方法,它接受参数int
n,并将前 n 个自然数分为质数和非质数。但首先,找出能够测试某一个待测数字是否是质数的谓词会很有帮助:
public boolean isPrime(int candidate) { return IntStream.range(2, candidate) ←─产生一个自然数范围,从2开始,直至但不包括待测数 .noneMatch(i -> candidate % i == 0); ←─如果待测数字不能被流中任何数字整除则返回true}
一个简单的优化是仅测试小于等于待测数平方根的因子:
public boolean isPrime(int candidate) { int candidateRoot = (int) Math.sqrt((double) candidate); return IntStream.rangeClosed(2, candidateRoot) .noneMatch(i -> candidate % i == 0);}
现在最主要的一部分工作已经做好了。为了把前n个数字分为质数和非质数,只要创建一个包含这n个数的流,用刚刚写的isPrime
方法作为谓词,再给partitioningBy
收集器归约就好了:
public Map<Boolean, List<Integer>> partitionPrimes(int n) { return IntStream.rangeClosed(2, n).boxed .collect( partitioningBy(candidate -> isPrime(candidate)));}
现在我们已经讨论过了Collectors
类的静态工厂方法能够创建的所有收集器,并介绍了使用它们的实际例子。表6-1将它们汇总到一起,给出了它们应用到Stream<T>
上返回的类型,以及它们用于一个叫作menuStream
的Stream<Dish>
上的实际例子。
表6-1 Collectors
类的静态工厂方法
toList
List<T>
把流中所有项目收集到一个List
使用示例:List<Dish> dishes = menuStream.collect(toList);
toSet
Set<T>
把流中所有项目收集到一个Set
,删除重复项 使用示例:Set<Dish> dishes = menuStream.collect(toSet);
toCollection
Collection<T>
把流中所有项目收集到给定的供应源创建的集合 使用示例:Collection<Dish> dishes = menuStream.collect(toCollection, ArrayList::new);
counting
Long
计算流中元素的个数 使用示例:long howManyDishes = menuStream.collect(counting);
summingInt
Integer
对流中项目的一个整数属性求和 使用示例:int totalCalories = menuStream.collect(summingInt(Dish::getCalories));
averagingInt
Double
计算流中项目Integer
属性的平均值 使用示例:double avgCalories = menuStream.collect(averagingInt(Dish::getCalories));
summarizingInt
IntSummaryStatistics
收集关于流中项目Integer
属性的统计值,例如最大、最小、总和与平均值 使用示例:IntSummaryStatistics menuStatistics = menuStream.collect(summarizingInt(Dish::getCalories));
joining/`
String
连接对流中每个项目调用toString
方法所生成的字符串 使用示例:String shortMenu = menuStream.map(Dish::getName).collect(joining(", "));
maxBy
Optional<T>
一个包裹了流中按照给定比较器选出的最大元素的Optional
,或如果流为空则为Optional.empty
使用示例:Optional<Dish> fattest = menuStream.collect(maxBy(comparingInt(Dish::getCalories)));
minBy
Optional<T>
一个包裹了流中按照给定比较器选出的最小元素的Optional
,或如果流为空则为Optional.empty
使用示例:Optional<Dish> lightest = menuStream.collect(minBy(comparingInt(Dish::getCalories)));
reducing
归约操作产生的类型从一个作为累加器的初始值开始,利用BinaryOperator
与流中的元素逐个结合,从而将流归约为单个值 使用示例:int totalCalories = menuStream.collect(reducing(0, Dish::getCalories, Integer::sum));
collectingAndThen
转换函数返回的类型包裹另一个收集器,对其结果应用转换函数 使用示例:int howManyDishes = menuStream.collect(collectingAndThen(toList, List::size));
groupingBy
Map<K, List<T>>
根据项目的一个属性的值对流中的项目作问组,并将属性值作为结果`Map`的键 使用示例:Map<Dish.Type,List<Dish>> dishesByType = menuStream.collect(groupingBy(Dish::getType));
partitioningBy
Map<Boolean,List<T>>
根据对流中每个项目应用谓词的结果来对项目进行分区 使用示例:Map<Boolean,List<Dish>> vegetarianDishes = menuStream.collect(partitioningBy(Dish::isVegetarian));
本章开头提到过,所有这些收集器都是对Collector
接口的实现,因此我们会在本章剩余部分中详细讨论这个接口。我们会看看这个接口中的方法,然后探讨如何实现你自己的收集器。
6.5 收集器接口
Collector
接口包含了一系列方法,为实现具体的归约操作(即收集器)提供了范本。我们已经看过了Collector
接口中实现的许多收集器,例如toList
或groupingBy
。这也意味着,你可以为Collector
接口提供自己的实现,从而自由地创建自定义归约操作。在6.6节中,我们将展示如何实现Collector
接口来创建一个收集器,来比先前更高效地将数值流划分为质数和非质数。
要开始使用Collector
接口,我们先看看本章开始时讲到的一个收集器——toList
工厂方法,它会把流中的所有元素收集成一个List
。我们当时说在日常工作中经常会用到这个收集器,而且它也是写起来比较直观的一个,至少理论上如此。通过仔细研究这个收集器是怎么实现的,我们可以很好地了解Collector
接口是怎么定义的,以及它的方法所返回的函数在内部是如何为collect
方法所用的。
首先让我们在下面的列表中看看Collector
接口的定义,它列出了接口的签名以及声明的五个方法。
代码清单6-4 Collector
接口
public interface Collector<T, A, R> { Supplier<A> supplier; BiConsumer<A, T> accumulator; Function<A, R> finisher; BinaryOperator<A> combiner; Set<Characteristics> characteristics;}
本列表适用以下定义。
T
是流中要收集的项目的泛型。A
是累加器的类型,累加器是在收集过程中用于累积部分结果的对象。R
是收集操作得到的对象(通常但并不一定是集合)的类型。
例如,你可以实现一个ToListCollector<T>
类,将Stream<T>
中的所有元素收集到一个List<T>
里,它的签名如下:
public class ToListCollector<T> implements Collector<T, List<T>, List<T>>
我们很快就会澄清,这里用于累积的对象也将是收集过程的最终结果。
6.5.1 理解Collector
接口声明的方法
现在我们可以一个个来分析Collector
接口声明的五个方法了。通过分析,你会注意到,前四个方法都会返回一个会被collect
方法调用的函数,而第五个方法characteristics
则提供了一系列特征,也就是一个提示列表,告诉collect
方法在执行归约操作的时候可以应用哪些优化(比如并行化)。
1. 建立新的结果容器:supplier
方法
supplier
方法必须返回一个结果为空的Supplier
,也就是一个无参数函数,在调用时它会创建一个空的累加器实例,供数据收集过程使用。很明显,对于将累加器本身作为结果返回的收集器,比如我们的ToListCollector
,在对空流执行操作的时候,这个空的累加器也代表了收集过程的结果。在我们的ToListCollector
中,supplier
返回一个空的List
,如下所示:
public Supplier<List<T>> supplier { return -> new ArrayList<T>;}
请注意你也可以只传递一个构造函数引用:
public Supplier<List<T>> supplier { return ArrayList::new;}
2. 将元素添加到结果容器:accumulator
方法
accumulator方法会返回执行归约操作的函数。当遍历到流中第 n 个元素时,这个函数执行时会有两个参数:保存归约结果的累加器(已收集了流中的前 n-1 个项目),还有第 n 个元素本身。该函数将返回void
,因为累加器是原位更新,即函数的执行改变了它的内部状态以体现遍历的元素的效果。对于ToListCollector
,这个函数仅仅会把当前项目添加至已经遍历过的项目的列表:
public BiConsumer<List<T>, T> accumulator { return (list, item) -> list.add(item);}
你也可以使用方法引用,这会更为简洁:
public BiConsumer<List<T>, T> accumulator { return List::add;}
3. 对结果容器应用最终转换:finisher
方法
在遍历完流后,finisher
方法必须返回在累积过程的最后要调用的一个函数,以便将累加器对象转换为整个集合操作的最终结果。通常,就像ToListCollector
的情况一样,累加器对象恰好符合预期的最终结果,因此无需进行转换。所以finisher
方法只需返回identity
函数:
public Function<List<T>, List<T>> finisher { return Function.identity;}
这三个方法已经足以对流进行顺序归约,至少从逻辑上看可以按图6-7进行。实践中的实现细节可能还要复杂一点,一方面是因为流的延迟性质,可能在collect
操作之前还需要完成其他中间操作的流水线,另一方面则是理论上可能要进行并行归约。
图 6-7 顺序归约过程的逻辑步骤
4. 合并两个结果容器:combiner
方法
四个方法中的最后一个——combiner
方法会返回一个供归约操作使用的函数,它定义了对流的各个子部分进行并行处理时,各个子部分归约所得的累加器要如何合并。对于toList
而言,这个方法的实现非常简单,只要把从流的第二个部分收集到的项目列表加到遍历第一部分时得到的列表后面就行了:
public BinaryOperator<List<T>> combiner { return (list1, list2) -> { list1.addAll(list2); return list1; }}
有了这第四个方法,就可以对流进行并行归约了。它会用到Java 7中引入的分支/合并框架和Spliterator
抽象,我们会在下一章中讲到。这个过程类似于图6-8所示,这里会详细介绍。
原始流会以递归方式拆分为子流,直到定义流是否需要进一步拆分的一个条件为非(如果分布式工作单位太小,并行计算往往比顺序计算要慢,而且要是生成的并行任务比处理器内核数多很多的话就毫无意义了)。
现在,所有的子流都可以并行处理,即对每个子流应用图6-7所示的顺序归约算法。
最后,使用收集器
combiner
方法返回的函数,将所有的部分结果两两合并。这时会把原始流每次拆分时得到的子流对应的结果合并起来。
图 6-8 使用combiner
方法来并行化归约过程
5. characteristics
方法
最后一个方法——characteristics
会返回一个不可变的Characteristics
集合,它定义了收集器的行为——尤其是关于流是否可以并行归约,以及可以使用哪些优化的提示。Characteristics
是一个包含三个项目的枚举。
UNORDERED
——归约结果不受流中项目的遍历和累积顺序的影响。CONCURRENT
——accumulator
函数可以从多个线程同时调用,且该收集器可以并行归约流。如果收集器没有标为