《Java 8 in Action》【06】----用流收集数据(一)

文章目录

          • 1.收集器简介
          • 2.归约和汇总
            • 2.1 具体汇总和归约工厂方法
            • 2.2 广义的归约汇总
          • 3.分组
            • 4.1 多级分组
            • 4.2 按子组收集数据
          • 4.分区
          • 5.总结

1.收集器简介

  Java8中的流支持两种类型的操作:中间操作(如filter或者map)和终端操作(如countfindFirstforEachreduce)。中间操作可以链接起来,将一个流转换为另一个流,这些操作不会消耗流,其目的是建立一个流水线。与此相反,终端操作会消耗流,以产生一个最终结果,如返回流中最大元素。本文主要介绍的是另一个终端操作collect,具体来说就是对流调用collect方法将对流中的元素触发一个归约操作。
collect方法有两种重载版本,其中一个版本可以接受Collector作为参数,通常Collector会对元素应用一个转换函数,并将结果积累在一个数据结构中并进行最终的输出。Collector接口里的方法实现决定了如何对流执行归约操作。可以自定义一个Collector实现类,也可以用Collectors类中提供的静态工厂方法来方便地创建常见的收集器实例。最直接和最常用的收集器是toList静态方法,它会将流中所有元素收集到一个List中。

List<Transaction> transactions = transactionStream.collect(Collectors.toList());

Collectors类提供的静态方法创建的收集器,它们提供的功能主要有三类:

  • 将流元素归约和汇总为一个值
  • 元素分组
  • 元素分区
2.归约和汇总
2.1 具体汇总和归约工厂方法

——————流中元素的数量:Collectors.counting()
  假设需要看下菜单里面有多少菜肴,可以使用Collectors.counting()静态方法返回的收集器,如:

long howManyDishes = menu.stream().collect(Collectors.counting());
//更简洁方式
long howManyDishes = menu.stream().count();

——————查找流中的最大值和最小值:Collectors.maxByCollectors.minBy
 假设需要从菜单中找出热量最高或者最低的菜,可以使用Collectors.maxByCollectors.minBy收集器,来计算流中的最大值和最小值,这两个收集器接收一个Comparator参数来比较流中的元素。

Optional<Dish> mostCalorieDish = menu.stream()
	.collect(maxBy(comparingInt(Dish::getCalories)));

 此处的Optional解决的是menu为空的情况,Java8中的Optional表示一个可以包含也可以不包含值的容器,这里用它来表示可能也可能不返回菜肴的情况。

——————字段求和:Collectors.summingXxxx()和求平均数:Collectors.averagingXxxx()

1.【求和】Collectors.summingIntCollectors.summingLongCollectors.summingDouble
Collectors类为三个基础数据类型intlongdouble分别提供了一个工厂方法Collectors.summingIntCollectors.summingLongCollectors.summingDouble,这些工厂方法可以接受一个把对象映射为对应求和字段的函数,并返回一个收集器,该收集器传递给collect方法后可以执行需要的汇总操作。例如求出菜单列表中的总热量:

int totalCalories = menu.stream()
	.collect(summingInt(Dish::getCalories));

2.【求平均数】Collectors.averagingIntCollectors.averagingLongCollectors.averagingDouble
 类似的Collectors.averagingIntCollectors.averagingLongCollectors.averagingDouble可以用来计算数值的平均数。

double avgCalories = menu.stream()
	.collect(Collectors.averagingInt(Dish::getCalories));

——————汇总信息: Collectors.summarizingInt()Collectors.summarizingLong()Collectors.summarizingDouble()
 前面分别介绍了使用流的收集器找到这些数值属性的最大值和最小值,以及计算其总和和平均值。不过有时如果需要一次操作得到其中两个或者更多这样结果,就可以使用Collectors.summarizingInt()Collectors.summarizingLong()Collectors.summarizingDouble()工厂方法。例如通过一次summarizing操作获取菜单中元素的数量,并得到菜热量总和、平均值、最大值和最小值这些信息。

IntSummaryStatistics menuStatistics = menu.stream()
	.collect(summarizingInt(Dish::getCalories));

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

IntSummaryStatistics{
     count=9, sum=4300, min=120,average=477.777778, max=800}

——————连接字符串:Collectors.joining()
joining工厂方法返回的收集器会把对流中每一个对象应用toString方法,得到的所有字符串连接起来的一个字符串。例如将菜单中的所有菜名连接起来:

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

 需要注意的是,joining在内部使用了StringBuilder来把生成的字符串逐个追加起来。上面案例,如果Dish类中toString()方法返回菜的名称,那么无需使用map提取每一道菜名称就可以得到相同结果:

String shortMenu = menu.stream()
	.collect(joining());

joining工厂方法有一个重载版本可以接受元素之间的分界符,用此方法可以得到一个逗号分隔的菜肴名称列表,如下:

String shortMenu = menu.stream()
	.map(Dish::getName)
	.collect(joining(", "));
2.2 广义的归约汇总

 上一节讨论的所有收集器, 实际上是Collectors.reducing工厂方法定义的归约过程是特殊情况。例如也可以使用reducing方法创建收集器来计算菜单的总热量。

