Java8学习笔记之收集器collect

流可以帮助你用类似于数据库的操作处理集合。流支持两种类型的操作:中间操作(如filter或map)和终端操作(如count、findFirst、forEach和reduce)。中间操作可以链接起来,将一个流转换为另一个流。终端操作会消耗流,以产生一个最终结果。

collect是一个归约操作,就像reduce一样可以接受各种做法作为参数,将流中的元素累积成一个汇总结果。具体的做法是通过新的Collector接口来定义的。

函数式编程与指令式编程的区别:

1)函数式编程只需指出希望的结果“做什么”,而不用操心执行的步骤“如何做,指令式编程需要自己定义逻辑实现及执行过程。

2)对于多级分组操作,由于需要好多层嵌套循环和条件,指令式代码很快就变得更难阅读、更难维护、更难修改。而函数式版本只要再加上 一个收集器就可以轻松地增强功能了。

3)函数式编程更易复合和重用。

1、收集器用作高级归约

对流调用collect方法将对流中的元素触发一个归约操作(由Collector来参数化),它遍历流中的每个元素,并让Collector进行处理。

最直接和最常用的收集器是toList静态方法,它会把流中符合筛选条件的所有元素收集到一个List中:

List list = numbers.stream().limit(2).collect(Collectors.toList());

预定义收集器:即可以从Collectors类提供的工厂方法(例如groupingBy)创建的收集器。其主要功能有:将流元素归约和汇总为一个值、元素分组、元素分区(使用谓词)。

2、规约和汇总

利用counting工厂方法返回的收集器,数一数菜单里有多少种菜:

long cou = menu.stream().collect(Collectors.counting());

或者:

long cou = menu.stream().count();

查找流中的最大值和最小值:Collectors.maxBy和Collectors.minBy

找出菜单中热量高的菜:

Comparator dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);

Optional mostCalorieDish = menu.stream().collect(maxBy(dishCaloriesComparator));

Collectors类为汇总提供了一个工厂方法:Collectors.summingInt。它接受一个把对象映射为求和所需int的函数,并返回一个收集器,该收集器在传递给普通的collect方法后即执行我们需要的汇总操作。

例如,计算菜单列表的总热量:

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

Collectors.summingLong和Collectors.summingDouble方法的作用完全一样,可以用于求和字段为long或double的情况。汇总不仅仅是求和;还有Collectors.averagingInt,及对应的averagingLong和 averagingDouble可以计算数值的平均数。

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

通过一次summarizing操作数出菜单中元素的个数,并得到菜肴热量总和、平均值、大值和小值:

IntSummaryStatistics menuStatistics = menu.stream()

        .collect(summarizingInt(Dish::getCalories));

这个收集器会把所有这些信息收集到一个叫作IntSummaryStatistics的类里,它提供了方便的取值(getter)方法来访问结果。summarizingLong和summarizingDouble工厂方法也有相关的LongSummaryStatistics和DoubleSummaryStatistics类型,适用于收集的属性是原始类型long或 double的情况。

3、连接字符串

joining工厂方法返回的收集器会把对流中每一个对象应用toString方法得到的所有字符串连接成一个字符串。joining在内部使用了StringBuilder来把生成的字符串逐个追加起来。

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

如果Dish类有一个toString方法来返回菜肴的名称,那你无需用提取每一道菜名称的函数来对原流做映射就能够得到相同的结果:

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

二者均可产生以下字符串:

porkbeefchickenfrench friesriceseason fruitpizzaprawnssalmon

joining工厂方法有一个重载版本可以接受元素之间的分界符:

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

生成结果:

pork, beef, chicken, french fries, rice, season fruit, pizza, prawns, salmon

4、广义的归约汇总

所有的收集器都是一个可以用reducing工厂方法定义的归约过程的特殊情况而已。Collectors.reducing工厂方法是所有这些特殊情况的一般化。

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

reducing方法需要三个参数:

第一个参数是归约操作的起始值,也是流中没有元素时的返回值,对于数值和而言0是一个合适的值。

第二个参数就是汇总函数,将菜肴转换成一个表示其所含热量的int。

第三个参数是一个BinaryOperator,将两个项目累积成一个同类型的值。 

你可以把单参数reducing工厂方法创建的收集器看作三参数方法的特殊情况,它把流中的第一个项目作为起点,把恒等函数(即一个函数仅仅是返回其输入参数)作为一个转换函数。这也意味着,要是把单参数reducing收集器传递给空流的collect方法,收集器就没有起点,它将因此而返回一个Optional对象。

Stream接口的collect和reduce方法都可以获得相同的结果,但又有区别。可以像下面这样使用reduce方法来实现toListCollector所做的工作:

