Java8中Collector详解及自定义Collector


Collector收集器的介绍及使用

1.Collector介绍

一种可变缩减操作,将输入元素累积到可变结果容器中,在处理完所有输入元素后可选择将累积结果转换为最终表示。缩减操作可以顺序执行也可以并行执行。

可变缩减操作的示例包括:将元素累积到集合中;使用StringBuilder连接字符串;计算有关元素的摘要信息,例如总和,最小值,最大值或平均值;计算“数据透视表”摘要,例如“卖方的最大价值交易”等。类Collectors提供许多常见的可变缩减的实现。

一个Collector收集器由四个函数指定,这四个函数一起工作以将条目累积到可变结果容器中,并可选择对结果执行最终转换。他们是:

  • 创建新的结果容器(supplier())
  • 将新数据元素合并到结果容器中(accumulator())
  • 将两个结果容器合并为一个(combiner())
  • 在容器上执行可选的最终转换(finisher())

收集器还具有一组特征,例如Collector.Characteristics.CONCURRENT,提供了可以缩减实现使用,以提供更好性能的提示。

使用收集器缩减的顺序实现将使用supplier函数创建单个结果容器,并为每个输入元素调用一次accumulator累加器函数。并行实现将对输入进行分区,为每个分区创建一个结果容器,将每个分区的内容累积到该分区的子结果中,然后使用combiner组合器函数将子结果合并为组合结果。

为确保顺序和并行执行产生相同的结果,收集器函数必须满足标识关联约束。

标识约束表示对于任何部分累积的结果,将其与空结果容器组合必须产生等效结果。也就是说,对于任何一系列累加器和组合器调用的结果的部分累积结果aa必须等效于combiner.apply(a, supplier.get())

关联约束表示拆分计算必须产生等效结果。也就是说,对于任何输入元素t1t2,结果r1r2在下面的计算中必须是等价的:

A a1 = supplier.get();
accumulator.accept(a1, t1);
accumulator.accept(a1, t2);
R r1 = finisher.apply(a1);  // result without splitting

A a2 = supplier.get();
accumulator.accept(a2, t1);
A a3 = supplier.get();
accumulator.accept(a3, t2);
R r2 = finisher.apply(combiner.apply(a2, a3));  // result with splitting

对于没有UNORDERED特性的收集器,如果finisher.apply(a1).equals(finisher.apply(a2)),则两个累积的结果a1a2相同 。对于无序收集器,等价性被放宽以允许与顺序差异相关的不相等。(例如,如果两个列表包含相同的元素,而忽略了顺序,那么将元素累积到List列表中的无序收集器将认为两个列表是等效的。)

2.Collector约束

基于Collector收集器实现缩减的库(如Stream.collect(Collector))必须遵守以下约束:

  • 传递给accumulator累加器函数的第一个参数,传递给combiner函数的两个参数以及传递给finisher函数的参数必须是先前调用supplier,accumulator或combiner函数的结果。
  • 该实现不应该对任何supplier,accumulator或combiner函数的结果做任何事情,除了将它们再次传递给accumulator, combiner或finisher函数,或者将它们返回到缩减操作的调用者之外。
  • 如果将结果传递给combiner或finisher函数,并且同一对象没有从该函数返回,则不再使用它。
  • 一旦结果传递给combiner或finisher函数,它就不会再次传递给accumulator函数。
  • 对于非并发收集器,从supplier,accumulator或combiner函数返回的任何结果必须是串行线程限制的。这使得收集可以并行进行,而无需Collector实现任何其它同步。缩减实现必须管理输入被正确分区,分区是单独处理的,并且只有在累积完成后才进行组合。
  • 对于并发收集器,实现可以自由地(但不是必须)并发实现缩减。并发缩减是使用相同的可并发修改的结果容器从多个线程并发调用accumulator累加器函数,而不是在累积期间保持结果隔离的情况。只有在收集器具有Collector.Characteristics.UNORDERED特性或原始数据无序时,才应应用并发缩减 。

