Java 8 - 收集器Collectors_分组groupingBy
Pre
来看个小例子: 把菜单中的菜按照类型进行分类,有菜的放一组,有肉的放一组,其他的都放另一组。
Map<Dish.Type, List<Dish>> collect = menu.stream().collect(groupingBy(Dish::getType));
用 Collectors.groupingBy 工厂方法返回的收集器就可以轻松地完成这项任务。
这里,给 groupingBy 方法传递了一个 Function (以方法引用的形式),它提取了流中每一道 Dish 的 Dish.Type 。我们把这个 Function 叫作 分类函数 ,因为它用来把流中的元素分成不同的组。
如下图所示, 分组操作的结果是一个 Map ,把分组函数返回的值作为映射的键,把流中所有具有这个分类值的项目的列表作为对应的映射值。
在菜单分类的例子中,键就是菜的类型,值就是包含所有对应类型的菜的列表。
【第二个例子】
但是,分类函数不一定像方法引用那样可用,因为你想用以分类的条件可能比简单的属性访问器要复杂。
例如,你可能想把热量不到400卡路里的菜分为“低热量”(diet),热量400到700卡路里的菜为“普通”(normal),高于700卡路里的菜为“高热量”(fat)。
由于 Dish 类 没有把这个操作写成一个方法,你无法使用方法引用,但你可以把这个逻辑写成Lambda表达式:
public enum CaloricLevel { DIET, NORMAL, FAT }
Map<CaloricLevel, List<Dish>> collect = menu.stream().collect(groupingBy(dish -> {
if (dish.getCalories() > 300) {
return CaloricLevel.DIET;
} else if (dish.getCalories() <= 700) {
return CaloricLevel.NORMAL;
} else {
return CaloricLevel.FAT;
));
多级分组
现在,已经看到了如何对菜单中的菜肴按照类型和热量进行分组,但要是想同时按照这两个标准分类怎么办呢?分组的强大之处就在于它可以有效地组合。让我们来看看怎么做。
要实现多级分组,我们可以使用一个由双参数版本的 Collectors.groupingBy 工厂方法创建的收集器,它除了普通的分类函数之外,还可以接受 collector 类型的第二个参数。那么要进行二级分组的话,我们可以把一个内层 groupingBy 传递给外层 groupingBy ,并定义一个为流中项目分类的二级标准。
public static Map<Dish.Type, Map<CaloricLevel, List<Dish>>> duobleGroup(){
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> collect = 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;
System.out.println(collect);
return collect;
}
我们把Dish的toString方法改写一下
@Override
public String toString() {
return name;
}
输出
{MEAT={FAT=[pork], DIET=[chicken], NORMAL=[beef]}, OTHER={DIET=[rice, season fruit], NORMAL=[french fries, pizza]}, FISH={DIET=[prawns], NORMAL=[salmon]}}
输出的结果里的外层 Map 的键就是第一级分类函数生成的值:“fish, meat, other”, 而这个 Map 的值又是一个 Map ,键是二级分类函数生成的值:“normal, diet, fat”。最后,第二级 map 的值是流中元素构成的 List ,是分别应用第一级和第二级分类函数所得到的对应第一级和第二级键的值:“salmon、pizza…” 这种多级分组操作可以扩展至任意层级,n级分组就会得到一个代表n级树形结构的n级Map 。
一般来说,把 groupingBy 看作“桶”比较容易明白。第一个 groupingBy 给每个键建立了一个桶。然后再用下游的收集器去收集每个桶中的元素,以此得到n级分组。
按子组收集数据
上个例子中,我们看到可以把第二个 groupingBy 收集器传递给外层收集器来实现多级分组。但进一步说,传递给第一个 groupingBy 的第二个收集器可以是任何类型,而不一定是另一个 groupingBy 。
例如,要数一数菜单中每类菜有多少个,可以传递 counting 收集器作为groupingBy 收集器的第二个参数
menu.stream().collect(groupingBy(Dish::getType, counting()))
输出
{FISH=2, MEAT=3, OTHER=4}
还要注意, 普通的单参数 groupingBy(f) (其中 f 是分类函数)实际上是 groupingBy(f,toList()) 的简便写法。
再举一个例子,你可以把前面用于查找菜单中热量最高的菜的收集器改一改,按照菜的类型分类:
// 按类型分类
System.out.println(menu.stream().collect(groupingBy(Dish::getType)));
// 按类型分类,获取最高热量的菜
System.out.println(menu.stream().collect(groupingBy(Dish::getType, maxBy(Comparator.comparing(Dish::getCalories)))));
输出
{FISH=[prawns, salmon], OTHER=[french fries, rice, season fruit, pizza], MEAT=[pork, beef, chicken]}
{FISH=Optional[salmon], OTHER=Optional[pizza], MEAT=Optional[pork]}
这个 Map 中的值是 Optional ,因为这是 maxBy 工厂方法生成的收集器的类型,但实际上,如果菜单中没有某一类型的 Dish ,这个类型就不会对应一个 Optional. empty() 值,而且根本不会出现在 Map 的?中。 groupingBy 收集器只有在应用分组条件后,第一次在流中找到某个键对应的元素时才会把键加入到分组 Map 中。这意味着 Optional 包装器在这里不是很有用,因为它不会仅仅因为它是归约收集器的返回类型而表达一个最终可能不存在却意外存在的值。
【把收集器的结果转换为另一类型】
因为分组操作的 Map 结果中的每个值上包装的 Optional 没什么用,所以你可能想要把它们去掉。要做到这一点,或者更一般地来说,把收集器返回的结果转换为另一种类型,你可以使用
Collectors.collectingAndThen
工厂方法返回的收集器
查找每个子组中热量最高的 Dish
// 按类型分类,获取最高热量的菜
System.out.println(menu.stream().collect(groupingBy(Dish::getType,
collectingAndThen(maxBy(Comparator.comparing(Dish::getCalories)),Optional::get))));
输出
{FISH=salmon, OTHER=pizza, MEAT=pork}
这个工厂方法接受两个参数——要转换的收集器以及转换函数,并返回另一个收集器。这个收集器相当于旧收集器的一个包装, collect 操作的最后一步就是将返回值用转换函数做一个映射。在这里,被包起来的收集器就是用 maxBy 建立的那个,而转换函数 Optional::get 则把返回的 Optional 中的值提取出来。
这个操作放在这里是安全的,因为 reducing收集器永远都不会返回 Optional.empty() 。
图解工作过程
从最外层开始逐层向里,注意以下几点
- 收集器用虚线表示,因此 groupingBy 是最外层,根据菜肴的类型把菜单流分组,得到三个子流
- groupingBy 收集器包裹着 collectingAndThen 收集器,因此分组操作得到的每个子流都用这第二个收集器做进一步归约
- collectingAndThen 收集器又包裹着第三个收集器 maxBy
- 随后由归约收集器进行子流的归约操作,然后包含它的 collectingAndThen 收集器会对其结果应用 Optional:get 转换函数。
- 对三个子流分别执行这一过程并转换而得到的三个值,也就是各个类型中热量最高的Dish ,将成为 groupingBy 收集器返回的 Map 中与各个分类键( Dish 的类型)相关联的值。
与 groupingBy联合使用的其他收集器的例子
一般来说,通过 groupingBy 工厂方法的第二个参数传递的收集器将会对分到同一组中的所有流元素执行进一步归约操作。
例如,你还重用求出所有菜肴热量总和的收集器,不过这次是对每一组 Dish 求和
menu.stream().collect(groupingBy(Dish::getType,summingInt(Dish::getCalories)));
返回
{MEAT=1900, FISH=750, OTHER=1550}
然而常常和 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() )));
System.out.println(caloricLevelsByType);
输出:
{MEAT=[NORMAL, FAT, DIET], OTHER=[NORMAL, DIET], FISH=[NORMAL, DIET]}
传递给映?方法的转换函数将 Dish 映射成了它的CaloricLevel :生成的 CaloricLevel 流传递给一个 toSet 收集器,它和 toList 类似,不过是把流中的元素映射到一个 Set 而不是 List 中,以便仅保留各不相同的值。
请注意在上一个示例中,对于返回的 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))));
System.out.println(caloricLevelsByType);
输出
{FISH=[DIET, NORMAL], OTHER=[DIET, NORMAL], MEAT=[DIET, FAT, NORMAL]}
附
public static List<Dish> menu = Arrays.asList(
new Dish("pork", false, 800, Dish.Type.MEAT),
new Dish("beef", false, 700, Dish.Type.MEAT),
new Dish("chicken", false, 400, Dish.Type.MEAT),