int totalCalories = menu.stream()
	.collect(reducing(0, Dish::getCalories, (i, j) -> i + j));

 它接收三个参数:

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

同样地可以使用单参数形式的reducing来找到热量最高的菜。

Optional<Dish> mostCalorieDish = menu.stream()
	.collect(reducing((d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));

 可以将单参数的reducing工厂方法创建的收集器看作三参数方法的特殊情况,它把流中第一个项目作为起点,把恒等函数(即一个函数仅仅是返回其输入参数)作为一个转换函数。这意味着,要把单参数reducing收集器传递给空流的collect方法,收集器就没有起点。收集框架比较灵活,可以以不同的方式执行同样的操作。前面使用reducing收集器求和例子,可以将Lambda累积函数换成Integer类sum方法引用。如下:

int totalCalories = menu.stream()
	.collect(reducing(0, //初始值
					Dish::getCalories,//转换函数
					Integer::sum)); //累积函数

 归约操作的工作原理就是利用累积函数,把一个初始值为起始值的累加器,和把转换函数应用到流中每一个元素上得到的结果不断迭代合起来。前面提到的Collectors.counting()收集器也是利用三参数reducing工厂方法实现,它把流中的每个元素都转换成一个值为1的Long 型对象,然后再把它们相加:

public static <T> Collector<T, ?, Long> counting() {
     
	return reducing(0L, e -> 1L, Long::sum);
}

 另外一种方法不使用收集器也能执行相同操作,将菜流映射为每一道菜的热量,然后使用流Stream中的reduce方法:

int totalCalories = menu.stream()
	.map(Dish::getCalories)
	.reduce(Integer::sum).get();

 就像流的任何单参数reduce操作一样,reduce(Integer::sum) 返回的不是int而是Optional,以便在空流的情况下安全地执行归约操作。然后只需用Optional对象中的get方法来提取里面的值就行了。需要注意的是,在这种情况下使用get方法是安全的,只是因为可以确定菜流不为空,否则get方法可能出现异常情况,因此通常应该使用允许提供默认值的方法,如orElseorElseGet来获取Optional中包含的值更为安全。
 最后更简洁的方法是将流映射到IntStream,然后调用sum方法,可以得到相同的结果:

int totalCalories = menu.stream()
	.mapToInt(Dish::getCalories)
	.sum();

 从上面这个例子说明,函数式编程通常提供了多种方法来执行同一个操作。但通常来说,应该使用最专门化的一个。无论是从可读性还是性能上看,这一般都是最好的决定。例如获取菜单的总热量,最后一个使用IntStream的解决方案最简明,也易读,同时它的性能是最好的一个,因为IntStream避免了自动拆箱操作,也就是避免了从Integerint的隐式转换。

3.分组

 一个常见操作是根据一个或者多个属性对集合中的元素进行分组,用Java8之前的指令式编程风格实现的话,比较麻烦而且容易出错。而用Java8中函数式风格编写的代码简单,并且可读性强。例如要将菜单中的菜肴按照菜品分类,有肉的一组,有鱼的一组,其他的为另一个组。可以用Collectors.groupingBy工厂方法返回的收集器就可以轻松解决这个问题。

Map<Dish.Type, List<Dish>> dishesByType = menu.stream()
	.collect(groupingBy(Dish::getType));

 这里给groupingBy方法传递了一个Function(即Dish::getType方法引用形式),它提取了流中每一道Dish的Type ,将此Function称为分类函数,因为用它将流中的元素分成不同的组。分组操作的结果是一个Map,把分类函数返回的值作为Map的键,把流中所有具有这个分类值的项目的列表作为Map的值。在菜单分类的例子中,键就是菜的类型,值就是包含所有对应类型的菜肴的列表。有时分类函数不一定像方法引用那样可用,因为需要的分类条件可能比简单地用属性来分类要复杂。
 例如把热量不到400卡路里的菜划分为"低热量"(diet),热量400到700卡路里的菜划为"普通"(normal),高于700卡路里的划为"高热量"(fat)。由于Dish类没提供此类操作的方法,因此无法使用方法引用,但可以把这个逻辑写成Lambda表达式:

 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;
        }));
4.1 多级分组

 前面对菜单按照类型和热量分别进行了分组,但如果需要同时按照这两个标准分类时,即实现多级分组,这时候可以使用有两个参数版本的Collectors.groupingBy工厂方法创建的收集器,这个方法接受Collector类型的第二个参数。要进行二级分组,可以将一个内层groupingBy传递给外层groupingBy,并定义第二级标准来对流的项进行分类。

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的键就是第一级分类函数生成的值,外层Map的值又是一个Map,里层Map的键是第二级分类函数生成的值,第二级Map的值是流中元素构成的List,是分别应用第一级和第二级分类函数所得到的对应第一级和第二级键的值 。这种多级分组操作可以扩展至任意层级,n级分组就会得到一个代表n级树形结构的n级Map