Stream stream = Arrays.asList(1, 2, 3, 4, 5, 6).stream();

List numbers = stream.reduce(

    new ArrayList(),

    (List l, Integer e) -> {

        l.add(e);

        return l;

    },

    (List l1, List l2) -> {

        l1.addAll(l2);

        return l1;

    });

区别是reduce方法旨在把两个值结合起来生成一个新值,它是一个不可变的归约。相反collect方法的设计就是要改变容器,从而累积要输出的结果。上面的代码片段是在滥用reduce方法,因为它在原地改变了作为累加器的List。错误的语义使用reduce方法还会造成一个实际问题:这个归约过程不能并行工作,因为由多个线程并发修改同一个数据结构可能会破坏List本身。如果你想要线程安全,就需要每次分配一个新List,而对象分配又会影响性能。这就是collect方法特别适合表达可变容器上的归约的原因,更关键的是它适合并行操作。

还有另一种方法不使用收集器也能执行相同操作—将菜肴流映射为每一道菜的热量,然后用前一个版本中使用的方法引用来归约得到的流:

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

注意:reduce(Integer::sum)返回的不是int而是Optional,以便在空流的情况下安全地执行归约操作。你只需用Optional对象中的get方法来提取里面的值就行了。在这种情况下使用get方法是安全的,因为你已经确定菜肴流不为空。

一般来说,使用允许提供默认值的方法,如orElse或orElseGet来解开Optional中包含的值更为安全。更简洁的方法是把流映射到一个IntStream,然后调用sum方法,也可以得到相同的结果:int totalCalories = menu.stream().mapToInt(Dish::getCalories).sum();

函数式编程通常提供了多种方法来执行同一个操作。收集器在某种程度上比Stream接口上直接提供的方法用起来更复杂,但好处在于它们能提供更高水平的抽象和概括,也更容易重用和自定义。

5、分组

常见的数据库操作是根据一个或多个属性对集合中的项目进行分组。可以用Collectors.groupingBy工厂方法返回的收集器进行分组操作;

Map> dishesByType = menu.stream()

        .collect(groupingBy(Dish::getType));

输出结果是下面的Map:

{FISH=[prawns, salmon], OTHER=[french fries, rice, season fruit, pizza],  MEAT=[pork, beef, chicken]}

上例中groupingBy方法传递了一个Function(以方法引用的形式),它提取了流中每 一道Dish的Dish.Type。我们把这个Function叫作分类函数,因为它用来把流中的元素分成不同的组。分组操作的结果是一个Map,把分组函数返回的值作为映射的键,把流中所有具有这个分类值的项目的列表作为对应的映射值。键就是菜的类型,值就是包含所有对应类型的菜肴的列表。

在分组过程中对流中的项目进行分类

1)多级分组

Map>> 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;

            })

    ));

结果:

{

    MEAT={

        DIET=[chicken], NORMAL=[beef], FAT=[pork]

    },

    FISH={

        DIET=[prawns], NORMAL=[salmon]

    },

    OTHER={

        DIET=[rice, seasonal fruit], NORMAL=[french fries, pizza]

    }

}

上例中外层Map的键就是第一级分类函数生成的值:“fish, meat, other”,而这个Map的值又是 一个Map,键是二级分类函数生成的值:“normal, diet, fat”。第二级map的值是流中元素构成的List,是分别应用第一级和第二级分类函数所得到的对应第一级和第二级键的值:“salmon、 pizza…” 这种多级分组操作可以扩展至任意层级,n级分组就会得到一个代表n级树形结构的n级 Map。

n层嵌套映射和n维分类表之间的等价关系

2)按子组收集数据

多级分组可以把第二个groupingBy收集器传递给外层收集器来。传递给第一个groupingBy的第二个收集器可以是任何类型,而不一定是另一个groupingBy。

计算菜单中每类菜有多少个:

Map typesCount = menu.stream()

    .collect(groupingBy(Dish::getType, counting()));

结果是下面的Map:

{MEAT=3, FISH=2, OTHER=4}

注意:普通的单参数groupingBy(f)(其中f是分类函数)实际上是groupingBy(f, toList())的简便写法。

查找菜单中热量高的菜肴并按菜的类型分类:

Map> mostCaloricByType = menu.stream()

    .collect(groupingBy(Dish::getType, maxBy(comparingInt(Dish::getCalories))));

结果是下面的Map:

{FISH=Optional[salmon], OTHER=Optional[pizza], MEAT=Optional[pork]}

