Java8-Lambda编程[2] Colloctor接口

Collector,意为收集器。上一节提到Stream类的一个及时求值方法collect就是以Collector对象为参数的,它会根据传入的Collector对象返回一个收集类。collect方法还有一种三参数形式,与reduce方法的该形式一样涉及并行知识,我们将留到下一节再进行讨论。Collector是一个泛型接口,含有三个泛型参数,我们暂不深究它的原理,直接使用_java.util.stream_包中为我们准备好的Collectors工具类来获取Collector对象,以调用collect方法。由于在Java8之前接口不允许有静态方法,所以要用的东西都放在了辅助类Collectors中,它为我们提供了三个获取Collector对象的静态方法,即给出了三种实现方式,分别是toList、toSet、toCollection方法。toList与toSet没有参数,会自动选择合适的List与Set的实现方案,而toCollection则需要用户提供一个Supplier对象来选取生成收集类的方案。讲得有些晦涩,还是直接来看代码吧:

    List<String> strings= Arrays.asList("aa","ab","dd","zz","ff","ar");
    //收集为列表
    List<String> 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。前两个方法对于收集类的实现方式由系统为我们优选,而toCollection方法则要求我们自力更生,选取合适的实现。collect方法除了能将流收集为收集类,收集为映射也不是不可以,只是要传入两个Function对象来确定键与值的 生成方式,后面还有两个参数是可选的,分别决定映射的混合方式(BinaryOperator对象)与Map接口的具体实现(Supplier对象)。下面是一个例子:

    List<String> strings= Arrays.asList("a11","b22","c33","d44","f55");
    Stream<String> stream = strings.stream();
    Map<String, String> map =
            stream.collect(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<String> strings = Arrays.asList("a11", "a22", "a33", "d44", "f55");
        Stream<String> stream = strings.stream();
        Map<String, String> map =
                stream.collect(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}

除了简单的收集操作,collect方法还实现更为高级的功能,用于对收集后的对象进行分组与统计等操作。例如下面的代码:

    List<String> list=Arrays.asList("aa", "ab", "dd", "zz", "ff", "ar");
    Set<String> strings = list.stream().collect(Collectors.toSet());
    Stream<String> stream = strings.stream();
    //按照条件分裂为两个列表的映射
    Map<Boolean,List<String>> part
            =stream.collect(Collectors.partitioningBy(s->s.startsWith("a")));
    stream=strings.stream();
    //按照条件分组为多个列表的映射
    Map<String,List<String>> 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], true=[aa, ab, ar]}
    System.out.println(group);//{a=[aa, ab, ar], d=[dd], f=[ff], z=[zz]}
    System.out.println(str);//{aa-ab-dd-zz-ff-ar}

上述三个方法分别名为partitioningBy、groupingBy、joining,都有一个共同点就是后面带有ing,动感十足。我们一个个来看,partitioningBy方法的作用是根据传入的Lambda表达式(一个Predicate对象)来进行判断,符合条件的元素与不符合条件的元素将会被分裂为两组,分别收集成一个List对象,并按照true和false两个Boolean型的键值映射为一个Map对象。groupingBy方法则是按照Lambda表达式(一个Function对象)的运算结果来对元素进行分组,每组都是一个List对象,并以相应的运算结果为键值映射为一个Map对象。joining方法则更为简单,它按照用户传入的三个参数,将元素连接成一个字符串,三个参数分别用于设置字符串的分隔符、前缀、后缀,也可以省略前后缀,或进一步省略分隔符。

上述三个方法均还有一个可选参数,可以传入一个Collector类型的对象,将收集操作进行函数嵌套形式的级联。这种设计模式有点像装饰器模式,最终返回一个Collector对象给collect方法,来对流中的元素进行特定形式的收集。我们可以利用这点特性将上面三个操作合并到一起:

    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}, f={ff}, z={zz}}, true={a={aa-ab-ar}}}

上述表达式的结果看起来很奇怪,但其实也不怪人家,我们自己的logy其实就写的很奇怪。想要一口气实现上述的三个操作,结果却先对流中的元素进行了分组,然后在把a开头的字符串组与其余字符串组分裂开,最后对true映射的a组字符串进行了连接。流在遍历的时候,具体的函数执行顺序我不敢妄加揣测,所以出了这样的结果令我们不能不小心对待。下面还是来看一个更好的例子吧,为了代码更为简洁,我将Collectors类的静态方法直接引入:

import java.util.stream.Collectors;
import static java.util.stream.Collectors.*;
...
        List<String>strings=Arrays.asList("a1","aa2","aaa3","b1","bb2","cc2","ccc3","d1");
        Stream<String> stream = strings.stream();
        Map<Character,Double> 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}