除了Collectors预定义的实现之外,静态工厂方法of(Supplier, BiConsumer, BinaryOperator, Characteristics...) 可用于构造收集器。例如,可以创建一个收集器,将窗口小部件累积到TreeSet

Collector<Widget, ?, TreeSet<Widget>> intoSet =
    Collector.of(TreeSet::new, TreeSet::add,
                 (left, right) -> { left.addAll(right); return left; });

(此行为也由预定义的收集器Collectors.toCollection(Supplier)实现)。

注意:

使用Collector收集器执行缩减操作应产生等同于以下结果的结果:

R container = collector.supplier().get();
for (T t : data)
    collector.accumulator().accept(container, t);
return collector.finisher().apply(container);

但是,库可以自由的对输入分区,对分区执行缩减,然后使用组合器函数组合部分结果以实现并行缩减。(根据具体的缩减操作,这可能会执行的更好或更差,具体取决于累加器和组合器函数的相对成本。)

Collectors被设计为组合的 ; Collectors中的许多方法都是采用收集器并生成新收集器的函数。例如,给定以下收集器来计算员工Stream流的工资总和:

Collector<Employee, ?, Integer> summingSalaries
         = Collectors.summingInt(Employee::getSalary))

如果我们想创建一个收集器来按部门列出工资总额,我们可以使用Collectors.groupingBy(Function, Collector)重用“工资总和”逻辑 :

Collector<Employee, ?, Map<Department, Integer>> summingSalariesByDept
         = Collectors.groupingBy(Employee::getDepartment, summingSalaries);

3.Collector接口方法

Collector接口包含了一系列方法,为实现具体的归约操作(即收集器)提供了范本。

Collector接口中实现了许多收集器,例如toListgroupingBy。可以为Collector接口提供自己的实现,从而自由地创建自定义归约操作。

要开始使用Collector接口,先看看一个收集器——toList工厂方法,它会把流中的所有元素收集成一个List。在日常工作中经常会用到这个收集器,而且它也是写起来比较直观的一个,至少理论上如此。通过仔细研究这个收集器是怎么实现的,可以很好地了解Collector接口是怎么定义的,以及它的方法所返回的函数在内部是如何为collect方法所用的。

首先在下面的列表中看看Collector接口的定义,它列出了接口的签名以及声明的五个方法。

Collector接口

public interface Collector<T, A, R> {
    Supplier<A> supplier();
    BiConsumer<A, T> accumulator();
    Function<A, R> finisher();
    BinaryOperator<A> combiner();
    Set<Characteristics> characteristics();
}

Collector<T, A, R>接受三个泛型参数,对缩减操作的数据类型做相应限制:

  • T:缩减操作的输入元素的类型
  • A:缩减操作的可变累积类型(通常隐藏为实现细节)
  • R:缩减操作的结果类型

例如,可以实现一ToListCollector<T>类,将Stream<T>中的所有元素收集到一个
List<T>里,它的签名如下:
public class ToListCollector<T> implements Collector<T, List<T>, List<T>>
这里用于累积的对象也将是收集过程的最终结果。

4.理解Collector接口声明的方法

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

  1. 建立新的结果容器:supplier方法
    supplier方法必须返回一个结果为空的Supplier,也就是一个无参数函数,在调用时它会创建一个空的累加器实例,供数据收集过程使用。很明显,对于将累加器本身作为结果返回的收集器,比如我们的 ToListCollector,在对空流执行操作的时候,这个空的累加器也代表了收集过程的结果。在我们的 ToListCollector中,supplier返回一个空的List,如下所示:
public Supplier<List<T>> supplier() {
	return () -> new ArrayList<T>();
}