注意:这个Map中的值是Optional,因为这是maxBy工厂方法生成的收集器的类型,但实际上, 如果菜单中没有某一类型的Dish,这个类型就不会对应一个Optional.empty()值,而且根本不会出现在Map的键中。groupingBy收集器只有在应用分组条件后,第一次在流中找到某个键对应的元素时才会把键加入分组Map中。这意味着Optional包装器在这 里不是很有用,因为它不会仅仅因为它是归约收集器的返回类型而表达一个最终可能不存在却意外存在的值。

把收集器的结果转换为另一种类型

因为分组操作的Map结果中的每个值上包装的Optional没什么用,如何去掉?解决方式是把收集器返回的结果转换为另一种类型,可以使用Collectors.collectingAndThen工厂方法返回的收集器。

查找每个子组中热量高的Dish:

Map mostCaloricByType = menu.stream()

    .collect(groupingBy(Dish::getType, //分类函数

            collectingAndThen(

                maxBy(comparingInt(Dish::getCalories)), //包装后的收集器

                    Optional::get))); //转换函数

其结果是下面的Map:

{FISH=salmon, OTHER=pizza, MEAT=pork}

这个工厂方法接受两个参数:要转换的收集器和转换函数,并返回另一个收集器。这个收集器相当于旧收集器的一个包装,collect操作的最后一步就是将返回值用转换函数做一个映射。在这里,被包起来的收集器就是用maxBy建立的那个,而转换函数Optional::get则把返回的Optional中的值提取出来。这个操作在这里是安全的,因为reducing收集器永远都不会返回Optional.empty()。

与groupingBy联合使用的其他收集器

一般来说,通过groupingBy工厂方法的第二个参数传递的收集器将会对分到同一组中的所有流元素执行进一步归约操作。如对每一组Dish求和:

Map totalCaloriesByType = menu.stream()

    .collect(groupingBy(Dish::getType, summingInt(Dish::getCalories)));

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

Map> 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;

            },

            toSet())

        )

    );

传递给映射方法的转换函数将Dish映射成了它的CaloricLevel:生成的CaloricLevel流传递给一个toSet收集器,它和toList类似,不过是把流中的元素累积到一个Set而不是List中,以便仅保留各不相同的值。这个映射收集器将会收集分组函数生成的各个子流中的元素,让你得到这样的Map结果:

{OTHER=[DIET, NORMAL], MEAT=[DIET, NORMAL, FAT], FISH=[DIET, NORMAL]}

6、分区

分区是分组的特殊情况:由一个谓词(返回一个布尔值的函数)作为分类函数,称为分区函数。分区函数返回一个布尔值,这意味着得到的分组Map的键类型是Boolean,它可以分为两组:true是一组,false是一组。

示例按照素食和非素食分开:

Map> partitionedMenu = menu.stream().collect(partitioningBy(Dish::isVegetarian));

返回下面的Map:

{false=[pork, beef, chicken, prawns, salmon], true=[french fries, rice, season fruit, pizza]}

通过Map中键为true的值,就可以找出所有的素食菜肴了:

List vegetarianDishes = partitionedMenu.get(true);

或者通过集合的方式也可以达到同样的结果:

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

1)分区的优势

分区的好处在于保留了分区函数返回true或false的两套流元素列表。partitioningBy工厂方法有一个重载版本,可以像下面这样传递第二个收集器:

Map>> vegetarianDishesByType =

    menu.stream().collect(

        partitioningBy(Dish::isVegetarian, //分区函数

            groupingBy(Dish::getType))); //第二个收集器

结果:

{

    false={FISH=[prawns, salmon], MEAT=[pork, beef, chicken]},  

    true={OTHER=[french fries, rice, season fruit, pizza]}

}

找到素食和非素食中热量高的菜:

Map mostCaloricPartitionedByVegetarian = menu.stream()

    .collect(

        partitioningBy(Dish::isVegetarian,

            collectingAndThen(maxBy(comparingInt(Dish::getCalories)),Optional::get)));

得到以下结果:

{false=pork, true=pizza}

2)将数字分为质数和非质数

首先定义一个检查数字是否是质数的方法:

public boolean isPrime(int candidate) {

    int candidateRoot = (int) Math.sqrt((double) candidate); //待测数平方根的因子

    return IntStream.rangeClosed(2, candidateRoot) //产生一个自然数范围,从2开始直至但不包括待测数

        .noneMatch(i -> candidate % i == 0); //如果待测数字不能被流中任何数字整除则返回true

}

然后创建一个包含n个数的流,用isPrime方法作为谓词,再给partitioningBy收集器归约。

public Map> partitionPrimes(int n) {

    return IntStream.rangeClosed(2, n).boxed()

        .collect(partitioningBy(candidate -> isPrime(candidate)));

}

