引言
Collector意为收集器,上一章的最后提到Stream接口有一个名为collect的及时求值方法,它就是以Collector对象为参数的,并会根据所传入Collector的具体配置来返回一个指定的收集类。
他山之石 Collectors工具类
Collector是一个泛型接口,含有三个泛型参数,如果我们要实例化该接口除了自己实现所有方法外,还有一个名为of的工厂方法。这里的of并不像Stream里of那样方便,它要传入如下五个参数:
public static Collector of(Supplier supplier,
BiConsumer accumulator,
BinaryOperator combiner,
Function finisher,
Characteristics... characteristics) {...}
这五个参数的名字恰好就对应着Collector要实现的五个方法的名字,该方法还有一个不带finisher的重载形式,但是仍然要传这么多Lambda,还要在最后再传一个特征集,这对一般的使用者来说太过不便。这时我们就该想到,java.util包里会不会像以前那样,提供给我们实用的辅助工具呢,翻来翻去,还真有一个Collectors类,它位于java.util下面的java.util.stream包中,显然是到了J8才新增的。它为我们提供了三个获取Collector对象的静态方法,即三种收集类的实现方式,分别是toList、toSet、toCollection方法。toList与toSet没有参数,会自动选择合适的List与Set的实现方案,一般是ArrayList与HashSet,而toCollection则需要用户提供一个Supplier对象来选取生成收集类的方案。
例2.1:
List strings= Arrays.asList("aa","ab","dd","zz","ff","ar");
//收集为列表
List list=strings.stream()
.collect(Collectors.toList());
//收集为集合
Set set=strings.stream()
.collect(Collectors.toSet());
//收集为收集 具体类型为TreeSet
Collection collection=strings.stream()
.collect(Collectors.toCollection(TreeSet::new));
//输出
System.out.println(list);//[aa, ab, dd, zz, ff, ar]
System.out.println(set);//[aa, dd, zz, ff, ab, ar]
System.out.println(collection);//[aa, ab, ar, dd, ff, zz]
三个方法的转换结果中元素顺序各不相同,toList方法转换后的是一个ArrayList对象,顺序同产生流的List对象完全一致,而后两个方法分别转换为HashSet、TreeSet,所以不会保留之前的顺序,而是分别按照集合的Hash与Tree算法来排布。前两个方法对于收集类的实现方式由系统为我们优选,而toCollection方法则要求我们自力更生,选取合适的实现。collect方法除了能将流收集为收集类,收集为映射也不是不可以,只是要传入两个Function对象来确定键与值的生成方式,后面还有两个可选参数,分别决定映射的混合方式(BinaryOperator对象)与Map接口的具体实现(Supplier对象)。
例2.2:
List strings= Arrays.asList("a11","b22","c33","d44","f55");
Stream stream = strings.stream();
Map map =
stream.collect(Collectors.toMap(s -> s.substring(0, 1), s -> s.substring(1)));
System.out.println(map);//{a=11, b=22, c=33, d=44, f=55}
上例中我们将流映射为一个HashMap(默认实现),以首字符为键,后面的子串为值,如果存在两个字符串首字符相同,则会抛出异常提示映射的键值重复。我们可以通过添加第三个参数的方式来解决这个问题:
List strings = Arrays.asList("a11", "a22", "a33", "d44", "f55");
Stream stream = strings.stream();
Map map =
stream.collect(Collectors.toMap(
s -> s.substring(0, 1),
s -> s.substring(1),
(s1, s2) -> s1 + "-" + s2));
System.out.println(map);//{a=11-22-33, d=44, f=55}
收集器的仿级联
不能自理的Collector接口
Collector的方法,除了我们上面说的那几个要实现的方法和工厂方法外,就没有了。但是它也可以有类似级联的操作,用于对收集后的对象进行分组与统计等操作,这就要需要借助孜孜不倦生成它的Collectors工具类,和默默无言收容它的collect方法。在讲这种仿拟的级联之前,先来看几个用于级联的函数。
例2.3:
List strings=Arrays.asList("aa", "ab", "dd", "zz", "ff", "ar","dc");
Stream stream = strings.stream();
//按照条件分裂为两个列表的映射
Map> part
=stream.collect(Collectors.partitioningBy(s->s.startsWith("a")));
stream=strings.stream();
//按照条件分组为多个列表的映射
Map> group=
stream.collect(Collectors.groupingBy(s->s.substring(0,1)));
//按照预设的格式连接成字符串
stream=strings.stream();
String str=stream.collect(Collectors.joining("-","{","}"));
//输出
System.out.println(part);//{false=[dd, zz, ff,dc], true=[aa, ab, ar]}
System.out.println(group);//{a=[aa, ab, ar], d=[dd,dc], f=[ff], z=[zz]}
System.out.println(str);//{aa-ab-dd-zz-ff-ar-dc}
上述三个方法分别名为partitioningBy、groupingBy、joining,后面都带有ing,动感十足。我们一个个来看,partitioningBy方法的作用是根据传入的Predicate参数来进行判断,符合表达式条件的元素与不符合条件的元素将会被分裂为两组,分别收集成一个List,并按照true和false两个Boolean型的键值映射为一个Map。groupingBy方法则是根据传入的Function参数来对元素进行分组,按照相应的运算结果产生多个List,并以结果为键映射为一个Map。joining方法则更为简单,它按照用户传入的三个参数,将元素连接成一个字符串,三个参数分别用于设置字符串的分隔符、前缀、后缀,也可以省略前后缀,或进一步省略分隔符。
这三个方法返回值均为Collector类型,却都不属于Collector,而从属与外部的Collectors工具类,这样的话还怎么进行级联呢?那就只能继续借助Collectors类了,它为上述三个方法又加了一个重载,在参数列表最后又新增了一个Collector类型的参数,我们可以将后面要级联的方法写在这里,进行同返回值方法方法的参数嵌套式调用,这种设计模式有点像装饰器模式。仿级联方法最终返回一个Collector对象给collect方法,我们可以利用这点特性将上面三个操作合并到一起。
例2.4:
Object o=stream.collect(
Collectors.partitioningBy(s -> s.startsWith("a"),
Collectors.groupingBy(s -> s.substring(0, 1),
Collectors.joining("-", "{", "}")
)));
System.out.println(o);
//{false={d={dd-dc}, f={ff}, z={zz}}, true={a={aa-ab-ar}}}
上面的结果看起来很奇怪,但其实也不怪人家,我们自己的logy其实就写的很奇怪。想要一口气实现上述的三个操作,结果却先把a开头的字符串与其余字符串分裂开,再分别对两个列表中的元素进行分组,最后再对每组内部的字符串用分隔符进行了连接,而前后缀都加在了最外面。流在收集的时候,具体的方法执行顺序我不敢妄加揣测,所以出了这样的结果令我们不能不小心对待。
统计函数的ing用法
除了能进行简单的分组,Collector还能做一整套统计函数的活。提到统计,无非是什么元素个数,最值,平均值一类的东西,他们显然都只有一个数,是一种归纳操作。那么为什么不直接调用流的reduce方法,而要在collect方法里利用收集器来做这些事呢?别忘了我们前面讲的两个方法,它们提供了对元素分组的功能,这样一来事情就很明白了,我们要利用这些统计函数来对每一组的元素进行统计。这非常像SQL语言中的groupby操作与统计函数的结合,连名字都是那么相似,只是多了个ing的形式。
例2.4:
import java.util.stream.Collectors;
import static java.util.stream.Collectors.*;
...
Liststrings=Arrays.asList("a1","aa2","aaa3","b1","bb2","cc2","ccc3","d1");
Stream stream = strings.stream();
Map average=stream.collect(
groupingBy(s -> s.charAt(0),
mapping(s->s.concat("cc"),
averagingInt(s->s.length())
)));
System.out.println(average);//{a=5.0, b=4.5, c=5.5, d=4.0}
为了代码更为简洁,我将Collectors类的静态方法直接引入,这样就不用每次都要写“Collectors.”前缀了。看名字就能知道,averagingInt方法作用是求取整数的平均值,根据Integer类型的数值样本产生Double类型的平均值。请注意我这里的用辞,这里的统计方法并不是传入统计样本返回统计结果,而是传入统计样本的生成规则(Lambda表达式)返回一个收集器,再将收集器传入collect方法,按照对应的分组产生最后得到的统计值收集或统计值映射。这么说可能有些晦涩,最好结合代码来理解。上例中我们先按照首字符对字符串进行了分组,然后将每个字符串映射为自身连接上“cc”,最后求出每组串长度的平均数,作为最终生成的映射的值。注意这里我虽然描述了操作的执行顺序,但是这并不代表着方法调用的顺序,在上面的代码中,如果我交换groupingBy与mapping两个方法的顺序,代码的执行结果并不会改变。这样的一套操作相比函数级联,更像sql语句的调用,不同的方法管理不同的功能,一般不会互相干扰。对于初学者来说,最好不要妄自揣测收集器相关函数的调用顺序,否则可能会写出事与愿违的代码。
类似averagingInt的方法还有averagingLong、averagingDouble、summingNum、maxBy、minBy、count、summaringNum(Num=Int、Long、Double),分别用于计算平均值、和、最大值、总个数、综合统计量。其中summaring族方法会产生一个NumSummaryStatistics(意为统计资料)对象,再根据具体的getCount、getMax等方法获取各项统计信息。maxBy、minBy两个方法传入Comparator(比较器)产生Optional。
例2.5:
List strings = Arrays.asList("a", "aab", "dd", "zz", "ff", "ar", "d");
Stream stream = strings.stream();
Map map =
stream.collect(
groupingBy(s->s.charAt(0),
summarizingInt(String::length))
);
map.get('a').getCount();//总数为3
map.get('a').getAverage();//平均长度2.0
map.get('a').getMax();//最长为3
map.get('a').getMin();//最短为1
map.get('a').getSum();//总长度为6
mapping、maxBy、minBy之类的收集器方法与map、max、min等流方法相比起来,显得有些臃赘,我们可以用相对应的流方法来替换这些收集器方法,尽量往台面上靠。比如下面的代码,我的编译器(IDEA)就建议我将其替换为流方法改写的形式。
例2.6:
//改写前
Optional max = stream.collect(
mapping(s -> s + "ff",
maxBy(Comparator.comparing(String::length)))
);
//改写后
Optional max = stream
.map(s -> s + "ff")
.max(Comparator.comparing(String::length));
除了上述的这些方法,Collectors类还有以下方法:groupingByConcurrent、toConcurrentMap、collectingAndThen,它们的名字都很长,不过不要害怕。前两个方法分别是groupingBy和toMap的并发版本,关于并发的问题我们会在第五章再详谈。collectingAndThen方法则用于在收集后进行后续操作,它有两个参数,第一个类型为Collector,用于确定收集方式,第二个类型为Function,用于确定后续操作。
例2.7:
//将流收集为列表,并算出列表的容量
List strings = Arrays.asList("a", "aab", "dd", "zz", "ff", "ar", "d");
Stream stream = strings.stream();
int size=stream.collect(collectingAndThen(toList(), List::size));//不能直接在外面套System.out输出
//否则会出现类型推断错乱的问题 提示莫名其妙的静态环境问题 非要套的的话 就必须把Integer手动转成String
System.out.println(size);//7
归纳函数的ing用法
讲了这么多的函数,一般的操作我们都可以得心应手,如果你还不满足这些功能,想要自己规定一个更为复杂的功能,那么这里还有最后一个方法可供选择,那就是reducing。reducing方法看名字和reduce方法很像,只是加上按例ing变得活泼了,它的功能和reduce是一样的,都是对流进行归纳操作。reducing方法有三种重载,必须要有的是一个BinaryOperator类型的参数用来确定具体的合并方式,此参数决定着collect方法最后的返回值。可选的两个参数都要写在它的前面,第一个是归纳后的默认值,第二个是归纳之前要进行的映射操作。
例2.8:
List strings = Arrays.asList("aa", "bb", "cc", "dd");
//一参数
Stream stream = strings.stream();
Optional s1=stream.collect(reducing(String::concat));
//二参数
stream = strings.stream();
String s2=stream.collect(reducing("Begin:",String::concat));
//三参数
stream = strings.stream();
String s3=stream.collect(reducing("Begin:",String::toUpperCase,String::concat));
//输出
System.out.println(s1.get());//aabbccdd
System.out.println(s2);//Begin:aabbccdd
System.out.println(s3);//Begin:AABBCCDD
上例可以看出,单一参数的重载由于缺乏默认值,可能会有空操作的风险,所以产生了一个Optional对象。ruducing方法虽然看起来活力满满,但其实这种写法其实也很臃赘了,完全可以用reduce方法和map方法来代替。
小结
Collector接口是java.util.stream包中唯一名称不带有Stream的接口,它的用途一般也仅限于Stream的collect方法,关于它与Stream族接口的恩怨纠纷,我们以后再细说。