请注意也可以只传递一个构造函数引用:

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

    public BiConsumer<List<T>, T> accumulator() {
    	return (list, item) -> list.add(item);
    }
    

    也可以使用方法引用,这会更为简洁:

    public BiConsumer<List<T>, T> accumulator() {
        return List::add;
    }
    
  2. 对结果容器应用最终转换:finisher方法
    在遍历完流后,finisher方法必须返回在累积过程的最后要调用的一个函数,以便将累加器对象转换为整个集合操作的最终结果。通常,就像ToListCollector的情况一样,累加器对象恰好符合预期的最终结果,因此无需进行转换。所以finisher方法只需返回identity恒等函数,即返回它本身:

    public Function<List<T>, List<T>> finisher() {
    	return t -> t;
    }
    

    public Function<List<T>, List<T>> finisher() {
    	return Function.identity();
    }
    

    这三个方法已经足以对流进行顺序归约,至少从逻辑上看可以按下图进行。实践中的实现细节可能还要复杂一点,一方面是因为流的延迟性质,可能在collect操作之前还需要完成其他中间操作的流水线,另一方面则是理论上可能要进行并行归约。

Collector接口顺序归约过程逻辑步骤

  1. 合并两个结果容器:combiner方法
    四个方法中的最后一个——combiner方法会返回一个供归约操作使用的函数,它定义了对流的各个子部分进行并行处理时,各个子部分归约所得的累加器要如何合并。对于toList而言,这个方法的实现非常简单,只要把从流的第二个部分收集到的项目列表加到遍历第一部分时得到的列表后面就行了:

    public BinaryOperator<List<T>> combiner() {
        return (list1, list2) -> {
        	list1.addAll(list2);
        	return list1; 
        }
    }
    

    有了这第四个方法,就可以对流进行并行归约了。它会用到Java 7中引入的分支/合并框架和Spliterator抽象。这个过程类似于下图所示,这里会详细介绍。

    • 原始流会以递归方式拆分为子流,直到定义流是否需要进一步拆分的一个条件为非(如果分布式工作单位太小,并行计算往往比顺序计算要慢,而且要是生成的并行任务比处理器内核数多很多的话就毫无意义了)。
    • 现在,所有的子流都可以并行处理,即对每个子流应用上图所示的顺序归约算法。
    • 最后,使用收集器combiner方法返回的函数,将所有的部分结果两两合并。这时会把原始流每次拆分时得到的子流对应的结果合并起来。

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

  1. characteristics方法
    最后一个方法——characteristics会返回一个不可变的Characteristics集合,它定义了收集器的行为——尤其是关于流是否可以并行归约,以及可以使用哪些优化的提示。
    Characteristic是一个包含三个项目的枚举。

    • UNORDERED——归约结果不受流中项目的遍历和累积顺序的影响。
    • CONCURRENT——accumulator函数可以从多个线程同时调用,且该收集器可以并行归约流。如果收集器没有标为UNORDERED,那它仅在用于无序数据源时才可以并行归约。
    • IDENTITY_FINISH——这表明完成器方法返回的函数是一个恒等函数,可以跳过。这种情况下,累加器对象将会直接用作归约过程的最终结果。这也意味着,将累加器A不加检查地转换为结果R是安全的。

    ToListCollector是IDENTITY_FINISH的,因为用来累积流中元素的List已经是我们要的最终结果,用不着进一步转换了,但它并不是UNORDERED,因为用在有序流上的时候,还是希望顺序能够保留在得到的List中。最后,它是CONCURRENT的,但刚才介绍的,仅仅在背后的数据源无序时才会并行处理。

5.整合自定义Collector

前一小节中谈到的五个方法足够我们开发自己的ToListCollector了。你可以把它们都融合起来,如下面的代码清单所示。
ToListCollector

import java.util.;
import java.util.function.;
import java.util.stream.Collector;
import static java.util.stream.Collector.Characteristics.*;
public class ToListCollector<T> implements Collector<T, List<T>, List<T>> {
    @Override
    public Supplier<List<T>> supplier() {
        return ArrayList::new;
    }
    @Override
    public BiConsumer<List<T>, T> accumulator() {
        return List::add;
    }
    @Override
    public Function<List<T>, List<T>> finisher() {
        return Function.indentity();
    }
    @Override
    public BinaryOperator<List<T>> combiner() {
        return (list1, list2) -> {
            list1.addAll(list2);
            return list1;
        };
    }
    @Override
    public Set<Characteristics> characteristics() {
        return Collections.unmodifiableSet(EnumSet.of(
                IDENTITY_FINISH, CONCURRENT));
    }
}

