Java8中的流支持两种类型的操作:中间操作(如filter
或者map
)和终端操作(如count
、findFirst
、forEach
和reduce
)。中间操作可以链接起来,将一个流转换为另一个流,这些操作不会消耗流,其目的是建立一个流水线。与此相反,终端操作会消耗流,以产生一个最终结果,如返回流中最大元素。本文主要介绍的是另一个终端操作collect
,具体来说就是对流调用collect
方法将对流中的元素触发一个归约操作。
collect
方法有两种重载版本,其中一个版本可以接受Collector
作为参数,通常Collector
会对元素应用一个转换函数,并将结果积累在一个数据结构中并进行最终的输出。Collector
接口里的方法实现决定了如何对流执行归约操作。可以自定义一个Collector
实现类,也可以用Collectors
类中提供的静态工厂方法来方便地创建常见的收集器实例。最直接和最常用的收集器是toList
静态方法,它会将流中所有元素收集到一个List
中。
List<Transaction> transactions = transactionStream.collect(Collectors.toList());
Collectors
类提供的静态方法创建的收集器,它们提供的功能主要有三类:
——————流中元素的数量:Collectors.counting()
假设需要看下菜单里面有多少菜肴,可以使用Collectors.counting()
静态方法返回的收集器,如:
long howManyDishes = menu.stream().collect(Collectors.counting());
//更简洁方式
long howManyDishes = menu.stream().count();
——————查找流中的最大值和最小值:Collectors.maxBy
和Collectors.minBy
假设需要从菜单中找出热量最高或者最低的菜,可以使用Collectors.maxBy
和Collectors.minBy
收集器,来计算流中的最大值和最小值,这两个收集器接收一个Comparator
参数来比较流中的元素。
Optional<Dish> mostCalorieDish = menu.stream()
.collect(maxBy(comparingInt(Dish::getCalories)));
此处的Optional
解决的是menu为空的情况,Java8中的Optional
表示一个可以包含也可以不包含值的容器,这里用它来表示可能也可能不返回菜肴的情况。
——————字段求和:Collectors.summingXxxx()
和求平均数:Collectors.averagingXxxx()
1.【求和】: Collectors.summingInt
、Collectors.summingLong
和Collectors.summingDouble
。
Collectors
类为三个基础数据类型int
、long
、double
分别提供了一个工厂方法Collectors.summingInt
、Collectors.summingLong
和 Collectors.summingDouble
,这些工厂方法可以接受一个把对象映射为对应求和字段的函数,并返回一个收集器,该收集器传递给collect方法后可以执行需要的汇总操作。例如求出菜单列表中的总热量:
int totalCalories = menu.stream()
.collect(summingInt(Dish::getCalories));
2.【求平均数】: Collectors.averagingInt
、Collectors.averagingLong
和Collectors.averagingDouble
。
类似的Collectors.averagingInt
、Collectors.averagingLong
和Collectors.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(", "));
上一节讨论的所有收集器, 实际上是Collectors.reducing
工厂方法定义的归约过程是特殊情况。例如也可以使用reducing
方法创建收集器来计算菜单的总热量。
int totalCalories = menu.stream()
.collect(reducing(0, Dish::getCalories, (i, j) -> i + j));
它接收三个参数:
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方法可能出现异常情况,因此通常应该使用允许提供默认值的方法,如orElse
或orElseGet
来获取Optional
中包含的值更为安全。
最后更简洁的方法是将流映射到IntStream
,然后调用sum
方法,可以得到相同的结果:
int totalCalories = menu.stream()
.mapToInt(Dish::getCalories)
.sum();
从上面这个例子说明,函数式编程通常提供了多种方法来执行同一个操作。但通常来说,应该使用最专门化的一个。无论是从可读性还是性能上看,这一般都是最好的决定。例如获取菜单的总热量,最后一个使用IntStream
的解决方案最简明,也易读,同时它的性能是最好的一个,因为IntStream
避免了自动拆箱操作,也就是避免了从Integer
到int
的隐式转换。
一个常见操作是根据一个或者多个属性对集合中的元素进行分组,用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;
}));
前面对菜单按照类型和热量分别进行了分组,但如果需要同时按照这两个标准分类时,即实现多级分组,这时候可以使用有两个参数版本的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
。
上面将第二个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 。可以把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;
}, Collectors.toSet())));
这段代码也可以这么理解,传递给映射方法的转换函数将Dish映射成了它的CaloricLevel,生成CaloricLevel流传递给一个toSet
收集器,它和toList
相似,不过是将流中的元素累积到一个Set
而不是List
中,以便仅保留各不相同的值。
分区是分组的特殊情况,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
上返回的类型,以及它们用于一个叫作menuStream
的Stream
上的实际例子。
collect
是一个终端操作,它接受的参数是将流中元素累积到汇总结果的各种方式(称为收集器)。groupingBy
对流中元素进行分组,或用partitioningBy
进行分区。