averagingInt方法用来求取平均值,根据Integer类型的样本数产生Double类型的结果数。请注意我的用辞,这里的方法并不是传参返回结果的函数,而是传入操作(Lambda表达式)给此方法返回一个收集器,再将收集器传入collect方法,产生最后得到的收集或映射的值。上面的代码先按照首字符对字符串进行了分组,我们最后返回的Map对象的第一个泛型参数为Character就是在此决定的,然后将每个字符串映射为自身连接上“cc”,最后求出每组串长度的平均数,作为Map对象的第二个参数,即值的类型。注意这里我虽然描述了操作的执行顺序,但是这并不代表着方法调用的顺序,在上面的代码中,如果我交换groupingBy与mapping两个方法的顺序,代码的执行结果并不会改变。对于像我这样的初学者来说,最好不要妄自揣测收集器相关函数的调用顺序,否则可能会写出事与愿违的代码。

类似averagingInt的方法还有averagingLong、averagingDouble、summing族、maxBy、minBy、count、summaring族,分别用于计算平均值、和、最大值、总个数、综合统计量。其中Summaring族方法会产生一个NumSummaryStatistics(Num=Int、Long、Double,意为统计资料)对象,再根据具体的getCount、getMax等方法获取各项统计信息。maxBy、minBy两个方法传入Comparator对象(比较器)产生Optional(可空)对象,如下面的代码:

    Optional<String> max=strings.stream().collect(
             maxBy(Comparator.comparing(String::length)));
    System.out.println(max.get());//aaa3

值得一提,mapping、maxBy、minBy之类的收集器方法与流方法相比起来,显得有些老派,我们可以用相对应的流方法来替换这些收集器方法,尽量往台面上靠。比如xia面的代码,我的编译器(IDEA)就建议我将其替换为map方法改写的形式:

    //改写前
    Optional max=stream.collect(
              mapping(String::trim,//一个没影响的方法 不要在意
               maxBy(Comparator.comparing(String::length))
                    ));
    System.out.println(max.get());
    //改写后
    Optional max=stream
             .map(String::intern)
              .collect(maxBy(Comparator.comparing(String::length)));
    System.out.println(max.get());

除了上述的这些方法,Collectors类还有以下方法:groupingByConcurrent、toConcurrentMap、collectingAndThen,它们的名字都很长,不过不要害怕,因为一般也用不太着。前两个方法看名字就知道和并发相关,我们暂且搁置不谈,collectingAndThen方法用于在收集后进行后续操作,它有两个参数,第一个参数为确定具体收集方式的Collector对象,第二个参数为确定后续操作的Function对象,如:

   //将流收集为列表,并算出列表的容量
   Stream<String> stream = strings.stream();
   stream.collect(collectingAndThen(toList(),o->o.size()));

讲了这么多的函数,一般的操作我们都可以得心应手,如果你还不满足这些功能,想要自己规定一个更为复杂的功能,那么这里还有最后一个方法可供选择——reducing。看名字和Stream的reduce方法很像,只是加上按例ing变得活泼了,它的功能和reduce是一样的,都是对流进行归纳操作。reducing方法有三种重载,必须要有的是一个BinaryOperator类型的参数用来确定具体的合并方式,此参数决定着collect方法最后的返回值。可选的两个参数都要写在它的前面,第一个是归纳后的默认值,第二个是归纳之前要进行的映射操作,让我们直接来看一个例子:

    List<String> strings = Arrays.asList(" aa", " bb", " cc", " dd");
    //一参数
    Stream<String> 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::trim,String::concat));
    //输出
    System.out.println(s1.get());//aa bb cc dd
    System.out.println(s2);//Begin: aa bb cc dd
    System.out.println(s3);//Begin:aabbccdd

上面的例子中,我们调用了reducing方法的三种重载,其中一参数形式由于缺乏默认值可能会有空操作的风险,所以产生了一个Optional对象。ruducing方法虽然看起来活力满满,但其实这种写法其实也很老派了,完全可以用reduce方法和map方法来代替:

    //一参数
    Stream<String> stream= strings.stream();
    Optional s1= stream.reduce(String::concat);
    //二参数
    stream = strings.stream();
    String s2= stream.reduce("Begin:", String::concat);
    //三参数
    stream = strings.stream();
    String s3= stream.map(String::trim)
            .reduce("Begin:", String::concat);

Collector接口是_java.util.stream_包中唯一名称不带有Stream的接口,它的用途一般也仅限于Stream的collect方法,关于它与Stream族接口的恩怨纠纷,我们以后再细说。