Java8实战-函数式数据处理-Steam流之收集器

一.收集器

收集器可以简洁而灵活地定义collect用来生成结果集合的标准。更具体地说,对流调用collect方法将对流中的元素触发一个归约操作(由Collector来参数化)。

案例,对一个交易列表按货币分组。

Map<Currency, List<Transaction>> transactionsByCurrencies =
 transactions.stream().collect(groupingBy(Transaction::getCurrency));

在这里插入图片描述

收集器的功能可以从Collectors类提供的工厂方法(例如groupingBy)创建的收集器。它们主要提供了三大功能:

  1. 将流元素归约和汇总为一个值
  2. 元素分组
  3. 元素分区

二.归约和汇总

  1. 查找流中的最大值和最小值

Collectors.maxByCollectors.minBy,来计算流中的最大或最小值。
这两个收集器接收一个Comparator参数来比较流中的元素。

假设你想要找出菜单中热量最高的菜。

//以创建一个Comparator来根据所含热量对菜肴进行比较,并把它传递给Collectors.maxBy
Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories); 
Optional<Dish> mostCalorieDish = menu.stream() .collect(maxBy(dishCaloriesComparator)); 
  1. 汇总

Collectors类专门为汇总提供了一个工厂方法:Collectors.summingInt。它可接受一个把对象映射为求和所需int的函数,并返回一个收集器;该收集器在传递给普通的collect方法后即执行我们需要的汇总操作。

举个例子,样求出菜单中菜肴列表的总热量

//在遍历流时,会把每一道菜都映射为其热量,然后把这个数字累加到一个累加器(这里的初始值0)。
        List<Dish> menu = new ArrayList<>();//菜单
        menu.add(new Dish(500,"麻婆豆腐"));
        menu.add(new Dish(400,"爆炒猪肝"));
        menu.add(new Dish(400,"油焖茄子"));
        menu.add(new Dish(400,"蒜蓉龙虾"));
		int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));

//更简洁可以如下写法,上行代码介绍summingInt
		int totalCalories = menu.stream().mapToInt(Dish::getCalories).sum();

Collectors.summingLongCollectors.summingDouble方法的作用完全一样,可以用于求和字段为long或double的情况。
在这里插入图片描述

汇总不仅仅是求和;还有Collectors.averagingInt,连同对应的averagingLongaveragingDouble可以计算数值的平均数:

        List<Dish> menu = new ArrayList<>();//菜单
        menu.add(new Dish(500,"麻婆豆腐"));
        menu.add(new Dish(400,"爆炒猪肝"));
        menu.add(new Dish(400,"油焖茄子"));
        menu.add(new Dish(400,"蒜蓉龙虾"));
		double avgCalories = menu.stream().collect(averagingInt(Dish::getCalories));

很多时候,可能想要得到两个或更多这样的结果,而且只需一次操作就可以完成。在这种情况下,你可以使用summarizingInt工厂方法返回的收集器。

例如,通过一次summarizing操作你可以就数出菜单中元素的个数,并得到菜肴热量总和、平均值、最大值和最小值:

        List<Dish> menu = new ArrayList<>();//菜单
        menu.add(new Dish(500,"麻婆豆腐"));
        menu.add(new Dish(400,"爆炒猪肝"));
        menu.add(new Dish(400,"油焖茄子"));
        menu.add(new Dish(400,"蒜蓉龙虾"));
        IntSummaryStatistics menuStatistics = menu.stream().collect(Collectors.summarizingInt(Dish::getCalories));
		System.out.println(menuStatistics);

这个收集器会把所有这些信息收集到一个叫作IntSummaryStatistics的类里,它提供了方便的取值(getter)方法来访问结果。打印menuStatisticobject会得到以下输出:

IntSummaryStatistics{count=4, sum=1700, min=400, average=425.000000, max=500}

同样,相应的summarizingLongsummarizingDouble工厂方法有相关的LongSummaryStatisticsDoubleSummaryStatistics类型,适用于收集的属性是原始类型long或double的情况。

  1. 连接字符串

joining工厂方法返回的收集器会把对流中每一个对象应用toString方法得到的所有字符串连接成一个字符串。

例如:

        List<Dish> menu = new ArrayList<>();//菜单
        menu.add(new Dish(500,"麻婆豆腐"));
        menu.add(new Dish(400,"爆炒猪肝"));
        menu.add(new Dish(400,"油焖茄子"));
        menu.add(new Dish(400,"蒜蓉龙虾"));
        String shortMenu = menu.stream().map(Dish::getName).collect(Collectors.joining());
        System.out.println(shortMenu);