请注意,这个实现与Collectors.toList方法并不完全相同,但区别仅仅是一些小的优化。
这些优化的一个主要方面是Java API所提供的收集器在需要返回空列表时使用了Collections.emptyList()这个单例(singleton)。这意味着它可安全地替代原生Java,来收集menu流中的所有Dish的列表:
List<Dish> dishes = menuStream.collect(new ToListCollector<Dish>());
这个实现和标准的
List<Dish> dishes = menuStream.collect(toList());
构造之间的其他差异在于toList是一个工厂,而ToListCollector必须用new来实例化。

6.使用collect方法进行收集

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

List<Dish> dishes = menuStream.collect(
                                ArrayList::new,
                                List::add,
                                List::addAll);

我们认为,这第二种形式虽然比前一个写法更为紧凑和简洁,却不那么易读。此外,以恰当的类来实现自己的自定义收集器有助于重用并可避免代码重复。另外值得注意的是,这第二个collect方法不能传递任何Characteristics,所以它永远都是一个IDENTITY_FINISH和CONCURRENT但并非UNORDERED的收集器。

7.自定义Collector以获得更好的性能

用Collectors类提供的一个方便的工厂方法创建一个收集器,它将前n个自然数划分为质数和非质数,如下所示。
将前n个自然数按质数和非质数分区