Collectors类的静态工厂方法
Collectors类的静态工厂方法 

7、收集器接口

Collector接口包含了一系列方法,为实现具体的归约操作(即收集器)提供了范本。你也可以为Collector接口提供自己的实现,从而自由地创建自定义归约操作。

Collector接口定义:

public interface Collector {

    Supplier supplier();

    BiConsumer accumulator();

    Function finisher();

    BinaryOperator combiner();

    Set characteristics();

}

对象类型解释:

T是流中要收集的项目的泛型。

A是累加器的类型,累加器是在收集过程中用于累积部分结果的对象。

R是收集操作得到的对象(通常但并不一定是集合)的类型。

你可以实现一个ToListCollector类,将Stream中的所有元素收集到一个List里,它的签名如下:

public class ToListCollector implements Collector, List>

1)理解Collector接口声明的方法

Collector接口声明了五个方法,前四个方法都会返回一个会被collect方法调用的函数,而第五个方法characteristics则提供了一系列特征,也就是一个提示列表,告诉collect方法在执行归约操作的时候可以应用哪些优化(比如并行化)。

建立新的结果容器:supplier方法

supplier方法必须返回一个结果为空的Supplier,也就是一个无参数函数,在调用时它会创建一个空的累加器实例,供数据收集过程使用。对于将累加器本身作为结果返回的收集器,比如ToListCollector,在对空流执行操作的时候,这个空的累加器也代表了收集过程的结果。在ToListCollector中,supplier返回一个空的List,如下所示:

public Supplier> supplier() {

    return () -> new ArrayList();

}

或者

public Supplier> supplier() {

    return ArrayList::new;

}

将元素添加到结果容器:accumulator方法

accumulator方法会返回执行归约操作的函数。当遍历到流中第n个元素时,这个函数执行时会有两个参数:保存归约结果的累加器(已收集了流中的前 n1 个项目),还有第n个元素本身。该函数将返回void,因为累加器是原位更新,即函数的执行改变了它的内部状态以体现遍历的元素的效果。对于ToListCollector,这个函数仅仅会把当前项目添加至已经遍历过的项目的列表:

public BiConsumer, T> accumulator() {

    return (list, item) -> list.add(item);

}

或者

public BiConsumer, T> accumulator() {

    return List::add;

}

对结果容器应用最终转换:finisher方法

在遍历完流后,finisher方法必须返回在累积过程的最后要调用的一个函数,以便将累加器对象转换为整个集合操作的终结果。就像ToListCollector的情况一样,累加器对象恰好符合预期的终结果,因此无需进行转换。所以finisher方法只需返回identity函数:

public Function, List> finisher() {

    return Function.identity();

}

顺序归约过程的逻辑步骤

合并两个结果容器:combiner方法

combiner方法会返回一个供归约操作使用的函数,它定义了对流的各个子部分进行并行处理时,各个子部分归约所得的累加器要如何合并。对于toList只要把从流的第二个部分收集到的项目列表加到遍历第一部分时得到的列表后面就行了:

public BinaryOperator> combiner() {

    return (list1, list2) -> {

        list1.addAll(list2);

        return list1;

    }

}

使用combiner方法来并行化归约过程

characteristics方法

characteristics会返回一个不可变的Characteristics集合,它定义了收集器的行为—尤其是关于流是否可以并行归约,以及可以使用哪些优化的提示。

Characteristics是一个包含三个项目的枚举:

UNORDERED:归约结果不受流中项目的遍历和累积顺序的影响。

CONCURRENT:accumulator函数可以从多个线程同时调用,且该收集器可以并行归约流。如果收集器没有标为UNORDERED,那它仅在用于无序数据源时才可以并行归约。

IDENTITY_FINISH:表明完成器方法返回的函数是一个恒等函数,可以跳过。这种情况下,累加器对象将会直接用作归约过程的终结果。这也意味着,将累加器A不加检查地转换为结果R是安全的。

2)全部融合到一起

实现自己的ToListCollector接口方法:

public class ToListCollector implements Collector, List> {

    @Override

    public Supplier> supplier() { return ArrayList::new; } //创建集合操作的起始点

    @Override

    public BiConsumer, T> accumulator() { return List::add; } //累积遍历过的项目,原位修改累加器

    @Override

    public Function, List> finisher() { return Function.indentity(); } //函数

    @Override

    public BinaryOperator> combiner() { 

        return (list1, list2) -> { 

            list1.addAll(list2); //修改第一个累加器,将其与第二个累加器的内容合并

            return list1; //返回修改后的第一个累加器

        };

    }   

    @Override