结果:

麻婆豆腐爆炒猪肝油焖茄子蒜蓉龙虾

此结果可读性并不好。joining工厂方法有一个重载版本可以接受元素之间的分界符,就可以得到一个逗号分隔的列表:

String shortMenu = menu.stream().map(Dish::getName).collect(joining(", "));

结果:

麻婆豆腐,爆炒猪肝,油焖茄子,蒜蓉龙虾
  1. 广义的归约汇总

事实上所有收集器,都是一个可以用reducing工厂方法定义的归约过程的特殊情况而已。Collectors.reducing工厂方法是所有这些特殊情况的一般化。

用reducing方法创建的收集器来计算菜单的总热量,如下所示:

        List<Dish> menu = new ArrayList<>();//菜单
        menu.add(new Dish(500,"麻婆豆腐"));
        menu.add(new Dish(400,"爆炒猪肝"));
        menu.add(new Dish(400,"油焖茄子"));
        menu.add(new Dish(400,"蒜蓉龙虾"));
        int totalCalories = menu.stream().collect(Collectors.reducing(0, Dish::getCalories, (i, j) -> i + j));
        System.out.println(totalCalories);
        
        //简单写法:
        int totalCalories = menu.stream().map(Dish::getCalories).reduce(0, Integer::sum);

它需要三个参数。

  1. 第一个参数是归约操作的起始值,也是流中没有元素时的返回值,所以很显然对于数值和而言0是一个合适的值。
  2. 第二个参数就是Lambda的函数,将菜肴转换成一个表示其所含热量的int。
  3. 第三个参数是一个BinaryOperator,将两个项目累积成一个同类型的值。这里它就是对两个int求和。