public Map<Boolean, List<Integer>> partitionPrimes(int n) {
    return IntStream.rangeClosed(2, n).boxed()
    		.collect(partitioningBy(candidate -> isPrime(candidate));
}

通过限制除数不超过被测试数的平方根,我们对最初的 isPrime 方法做了一些改进:

public boolean isPrime(int candidate) {
    int candidateRoot = (int) Math.sqrt((double) candidate);
    return IntStream.rangeClosed(2, candidateRoot)
					.noneMatch(i -> candidate % i == 0);
}

还有没有办法来获得更好的性能呢?答案是“有”,但为此你必须开发一个自定义收集器。

7.1仅用质数做除数

一个可能的优化是仅仅看看被测试数是不是能够被质数整除。要是除数本身都不是质数就用不着测了。所以我们可以仅仅用被测试数之前的质数来测试。然而我们目前所见的预定义收集器的问题,也就是必须自己开发一个收集器的原因在于,在收集过程中是没有办法访问部分结果的。这意味着,当测试某一个数字是否是质数的时候,你没法访问目前已经找到的其他质数的列表。
假设你有这个列表,那就可以把它传给 isPrime 方法,将方法重写如下:

public static boolean isPrime(List<Integer> primes, int candidate) {
	return primes.stream().noneMatch(i -> candidate % i == 0);
}

而且还应该应用先前的优化,仅仅用小于被测数平方根的质数来测试。因此,你需要想办法在下一个质数大于被测数平方根时立即停止测试。不幸的是,Stream API中没有这样一种方法。你可以使用 filter(p -> p <= candidateRoot) 来筛选出小于被测数平方根的质数。但 filter要处理整个流才能返回恰当的结果。如果质数和非质数的列表都非常大,这就是个问题了。你用不着这样做;你只需在质数大于被测数平方根的时候停下来就可以了。因此,我们会创建一个名为 takeWhile 的方法,给定一个排序列表和一个谓词,它会返回元素满足谓词的最长前缀:

public static <A> List<A> takeWhile(List<A> list, Predicate<A> p) {
    int i = 0;
    for (A item : list) {
        if (!p.test(item)) {
        	return list.subList(0, i);
        }
        i++;
    }
    return list;
}

利用这个方法,你就可以优化 isPrime 方法,只用不大于被测数平方根的质数去测试了:

public static boolean isPrime(List<Integer> primes, int candidate){
    int candidateRoot = (int) Math.sqrt((double) candidate);
    return takeWhile(primes, i -> i <= candidateRoot)
            .stream()
            .noneMatch(p -> candidate % p == 0);
}

请注意,这个 takeWhile 实现是即时的。理想情况下,我们会想要一个延迟求值的takeWhile ,这样就可以和 noneMatch 操作合并。不幸的是,这样的实现超出了本章的范围,你需要了解Stream API的实现才行。
有了这个新的 isPrime 方法在手,你就可以实现自己的自定义收集器了。首先要声明一个实现 Collector 接口的新类,然后要开发 Collector 接口所需的五个方法。

  1. 第一步:定义 Collector 类的签名
    让我们从类签名开始吧,记得 Collector 接口的定义是:
    public interface Collector<T, A, R>
    其中 T 、 A 和 R 分别是流中元素的类型、用于累积部分结果的对象类型,以及 collect 操作最
    终结果的类型。这里应该收集 Integer 流,而累加器和结果类型则都是 Map<Boolean, List<Integer>>(和先前代码清单6-6中分区操作得到的结果 Map 相同),键是 true 和 false ,
    值则分别是质数和非质数的 List :
public class PrimeNumbersCollector
                implements Collector<Integer,
                Map<Boolean, List<Integer>>,
                Map<Boolean, List<Integer>>>
  1. 第二步:实现归约过程
    接下来,你需要实现 Collector 接口中声明的五个方法。 supplier 方法会返回一个在调用时创建累加器的函数:
public Supplier<Map<Boolean, List<Integer>>> supplier() {
    return () -> new HashMap<Boolean, List<Integer>>() {{
                put(true, new ArrayList<Integer>());
                put(false, new ArrayList<Integer>());
            }};
}

这里不但创建了用作累加器的 Map ,还为 true 和 false 两个键下面初始化了对应的空列表。
在收集过程中会把质数和非质数分别添加到这里。收集器中最重要的方法是 accumulator ,因为它定义了如何收集流中元素的逻辑。这里它也是实现前面所讲的优化的关键。现在在任何一次迭代中,都可以访问收集过程的部分结果,也就是包含迄今找到的质数的累加器:

public BiConsumer<Map<Boolean, List<Integer>>, Integer> accumulator() {
    return (Map<Boolean, List<Integer>> acc, Integer candidate) -> {
                acc.get( isPrime(acc.get(true), candidate) )
                .add(candidate);
            };
}

在这个方法中,你调用了 isPrime 方法,将待测试是否为质数的数以及迄今找到的质数列表(也就是累积 Map 中 true 键对应的值)传递给它。这次调用的结果随后被用作获取质数或非质数列表的键,这样就可以把新的被测数添加到恰当的列表中。

  1. 第三步:让收集器并行工作(如果可能)
    下一个方法要在并行收集时把两个部分累加器合并起来,这里,它只需要合并两个 Map ,即将第二个 Map 中质数和非质数列表中的所有数字合并到第一个 Map 的对应列表中就行了:
public BinaryOperator<Map<Boolean, List<Integer>>> combiner() {
    return (Map<Boolean, List<Integer>> map1,
            Map<Boolean, List<Integer>> map2) -> {map1.get(true).addAll(map2.get(true));
            map1.get(false).addAll(map2.get(false));
            return map1;
        };
}

请注意,实际上这个收集器是不能并行使用的,因为该算法本身是顺序的。这意味着永远都不会调用 combiner 方法,你可以把它的实现留空(更好的做法是抛出一个 Unsupported-OperationException 异常)。为了让这个例子完整,我们还是决定实现它。

  1. 第四步: finisher 方法和收集器的 characteristics 方法
    最后两个方法的实现都很简单。前面说过, accumulator 正好就是收集器的结果,用不着进一步转换,那么 finisher 方法就返回 identity 函数:
public Function<Map<Boolean, List<Integer>>,
Map<Boolean, List<Integer>>> finisher() {
	return Function.identity();
}

就 characteristics 方法而言,我们已经说过,它既不是 CONCURRENT 也不是 UNORDERED ,但却是 IDENTITY_FINISH 的:

public Set<Characteristics> characteristics() {
	return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH));
}

下面列出了最后实现的 PrimeNumbersCollector 。