    public Set characteristics() {

        return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH, CONCURRENT)); //为收集器添加IDENTITY _FINISH和CONCURRENT标志

    } 

}

List dishes = menuStream.collect(new ToListCollector());

等同于

List dishes = menuStream.collect(toList());

差异在于toList是一个工厂,而ToListCollector必须用new来实例化。

进行自定义收集而不去实现Collector

对于IDENTITY_FINISH的收集操作,还有一种方法可以得到同样的结果而无需从头实现新的Collectors接口。Stream有一个重载的collect方法可以接受另外三个函数:supplier、 accumulator和combiner,其语义和Collector接口的相应方法返回的函数完全相同。我们可以这样把菜肴流中的项目收集到一个List中:

List dishes = menuStream.collect(

    ArrayList::new, //供应源

    List::add, //累加器

    List::addAll); //组合器

注意:自定义实现的collect方法不能传递任何Characteristics,它永远都是一个IDENTITY_FINISH和CONCURRENT,并非UNORDERED的收集器。

8、开发自己的收集器以获得更好的性能

示例:自定义收集器区分数字是否质数

判断数字是否为质数,优化isPrime方法:

public static boolean isPrime(List primes, int candidate){

    int candidateRoot = (int) Math.sqrt((double) candidate);

    return takeWhile(primes, i -> i <= candidateRoot)

        .stream()

        .noneMatch(p -> candidate % p == 0);

}

第一步:定义Collector类的签名

public class PrimeNumbersCollector implements

Collector>, Map>>

第二步:实现归约过程

需要实现Collector接口中声明的五个方法。supplier方法会返回一个在调用时创建累加器的函数:

public Supplier>> supplier() {

    return () -> new HashMap>() {{

        put(true, new ArrayList());

        put(false, new ArrayList());

    }};

}

收集器中重要的方法是accumulator,它定义了如何收集流中元素的逻辑。在任何一次迭代中,都可以访问收集过程的部分结果,也就是包含找到的质数的累加器:

public BiConsumer>, Integer> accumulator() {

    return (Map> acc, Integer candidate) -> {

        acc.get( isPrime(acc.get(true), candidate) ) //根据isPrime的结果,获取质数或非质数列表

        .add(candidate); //将被测数添加到相应的列表中

    };

}

第三步:让收集器并行工作(如果可能)

将第二个Map中质数和非质数列表中的所有数字合并到第一个Map的对应列表中:

public BinaryOperator>> combiner() {

    return (Map> map1,Map> map2) -> {

        map1.get(true).addAll(map2.get(true));

        map1.get(false).addAll(map2.get(false));

        return map1;

    };

}

注意:实际上这个收集器是不能并行使用的,因为该算法本身是顺序的。这意味着永远都不会调用combiner方法,你可以把它的实现留空(最好是抛出一个UnsupportedOperationException异常)。

第四步:finisher方法和收集器的characteristics方法

public Function>,Map>> finisher() {

    return Function.identity();

}

public Set characteristics() {

    return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH));

}

最后的实现类PrimeNumbersCollector:

public class PrimeNumbersCollector implements 

    Collector>, Map>> {

    @Override

    public Supplier>> supplier() { 

        return () -> new HashMap>() {{

            put(true, new ArrayList());

            put(false, new ArrayList());

        }}; //从一个有两个空List的Map开始收集过程

    } 

    @Override

    public BiConsumer>, Integer> accumulator() {

        return (Map> acc, Integer candidate) -> {

            acc.get( isPrime( acc.get(true), candidate) )

                .add(candidate); //根据isPrime方法的返回值,从Map中取质数或非质数列表,把当前的被测数加进去

        }; //将已经找到的质数列表传递给isPrime方法

    }

    @Override

    public BinaryOperator>> combiner() {

        return (Map> map1, Map> map2) -> {             map1.get(true).addAll(map2.get(true));

            map1.get(false).addAll(map2.get(false));

            return map1; //将第二个Map合并到第一个

        };

    }

    @Override

    public Function>, Map>> finisher() {             return Function.identity(); //收集过程最后无需转换,因此用 identity函数收尾

    }

    @Override

    public Set characteristics() {

        return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH));//这个收集器是IDENTITY_FINISH,既不是UNORDERED也不是CONCURRENT,因为质数是按顺序发现的

    }

}

使用这个新自定义收集器来代替用partitioningBy工厂方法创建的那个:

public Map> partitionPrimesWithCustomCollector(int n) {

    return IntStream.rangeClosed(2, n).boxed()

        .collect(new PrimeNumbersCollector());

}

    --以上示例摘自《Java8实战》

你可能感兴趣的:(Java8学习笔记之收集器collect)