可以使用下面这样单参数形式的reducing来找到热量最高的菜,如下所示:

        Optional<Dish> mostCalorieDish =
                menu.stream().collect(Collectors.reducing(
                        (d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));
        //简化写法:
        Optional<Dish> mostCalorieDish =
                menu.stream().reduce((d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2);

可以把单参数reducing工厂方法创建的收集器看作三参数方法的特殊情况,它把流中的第一个项目作为起点,把恒等函数(即一个函数仅仅是返回其输入参数)作为一个转换函数。这也意味着,要是把单参数reducing收集器传递给空流的collect方法,收集器就没有起点。


三.分组

  1. 简单分组

数据库操作会根据一个或多个属性对集合中的项目进行分组。Java 8所推崇的函数式风格来重写的话,就很容易转化为一个非常容易看懂的语句。

用Collectors.groupingBy工厂方法返回的收集器,根据不同厨师分组菜品:

        List<Dish> menu = new ArrayList<>();//菜单
        menu.add(new Dish(500, "麻婆豆腐", "王师傅"));
        menu.add(new Dish(400, "爆炒猪肝", "王师傅"));
        menu.add(new Dish(400, "油焖茄子", "李师傅"));
        menu.add(new Dish(400, "蒜蓉龙虾", "赵师傅"));
        Map<String, List<Dish>> map = menu.stream().collect(Collectors.groupingBy(Dish::getCooker));
        System.out.println(map);

这里给groupingBy方法传递了一个Function(以方法引用的形式),它提取了流中每一道Dish的Dish.cooker。我们把这个Function叫作分类函数,因为它用来把流中的元素分成不同的组。分组操作的结果是一个Map,把分组函数返回的值作为映射的键,把流中所有具有这个分类值的项目的列表作为对应的映射值。
在这里插入图片描述

  1. 多级分组

要实现多级分组,我们可以使用一个由双参数版本的Collectors.groupingBy工厂方法创建的收集器,它除了普通的分类函数之外,还可以接受collector类型的第二个参数。那么要进行二级分组的话,我们可以把一个内层groupingBy传递给外层groupingBy,并定义一个为流中项目分类的二级标准。

假设想把热量不到400卡路里的菜划分为“低热量”(diet),热量400到700卡路里的菜划为“普通”(normal),高于700卡路里的划为“高热量”(fat)。

public enum CaloricLevel { DIET, NORMAL, FAT } 

并且对菜单中的菜肴按照厨师和热量进行分组:

        Map<String, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel =
                menu.stream().collect(
                        Collectors.groupingBy(Dish::getCooker,
                                Collectors.groupingBy(dish -> {
                                    if (dish.getCalories() <= 400) {
                                        return CaloricLevel.DIET;
                                    } else if (dish.getCalories() <= 700) {
                                        return CaloricLevel.NORMAL;
                                    } else {
                                        return CaloricLevel.FAT;
                                    }})));
        System.out.println(dishesByTypeCaloricLevel);

结果:

李师傅={FAT=[Dish(calories=800, name=油焖茄子, cooker=李师傅)]}, 
王师傅={NORMAL=[Dish(calories=600, name=麻婆豆腐, cooker=王师傅)], DIET=[Dish(calories=300, name=爆炒猪肝, cooker=王师傅)]}, 
赵师傅={NORMAL=[Dish(calories=700, name=蒜蓉龙虾, cooker=赵师傅)]}

这种多级分组操作可以扩展至任意层级,n级分组就会得到一个代表n级树形结构的n级Map。一般来说,把groupingBy看作“桶”比较容易明白。第一个groupingBy给每个键建立了一个桶。然后再用下游的收集器去收集每个桶中的元素,以此得到n级分组。
在这里插入图片描述

  1. 按子组收集数据

我们可以通过多级分组把第二个groupingBy收集器传递给外层收集器来实现多级分组。但进一步说,传递给第一个groupingBy的第二个收集器可以是任何类型,而不一定是另一个groupingBy。

例如,要数一数菜单中每个厨师下有多少菜品,可以传递counting收集器作为groupingBy收集器的第二个参数

        List<Dish> menu = new ArrayList<>();//菜单;
        menu.add(new Dish(600, "麻婆豆腐", "王师傅"));
        menu.add(new Dish(300, "爆炒猪肝", "王师傅"));
        menu.add(new Dish(800, "油焖茄子", "李师傅"));
        menu.add(new Dish(700, "蒜蓉龙虾", "赵师傅"));

        Map<String, Long> typesCount = menu.stream()
                .collect(Collectors.groupingBy(Dish::getCooker,
                        Collectors.counting()));

其结果是下面的Map:

{李师傅=1, 王师傅=2, 赵师傅=1}

再举一个例子,查找菜单中厨师烧制的菜品热量最高的菜肴的收集器改一改,按照菜的厨师分类:

        Map<String, Optional<Dish>> mostCaloricByType = menu.stream()
                .collect(Collectors.groupingBy(Dish::getCooker,
                        Collectors.maxBy(
                                Comparator.comparingInt(Dish::getCalories))));
        System.out.println(mostCaloricByType);

其结果是下面的Map:

{
李师傅=Optional[Dish(calories=800, name=油焖茄子, cooker=李师傅)], 
王师傅=Optional[Dish(calories=600, name=麻婆豆腐, cooker=王师傅)], 
赵师傅=Optional[Dish(calories=700, name=蒜蓉龙虾, cooker=赵师傅)]
}

因为分组操作的Map结果中的每个值上包装的Optional没什么用,所以可能想要把它们去掉。要做到这一点,或者更一般地来说,把收集器返回的结果转换为另一种类型,可以使用Collectors.collectingAndThen工厂方法返回的收集器。

查找菜单中厨师烧制的菜品热量最高的菜肴的收集器改一改,按照菜的厨师分类:

        Map<String, Dish> mostCaloricByType = menu.stream()
                .collect(Collectors.groupingBy(Dish::getCooker,
                        Collectors.collectingAndThen(
                                Collectors.maxBy(Comparator.comparingInt(Dish::getCalories)),
                                Optional::get)));
        System.out.println(mostCaloricByType);

这个工厂方法接受两个参数:要转换的收集器以及转换函数,并返回另一个收集器。这个收集器相当于旧收集器的一个包装,collect操作的最后一步就是将返回值用转换函数做一个映射。在这里,被包起来的收集器就是用maxBy建立的那个,而转换函数Optional::get则把返回的Optional中的值提取出来。这个操作放在这里是安全的,因为reducing收集器永远都不会返回Optional.empty()。

其结果是下面的Map:

{
李师傅=Dish(calories=800, name=油焖茄子, cooker=李师傅), 
王师傅=Dish(calories=600, name=麻婆豆腐, cooker=王师傅), 
赵师傅=Dish(calories=700, name=蒜蓉龙虾, cooker=赵师傅)
}

在这里插入图片描述

分析:

  1. 收集器用虚线表示,因此groupingBy是最外层,根据菜肴的类型把菜单流分组,得到三个子流。
  2. groupingBy收集器包裹着collectingAndThen收集器,因此分组操作得到的每个子流都用这第二个收集器做进一步归约。
  3. collectingAndThen收集器又包裹着第三个收集器maxBy。
  4. 随后由归约收集器进行子流的归约操作,然后包含它的collectingAndThen收集器会对其结果应用Optional:get转换函数。
  5. 对三个子流分别执行这一过程并转换而得到的三个值,也就是各个厨师手中热量最高的Dish,将成为groupingBy收集器返回的Map中与各个分类键(Dish的类型)相关联的值。
  • [2] 与groupingBy联合使用的其他收集器的例子

通过groupingBy工厂方法的第二个参数传递的收集器将会对分到同一组中的所 有流元素执行进一步归约操作。

例如,求出所有菜肴热量总和的收集器,不过这次是对每个厨师的Dish求和:

        Map<String, Integer> totalCaloriesByType =
                menu.stream().collect(Collectors.groupingBy(Dish::getCooker,
                        Collectors.summingInt(Dish::getCalories)));
        System.out.println(totalCaloriesByType);

常常和groupingBy联合使用的另一个收集器是mapping方法生成的。这个方法接受两 个参数:一个函数对流中的元素做变换,另一个则将变换的结果对象收集起来。其目的是在累加 之前对每个输入元素应用一个映射函数,这样就可以让接受特定类型元素的收集器适应不同类型 的对象。

比方,对于每个厨师的Dish, 菜单中都有哪些CaloricLevel。

        List<Dish> menu = new ArrayList<>();//菜单;
        menu.add(new Dish(600, "麻婆豆腐", "王师傅"));
        menu.add(new Dish(300, "爆炒猪肝", "王师傅"));
        menu.add(new Dish(800, "油焖茄子", "李师傅"));
        menu.add(new Dish(700, "蒜蓉龙虾", "赵师傅"));

        Map<String, Set<CaloricLevel>> caloricLevelsByType =
                menu.stream().collect(
                        Collectors.groupingBy(Dish::getCooker, Collectors.mapping(
                                dish -> {
                                    if (dish.getCalories() <= 400) {
                                        return CaloricLevel.DIET;
                                    } else if (dish.getCalories() <= 700) {
                                        return CaloricLevel.NORMAL;
                                    } else {
                                        return CaloricLevel.FAT;
                                    }
                                },
                                Collectors.toSet())));
        System.out.println(caloricLevelsByType);

传递给映射方法的转换函数将Dish映射成了它的 CaloricLevel:生成的CaloricLevel流传递给一个toSet收集器,它和toList类似,不过是 把流中的元素累积到一个Set而不是List中,以便仅保留各不相同的值。

结果:

{李师傅=[FAT], 王师傅=[NORMAL, DIET], 赵师傅=[NORMAL]}

在上 一个示例中,对于返回的Set是什么类型并没有任何保证。但通过使用toCollection,你就可以有更多的控制。

例如,你可以给它传递一个构造函数引用来要求HashSet:

        Map<String, Set<CaloricLevel>> caloricLevelsByType =
                menu.stream().collect(
                        Collectors.groupingBy(Dish::getCooker, Collectors.mapping(
                                dish -> {
                                    if (dish.getCalories() <= 400) {
                                        return CaloricLevel.DIET;
                                    } else if (dish.getCalories() <= 700) {
                                        return CaloricLevel.NORMAL;
                                    } else {
                                        return CaloricLevel.FAT;
                                    }
                                },
                                Collectors.toCollection(HashSet::new))));

四.分区

分区是分组的特殊情况:由一个谓词(返回一个布尔值的函数)作为分类函数,它称分区函数。分区函数返回一个布尔值,这意味着得到的分组Map的键类型是Boolean,于是它最多可以分为两组——true是一组,false是一组。

例如,把菜单按照素食和非素食分开:

@Data
@AllArgsConstructor
public class Dish {

    private int calories;

    private String name;

    private String cooker;
    
    private boolean isVegetarian;
}
        List<Dish> menu = new ArrayList<>();//菜单;
        menu.add(new Dish(600, "麻婆豆腐", "王师傅",true));
        menu.add(new Dish(300, "爆炒猪肝", "王师傅",false));
        menu.add(new Dish(800, "油焖茄子", "李师傅",true));
        menu.add(new Dish(700, "蒜蓉龙虾", "赵师傅",false));

        Map<Boolean, List<Dish>> partitionedMenu = menu.stream().collect(Collectors.partitioningBy(Dish::isVegetarian));
        System.out.println(partitionedMenu);

这会返回下面的Map:

{
false=[Dish(calories=300, name=爆炒猪肝, cooker=王师傅, isVegetarian=false), 
			Dish(calories=700, name=蒜蓉龙虾, cooker=赵师傅, isVegetarian=false)],
true=[Dish(calories=600, name=麻婆豆腐, cooker=王师傅, isVegetarian=true), 
 			Dish(calories=800, name=油焖茄子, cooker=李师傅, isVegetarian=true)]
}

那么通过Map中键为true的值,就可以找出所有的素食菜肴了:

List<Dish> vegetarianDishes = partitionedMenu.get(true);

同样的分区谓词,对菜单List创建的流作筛选,然后把结果收集到另外一个List中也可以获得相同的结果:

List<Dish> vegetarianDishes = menu.stream().filter(Dish::isVegetarian).collect(Collectors.toList());

分区的优势
分区的好处在于保留了分区函数返回true或false的两套流元素列表。
partitioningBy 工厂方法有一个重载版本,可以传递第二个收集器。

例如:

        Map<Boolean, Map<String, List<Dish>>> vegetarianDishesByType =
                menu.stream().collect(
                        Collectors.partitioningBy(Dish::isVegetarian,
                        Collectors.groupingBy(Dish::getCooker)));
        System.out.println(vegetarianDishesByType);

结果:

{
false={王师傅=[Dish(calories=300, name=爆炒猪肝, cooker=王师傅, isVegetarian=false)],
	   赵师傅=[Dish(calories=700, name=蒜蓉龙虾, cooker=赵师傅, isVegetarian=false)]
			}, 
true={李师傅=[Dish(calories=800, name=油焖茄子, cooker=李师傅, isVegetarian=true)],
 	  王师傅=[Dish(calories=600, name=麻婆豆腐, cooker=王师傅, isVegetarian=true)]
			}
}

同样,找到素食和非素 食中热量最高的菜:

        Map<Boolean, Dish> mostCaloricPartitionedByVegetarian =
                menu.stream().collect(
                        Collectors.partitioningBy(Dish::isVegetarian,
                                Collectors.collectingAndThen(
                                        Collectors.maxBy(
                                        Comparator.comparingInt(Dish::getCalories)),
                                        Optional::get)));
        System.out.println(mostCaloricPartitionedByVegetarian);

结果:

{false=Dish(calories=700, name=蒜蓉龙虾, cooker=赵师傅, isVegetarian=false),
 true=Dish(calories=800, name=油焖茄子, cooker=李师傅, isVegetarian=true)}

五.总结

Collectors类的静态工厂方法

  • toList 把流中所有对象收集到一个List

使用示例:List dishes = menuStream.collect(toList());

  • toSet 把流中所有对象收集到一个Set,删除重复项

使用示例:Set dishes = menuStream.collect(toSet());

  • toCollection 把流中所有对象收集到给定的供应源创建的集合

使用示例:Collection dishes = menuStream.collect(toCollection(), ArrayList::new);

  • counting 计算流中元素的个数

使用示例:long howManyDishes = menuStream.collect(counting());

  • summingInt 对流中对象的一个整数属性求和

使用示例:int totalCalories = menuStream.collect(summingInt(Dish::getCalories));

  • averagingInt 计算流中对象Integer属性的平均值

使用示例:double avgCalories = menuStream.collect(averagingInt(Dish::getCalories));

  • summarizingInt 收集关于流中对象 Integer 属性的统计值,例如最大、最小、总和与平均值

使用示例:IntSummaryStatistics menuStatistics = menuStream.collect(summarizingInt(Dish::getCalories));

  • joining 连接对流中每个对象调用toString方法所生成的字符串

使用示例:String shortMenu = menuStream.map(Dish::getName).collect(joining(", "));

  • maxBy 一个包裹了流中按照给定比较器选出的最大元素的 Optional, 或如果流为空则为 Optional.empty()

使用示例:Optional fattest = menuStream.collect(maxBy(comparingInt(Dish::getCalories)));

  • minBy 一个包裹了流中按照给定比较器选出的最小元素的 Optional, 或如果流为空则为 Optional.empty()

使用示例:Optional 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 的键

使用示例:Map<String,List> dishesByCooker = menuStream.collect(groupingBy(Dish::getCooker));

  • partitioningBy 根据对流中每个对象应用谓词的结果来对项目进行分区

使用示例:Map<Boolean,List> vegetarianDishes = menu.stream().collect(Collectors.partitioningBy(Dish::isVegetarian));


over
在这里插入图片描述

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

筑梦的熊

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值