public class PrimeNumbersCollector
implements Collector<Integer,
Map<Boolean, List<Integer>>,
Map<Boolean, List<Integer>>> {
    @Override
    public Supplier<Map<Boolean, List<Integer>>> supplier() {
        return () -> new HashMap<Boolean, List<Integer>>() {{
            put(true, new ArrayList<Integer>());
            put(false, new ArrayList<Integer>());
        }};
    }
    @Override
    public BiConsumer<Map<Boolean, List<Integer>>, Integer> accumulator() {
        return (Map<Boolean, List<Integer>> acc, Integer candidate) -> {
            acc.get( isPrime( acc.get(true),
            candidate) )
            .add(candidate);
        };
    }
    @Override
    public BinaryOperator<Map<Boolean, List<Integer>>> combiner() {return (Map<Boolean, List<Integer>> map1,
    Map<Boolean, List<Integer>> map2) -> {
        map1.get(true).addAll(map2.get(true));
        map1.get(false).addAll(map2.get(false));
        return map1;
        };
    }
    @Override
    public Function<Map<Boolean, List<Integer>>,
    Map<Boolean, List<Integer>>> finisher() {
    	return Function.identity();
    }
    @Override
    public Set<Characteristics> characteristics() {
    	return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH));
    }
}

现在你可以用这个新的自定义收集器来代替6.4节中用 partitioningBy 工厂方法创建的那个,并获得完全相同的结果了:

public Map<Boolean, List<Integer>>
partitionPrimesWithCustomCollector(int n) {
    return IntStream.rangeClosed(2, n).boxed()
    		.collect(new PrimeNumbersCollector());
}

7.2比较收集器的性能

用 partitioningBy 工厂方法创建的收集器和你刚刚开发的自定义收集器在功能上是一样的,但是我们有没有实现用自定义收集器超越 partitioningBy 收集器性能的目标呢?现在让我们写个小测试框架来跑一下吧:

public class CollectorHarness {
    public static void main(String[] args) {
        long fastest = Long.MAX_VALUE;
        for (int i = 0; i < 10; i++) {
            long start = System.nanoTime();
            partitionPrimes(1_000_000);
            long duration = (System.nanoTime() - start) / 1_000_000;
            if (duration < fastest) fastest = duration;
        }
        System.out.println(
            "Fastest execution done in " + fastest + " msecs");
    }
}

请注意,更为科学的测试方法是用一个诸如JMH的框架,但我们不想在这里把问题搞得更复杂。对这个例子而言,这个小小的测试类提供的结果足够准确了。这个类会先把前一百万个自然数分为质数和非质数,利用 partitioningBy 工厂方法创建的收集器调用方法10次,记下最快的一次运行。在英特尔i5 2.4 GHz的机器上运行得到了以下结果:
Fastest execution done in 4716 msecs
现在把测试框架的 partitionPrimes 换成 partitionPrimesWithCustomCollector ,以便测试我们开发的自定义收集器的性能。现在,程序打印:
Fastest execution done in 3201 msecs
还不错!这意味着开发自定义收集器并不是白费工夫,原因有二:第一,你学会了如何在需要的时候实现自己的收集器;第二,你获得了大约32%的性能提升。

最后还有一点很重要,就像代码清单6-5中的 ToListCollector 那样,也可以通过把实现 PrimeNumbersCollector 核心逻辑的三个函数传给 collect 方法的重载版本来获得同样的结果:

public Map<Boolean, List<Integer>> partitionPrimesWithCustomCollector(int n) {
	IntStream.rangeClosed(2, n).boxed()
			.collect(
 				() -> new HashMap<Boolean, List<Integer>>() {{
                	put(true, new ArrayList<Integer>());
                	put(false, new ArrayList<Integer>());
            	}},
                (acc, candidate) -> {
                    acc.get( isPrime(acc.get(true), candidate) )
                    .add(candidate);
                },
               (map1, map2) -> {
                    map1.get(true).addAll(map2.get(true));
                    map1.get(false).addAll(map2.get(false));
                });
}

你看,这样就可以避免为实现 Collector 接口创建一个全新的类;得到的代码更紧凑,虽然可能可读性会差一点,可重用性会差一点。

本文参考:

https://docs.oracle.com/javase/8/docs/api/java/util/stream/Collector.html

《Java8 in Action》电子版

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值