4.2 按子组收集数据

 上面将第二个groupingBy收集器传递给外层收集器来实现了多级分组,实际上传递给第一个groupingBy的第二个收集器可以是任何类型,不一定是另外一个groupingBy,例如统计菜单中的每类菜的对应数量,可以传递counting收集器作为groupingBy收集器的第二个参数:

Map<Dish.Type, Long> typesCount = menu.stream()
	.collect(groupingBy(Dish::getType, counting()));

值得注意的是,单参数groupingBy(f) (其中 f 是分类函数)实际上是groupingBy(f,Collectors.toList()) 的简便写法。再比如,查找菜单中每类菜中热量最高的菜,这时需要先进行分类,然后第二个参数传递一个查找流中最大元素的收集器。

Map<Dish.Type, Optional<Dish>> mostCaloricByType = menu.stream()
	.collect(groupingBy(Dish::getType,maxBy(comparingInt(Dish::getCalories))));

——————把收集器的结果转换为另一种类型:Collectors.collectingAndThen
 回看上面返回结果Map,它的值是一个Optional类型,如果需要将收集器返回的结果Optional转换为另外一种类型,可以使用Collectors.collectingAndThen工厂方法返回的收集器。例如获取每个菜品中热量最高的Dish。

 Map<Dish.Type, Dish> mostCaloricByType = menu.stream()
            .collect(groupingBy(Dish::getType,
            				collectingAndThen(maxBy(comparingInt(Dish::getCalories)), 
                    							Optional::get)));

collectingAndThen工厂方法接受两个参数:要转换的收集器以及转化函数,并返回另外一个收集器。这个收集器相当于旧收集器的一个包装, collect操作的最后一步就是将返回值用转换函数做一个映射。本例中被包起来的收集器就是用maxBy建立的那个,而转换函数Optional::get则把返回的Optional中的值提取出来,这个操作在此处是安全的,因为收集器永远都不会返回Optional.empty()
 本例中最外层是groupingBy收集器,根据菜的类型将菜单流分组,得到了子流。groupingBy收集器包裹着collectingAndThen收集器,因此分组操作得到的每个子流都用第二个收集器作进一步归约。collectingAndThen收集器又包裹了第三个收集器maxBy,由归约收集器maxBy进行子流的归约操作,然后包含它的collectingAndThen收集器会对其结果应用Optional::get转换函数。

——————与 groupingBy 联合使用的其他收集器的例子
 一般来说,通过groupingBy工厂方法的第二个参数传递的收集器将会对分到同一个组中的所有流元素执行进一步的归约操作。例如对菜单中每一类的菜求热量的总和。

Map<Dish.Type, Integer> totalCaloriesByType = menu.stream()
	.collect(groupingBy(Dish::getType,summingInt(Dish::getCalories)));

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

 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;
            }, Collectors.toSet())));

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

4.分区

 分区是分组的特殊情况,partitioningBy工厂方法提供了此功能:有一个Predicate(返回一个boolean类型的函数)作为分类函数,它称为分区函数。分区函数返回一个布尔值,这意味着得到的分组Map的键类型是Boolean,于是它最多可以分为两组,true是一组,false是一组。例如将菜单中菜按照素食和非素食分开:

//先分区
Map<Boolean, List<Dish>> partitionedMenu = menu.stream()
	.collect(partitioningBy(Dish::isVegetarian));
//通过Map的键为true的值,找到所有的素食
List<Dish> vegetarianDishes = partitionedMenu.get(true);

 用同样的谓词,对菜单List创建的流作筛选,然后将结果收集到另外一个List中也可以获取相同的结果。

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

 分区的好处在于保留了分区函数返回true或者false的两套流元素列表,如果要获取非素食类型的Dish的List,可以直接从Map中取false键对应值即可,partitioningBy工厂方法还有一个重载版本,可以传递第二个收集器。例如可以对分区得到的素食和非素食子流,再分别按类型对菜肴分组,得到一个二级Map

Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishesByType = menu.stream()
	.collect(partitioningBy(Dish::isVegetarian,groupingBy(Dish::getType)));

 再例如从菜单中找到素食和非素食中热量最高的菜:

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

——————例子:将数字按质数和非质数分区
 假设要写一个方法,它接收参数int n,并将前n个数字分为质数和非质数。但首先找出能够测试某一个待测数字是否是质数的谓词predicate。

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 类的静态工厂方法能够创建的一些收集器和对应例子进行了汇总。给出了它们应用到 Stream 上返回的类型,以及它们用于一个叫作menuStreamStream 上的实际例子。
《Java 8 in Action》【06】----用流收集数据(一)_第1张图片
《Java 8 in Action》【06】----用流收集数据(一)_第2张图片

5.总结
  1. collect是一个终端操作,它接受的参数是将流中元素累积到汇总结果的各种方式(称为收集器)。
  2. 预定义收集器包括将流元素归约和汇总到一个值,例如计算最小值、最大值或平均值。
  3. 预定义收集器可以用groupingBy对流中元素进行分组,或用partitioningBy进行分区。
  4. 收集器可以高效地复合起来,进行多级分组、分区和归约。

你可能感兴趣的:(Java,8,java8)