你可以把Java8的流看做花哨又懒惰的数据集迭代器。他们支持两种类型的操作:中间操作(e.g. filter, map)和终端操作(如count, findFirst, forEach, reduce). 中间操作可以连接起来,将一个流转换为另一个流。这些操作不会消耗流,其目的是建立一个流水线。与此相反,终端操作会消耗类,产生一个最终结果。
collect就是一个归约操作,就像reduce一样可以接受各种做法作为参数,将流中的元素累积成一个汇总结果。具体的做法是通过定义新的Collector接口来定义的。
案例:
最大值,最小值,平均值
// 为啥返回Optional? 如果stream为null怎么办, 这时候Optinal就很有意义了
Optional<Dish> mostCalorieDish = dishes.stream().max(Comparator.comparingInt(Dish::getCalories));
Optional<Dish> minCalorieDish = dishes.stream().min(Comparator.comparingInt(Dish::getCalories));
Double avgCalories = dishes.stream().collect(Collectors.averagingInt(Dish::getCalories));
IntSummaryStatistics summaryStatistics = dishes.stream().collect(Collectors.summarizingInt(Dish::getCalories));
double average = summaryStatistics.getAverage();
long count = summaryStatistics.getCount();
int max = summaryStatistics.getMax();
int min = summaryStatistics.getMin();
long sum = summaryStatistics.getSum();
连接收集器
String join1 = dishes.stream().map(Dish::getName).collect(Collectors.joining()); //直接连接
String join2 = dishes.stream().map(Dish::getName).collect(Collectors.joining(", ")); //逗号
list.stream().collect(Collectors.joings(",")) ; //将List集合中的元素转换为一个流,然后将这些元素连接成一个以逗号分隔的字符串
toList
//将原来的Stream映射为一个单元素流,然后收集为List
List<String> names = dishes.stream().map(Dish::getName).collect(toList());
toSet
//将Type收集为一个set,可以去重复
Set<Type> types = dishes.stream().map(Dish::getType).collect(Collectors.toSet());
toMap
//Dish::getType:表示使用 Dish 对象的 getType() 方法作为 Map 的键。 d -> d:表示将每个 Dish 对象本身作为 Map 的值
Map<Type, Dish> byType = dishes.stream().collect(toMap(Dish::getType, d -> d));
有时候可能需要将一个数组转为map,做缓存,方便多次计算获取。toMap提供的方法k和v的生成函数。(注意,上述demo是一个坑,不可以这样用!!!, 请使用toMap(Function, Function, BinaryOperator)) 。
toMap(Function, Function, BinaryOperator) 是 Collectors.toMap() 方法的一个重载版本,用于在收集元素到 Map 的过程中处理重复键的情况。让我们来解释这三个参数的含义:
第一个参数 Function:这个函数指定了如何从元素中提取键。在这个参数中,你可以传入一个函数,用于从元素中提取键值。在这个例子中,Dish::getType 表示使用 Dish 对象的 getType() 方法作为键。
第二个参数 Function:这个函数指定了如何从元素中提取值。在这个参数中,你可以传入一个函数,用于从元素中提取值。在这个例子中,d -> d 表示将每个 Dish 对象本身作为值。
第三个参数 BinaryOperator:这个函数定义了当出现重复键时如何处理这些值。如果 Map 中已经存在相同的键,BinaryOperator 将被调用来决定如何合并现有值和新值。常见的操作包括覆盖现有值、保留现有值或者自定义合并逻辑。
//Dish::getType 用于提取键;Function.identity() 用于提取值,这里表示保留原始值;(existing, replacement) -> replacement 是一个 BinaryOperator,它表示当出现重复键时,选择新值覆盖旧值。
Map<Type, Dish> byType = dishes.stream().collect(Collectors.toMap(Dish::getType, Function.identity(), (existing, replacement) -> replacement));
// (existing, replacement) -> existing 是一个 BinaryOperator,它表示当出现重复键时,选择保留已经存在的值而不覆盖。
Map<Type, Dish> byType = dishes.stream() .collect(Collectors.toMap(Dish::getType, Function.identity(), (existing, replacement) -> existing));
自定义收集器
自定义归约reducing
前面几个都是reducing工厂方法定义的归约过程的特殊情况,其实可以用Collectors.reducing创建收集器。比如,求和
// reducing收集器是一个可以用来累加元素的收集器,它接受三个参数:初始值、转换函数和累加器函数。
Integer totalCalories = dishes.stream().collect(reducing(0, Dish::getCalories, (i, j) -> i + j));
//使用内置函数代替箭头函数
Integer totalCalories2 = dishes.stream().collect(reducing(0, Dish::getCalories, Integer::sum));
// reducing除了接收一个初始值,还可以把第一项当作初始值
Optional<Dish> mostCalorieDish = dishes.stream().collect(reducing((d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));
当然也可以直接使用reduce
// map(Dish::getCalories)将每个菜肴对象映射为其热量值
Optional<Integer> totalCalories3 = dishes.stream().map(Dish::getCalories).reduce(Integer::sum);
虽然都可以,但考量效率的话,还是要选择下面这种
intsum = dishes.stream().mapToInt(Dish::getCalories).sum();
reducing
关于reducing的用法比较复杂,目标在于把两个值合并成一个值。
public static<T, U>
Collector<T, ?, U> reducing(U identity,
Function<? superT, ? extendsU> mapper,
BinaryOperator<U> op)
首先看到3个泛型,
U是返回值的类型,比如上述demo中计算热量的,U就是Integer。
关于T,T是Stream里的元素类型。由Function的函数可以知道,mapper的作用就是接收一个参数T,然后返回一个结果U。对应demo中Dish。
?在返回值Collector的泛型列表的中间,这个表示容器类型,一个收集器当然需要一个容器来存放数据。这里的?则表示容器类型不确定。事实上,在这里的容器就是U[]。
关于参数:
identity是返回值类型的初始值,可以理解为累加器的起点。
mapper则是map的作用,意义在于将Stream流转换成你想要的类型流。
op则是核心函数,作用是如何处理两个变量。其中,第一个变量是累积值,可以理解为sum,第二个变量则是下一个要计算的元素。从而实现了累加。
reducing还有一个重载的方法,可以省略第一个参数,意义在于把Stream里的第一个参数当做初始值。
public static<T> Collector<T, ?, Optional<T>>
reducing(BinaryOperator<T> op)
先看返回值的区别,T表示输入值和返回值类型,即输入值类型和输出值类型相同。还有不同的就是Optional了。这是因为没有初始值,而第一个参数有可能是null,当Stream的元素是null的时候,返回Optional就很意义了。
再看参数列表,只剩下BinaryOperator。BinaryOperator是一个三元组函数接口,目标是将两个同类型参数做计算后返回同类型的值。可以按照1>2? 1:2来理解,即求两个数的最大值。求最大值是比较好理解的一种说法,你可以自定义lambda表达式来选择返回值。那么,在这里,就是接收两个Stream的元素类型T,返回T类型的返回值。用sum累加来理解也可以。
上述的demo中发现reduce和collect的作用几乎一样,都是返回一个最终的结果,比如,我们可以使用reduce实现toList效果:
//手动实现toListCollector --- 滥用reduce, 不可变的规约---不可以并行
List<Integer> calories = dishes.stream().map(Dish::getCalories)
.reduce(newArrayList<Integer>(),
(List<Integer> l, Integer e) -> {
l.add(e);
returnl;
},
(List<Integer> l1, List<Integer> l2) -> {
l1.addAll(l2);
returnl1;
}
);
关于上述做法解释一下。
<U> U reduce(U identity,
BiFunction<U, ? superT, U> accumulator,
BinaryOperator<U> combiner);
U是返回值类型,这里就是List
BiFunction accumulator是是累加器,目标在于累加值和单个元素的计算规则。这里就是List和元素做运算,最终返回List。即,添加一个元素到list。
BinaryOperator combiner是组合器,目标在于把两个返回值类型的变量合并成一个。这里就是两个list合并。
这个解决方案有两个问题:一个是语义问题,一个是实际问题。语义问题在于,reduce方法旨在把两个值结合起来生成一个新值,它是一个不可变归约。相反,collect方法的设计就是要改变容器,从而累积要输出的结果。这意味着,上面的代码片段是在滥用reduce方法,因为它在原地改变了作为累加器的List。错误的语义来使用reduce方法还会造成一个实际问题:这个归约不能并行工作,因为由多个线程并发修改同一个数据结构可能会破坏List本身。在这种情况下,如果你想要线程安全,就需要每次分配一个新的List,而对象分配又会影响性能。这就是collect适合表达可变容器上的归约的原因,更关键的是它适合并行操作。
总结:reduce适合不可变容器归约,collect适合可变容器归约。collect适合并行
分组
数据库中经常遇到分组求和的需求,提供了group by原语。在Java里, 如果按照指令式风格(手动写循环)的方式,将会非常繁琐,容易出错。而Java8则提供了函数式解法。
比如,将dish按照type分组。和前面的toMap类似,但分组的value却不是一个dish,而是一个List。
Map<Type, List<Dish>> dishesByType = dishes.stream().collect(groupingBy(Dish::getType));
这里
publicstatic<T, K> Collector<T, ?, Map<K, List<T>>>
groupingBy(Function<? superT, ? extendsK> classifier)
参数分类器为Function,旨在接收一个参数,转换为另一个类型。上面的demo就是把stream的元素dish转成类型Type,然后根据Type将stream分组。其内部是通过HashMap来实现分组的。groupingBy(classifier, HashMap::new, downstream);
除了按照stream元素自身的属性函数去分组,还可以自定义分组依据,比如根据热量范围分组。
既然已经知道groupingBy的参数为Function, 并且Function的参数类型为Dish,那么可以自定义分类器为:
private CaloricLevel getCaloricLevel(Dish d) {
if(d.getCalories() <= 400) {
returnCaloricLevel.DIET;
} elseif(d.getCalories() <= 700) {
returnCaloricLevel.NORMAL;
} else{
returnCaloricLevel.FAT;
}
}
再传入参数即可
Map<CaloricLevel, List<Dish>> dishesByLevel = dishes.stream().collect(groupingBy(this::getCaloricLevel));
多级分组
groupingBy还重载了其他几个方法,比如
publicstatic<T, K, A, D>
Collector<T, ?, Map<K, D>> groupingBy(Function<? superT, ? extendsK> classifier,
Collector<? superT, A, D> downstream)
泛型多的恐怖。简单的认识一下。classifier还是分类器,就是接收stream的元素类型,返回一个你想要分组的依据,也就是提供分组依据的基数的。所以T表示stream当前的元素类型,K表示分组依据的元素类型。第二个参数downstream,下游是一个收集器Collector. 这个收集器元素类型是T的子类,容器类型container为A,reduction返回值类型为D。也就是说分组的K通过分类器提供,分组的value则通过第二个参数的收集器reduce出来。正好,上个demo的源码为:
public static<T, K> Collector<T, ?, Map<K, List<T>>>
groupingBy(Function<? superT, ? extendsK> classifier) {
returngroupingBy(classifier, toList());
}
将toList当作reduce收集器,最终收集的结果是一个List<Dish>, 所以分组结束的value类型是List<Dish>。那么,可以类推value类型取决于reduce收集器,而reduce收集器则有千千万。比如,我想对value再次分组,分组也是一种reduce。
//多级分组
Map<Type, Map<CaloricLevel, List<Dish>>> byTypeAndCalory = dishes.stream().collect(
groupingBy(Dish::getType, groupingBy(this::getCaloricLevel)));
byTypeAndCalory.forEach((type, byCalory) -> {
System.out.println("----------------------------------");
System.out.println(type);
byCalory.forEach((level, dishList) -> {
System.out.println("\t"+ level);
System.out.println("\t\t"+ dishList);
});
});
验证结果为:
----------------------------------
FISH
DIET
[Dish(name=prawns, vegetarian=false, calories=300, type=FISH)]
NORMAL
[Dish(name=salmon, vegetarian=false, calories=450, type=FISH)]
----------------------------------
MEAT
FAT
[Dish(name=pork, vegetarian=false, calories=800, type=MEAT)]
DIET
[Dish(name=chicken, vegetarian=false, calories=400, type=MEAT)]
NORMAL
[Dish(name=beef, vegetarian=false, calories=700, type=MEAT)]
----------------------------------
OTHER
DIET
[Dish(name=rice, vegetarian=true, calories=350, type=OTHER), Dish(name=season fruit, vegetarian=true, calories=120, type=OTHER)]
NORMAL
[Dish(name=french fries, vegetarian=true, calories=530, type=OTHER), Dish(name=pizza, vegetarian=true, calories=550, type=OTHER)]
总结:groupingBy的核心参数为K生成器,V生成器。V生成器可以是任意类型的收集器Collector。
比如,V生成器可以是计算数目的, 从而实现了sql语句中的select count(*) from table A group by Type
Map<Type, Long> typesCount = dishes.stream().collect(groupingBy(Dish::getType, counting()));
System.out.println(typesCount);
-----------
{FISH=2, MEAT=3, OTHER=4}
sql查找分组最高分select MAX(id) from table A group by Type
Map<Type, Optional<Dish>> mostCaloricByType = dishes.stream()
.collect(groupingBy(Dish::getType, maxBy(Comparator.comparingInt(Dish::getCalories))));
这里的Optional没有意义,因为肯定不是null。那么只好取出来了。使用collectingAndThen
Map<Type, Dish> mostCaloricByType = dishes.stream()
.collect(groupingBy(Dish::getType,
collectingAndThen(maxBy(Comparator.comparingInt(Dish::getCalories)), Optional::get)));
到这里似乎结果出来了,但IDEA不同意,编译黄色报警,按提示修改后变为:
Map<Type, Dish> mostCaloricByType = dishes.stream()
.collect(toMap(Dish::getType, Function.identity(),
BinaryOperator.maxBy(comparingInt(Dish::getCalories))));
是的,groupingBy就变成toMap了,key还是Type,value还是Dish,但多了一个参数!!这里回应开头的坑,开头的toMap演示是为了容易理解,真那么用则会被搞死。我们知道把一个List重组为Map必然会面临k相同的问题。当K相同时,v是覆盖还是不管呢?前面的demo的做法是当k存在时,再次插入k则直接抛出异常:
java.lang.IllegalStateException: Duplicate key Dish(name=pork, vegetarian=false, calories=800, type=MEAT)
at java.util.stream.Collectors.lambda$throwingMerger$0(Collectors.java:133)
正确的做法是提供处理冲突的函数,在本demo中,处理冲突的原则就是找出最大的,正好符合我们分组求最大的要求。(真的不想搞Java8函数式学习了,感觉到处都是性能问题的坑)
继续数据库sql映射,分组求和select sum(score) from table a group by Type
Map<Type, Integer> totalCaloriesByType = dishes.stream().collect(groupingBy(Dish::getType, summingInt(Dish::getCalories)));
然而常常和groupingBy联合使用的另一个收集器是mapping方法生成的。这个方法接收两个参数:一个函数对流中的元素做变换,另一个则将变换的结果对象收集起来。其目的是在累加之前对每个输入元素应用一个映射函数,这样就可以让接收特定类型元素的收集器适应不同类型的对象。我么来看一个使用这个收集器的实际例子。比如你想得到,对于每种类型的Dish,菜单中都有哪些CaloricLevel。我们可以把groupingBy和mapping收集器结合起来,如下所示:
Map<Type, Set<CaloricLevel>> caloricLevelsByType = dishes.stream().collect(groupingBy(Dish::getType, mapping(this::getCaloricLevel, toSet())));
这里的toSet默认采用的HashSet,也可以手动指定具体实现toCollection(HashSet::new)
分区
分区是分组的特殊情况:由一个谓词(返回一个布尔值的函数)作为分类函数,它称为分区函数。分区函数返回一个布尔值,这意味着得到的分组Map的键类型是Boolean,于是它最多可以分为两组:true or false. 例如,如果你是素食者,你可能想要把菜单按照素食和非素食分开:
Map<Boolean, List<Dish>> partitionedMenu = dishes.stream().collect(partitioningBy(Dish::isVegetarian));
当然,使用filter可以达到同样的效果:
List<Dish> vegetarianDishes = dishes.stream().filter(Dish::isVegetarian).collect(Collectors.toList());
分区相对来说,优势就是保存了两个副本,当你想要对一个list分类时挺有用的。同时,和groupingBy一样,partitioningBy一样有重载方法,可以指定分组value的类型。
Map<Boolean, Map<Type, List<Dish>>> vegetarianDishesByType = dishes.stream().collect(partitioningBy(Dish::isVegetarian, groupingBy(Dish::getType)));
Map<Boolean, Integer> vegetarianDishesTotalCalories = dishes.stream().collect(partitioningBy(Dish::isVegetarian, summingInt(Dish::getCalories)));
Map<Boolean, Dish> mostCaloricPartitionedByVegetarian = dishes.stream().collect(partitioningBy(Dish::isVegetarian,collectingAndThen(maxBy(comparingInt(Dish::getCalories)), Optional::get)));
作为使用partitioningBy收集器的最后一个例子,我们把菜单数据模型放在一边,来看一个更加复杂也更为有趣的例子:将数组分为质数和非质数。
首先,定义个质数分区函数:
private booleanisPrime(intcandidate) {
int candidateRoot = (int) Math.sqrt((double) candidate);
return IntStream.rangeClosed(2, candidateRoot).noneMatch(i -> candidate % i == 0);
}
然后找出1到100的质数和非质数
Map<Boolean, List<Integer>> partitionPrimes = IntStream.rangeClosed(2, 100).boxed().collect(partitioningBy(this::isPrime));