Collector
接口中包含一系列方法,为实现具体的归约操作(即收集器)提供了范本。Collectors
类中已经提供了一些静态工厂方法来返回常见收集器,例如toList
或者groupingBy
。也可以为Collector
接口提供自己的实现,从而创建自定义归约操作。本章将会先介绍实现一个类似于toList
收集器实现过程,依此了解Collector
接口的定义,然后通过实现Collector
接口来自定义一个收集器,并用此收集器将数值流划分为质数和非质数。
Collector
接口在java.util.stream
包下,下面是接口源码。
public interface Collector<T, A, R> {
Supplier<A> supplier();
BiConsumer<A, T> accumulator();
Function<A, R> finisher();
BinaryOperator<A> combiner();
Set<Characteristics> characteristics();
}
其中:
T
是流中要收集的项目的泛型。A
是累加器的类型,累加器是在收集过程中用于累积部分结果的对象。R
是收集操作得到的对象(通常但并不一定是集合)的类型。例如现在需要实现一个类似于toList
收集器,首先定义一个ToListCollector
类,它的目的是将Stream
中的所有元素收集到List
里。
public class ToListCollector<T> implements Collector<T, List<T>, List<T>>
接下来将会逐个分析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;
}
——————2.将元素添加到结果容器: 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;
}
——————3. 对结果容器应用最终转换: finisher
方法
在遍历完流后, finisher
方法必须返回在累积过程的最后要调用的一个函数,以便将累加器对象转换为整个集合操作的最终结果。对于ToListCollector
来说,累加器对象恰好符合预期的最终结果(即List
),因此无需进行转换。所以finisher
方法只需要返回 identity
函数。
public Function<List<T>, List<T>> finisher() {
return Function.identity();
}
这三个方法已经足以对流进行顺序归约。实践中实现细节可能还要复杂一点,一方面是因为流的延迟性质,可能在 collect
操作之前还需要完成其他中间操作的流水线,另一方面则是理论上可能要进行并行归约。
——————4.合并两个结果容器: combiner
方法
combiner
方法会返回一个供归约操作使用的函数,它定义了对流的各个子部分进行并行处理时,各个子部分归约所得的累加器要如何合并。对于toList
而言,这个方法的实现非常简单,只要把从流的第二个部分收集到的项目列表加到遍历第一部分得到的列表后面就行了:
public BinaryOperator<List<T>> combiner() {
return (list1, list2) -> {
list1.addAll(list2);
return list1;
}
}
有了这第四个方法,就可以对流进行归约了,它会用到Java7中引入的分支/合并框架和Spliterator
抽象。具体过程是:
combiner
方法返回的函数,将所有部分结果两两合并。这时会把原始流每次拆分时得到的子流对应的结果合并起来。——————5. 定义收集器的行为:characteristics
方法
最后一个方法characteristics
返回一个不可变的Characteristics
集合,它定义了收集器的行为,尤其是关于流是否可以并行归约,以及可以使用哪些优化的提示。Characteristics
是一个包含三个项目的枚举。
UNORDERED
——归约结果不受流中项目的遍历和累积顺序的影响。CONCURRENT
—— accumulator
函数可以从多个线程同时调用,且该收集器可以并行归约流。如果收集器没有标为 UNORDERED
,那它仅在用于无序数据源时才可以并行归约。IDENTITY_FINISH
——这表明完成器方法返回的函数是一个恒等函数,可以跳过。这种情况下,累加器对象将会直接用作归约过程的最终结果。这也意味着将累加器A
不加检查地转换为结果R
是安全的。 ToListCollector
是IDENTITY_FINISH
的,因为用来累积流中的元素的List已经是需要的最终结果。不用再进行转换。但它并不是UNORDERED
,因为用在有序流上的时候,需要让这种顺序能够保留在得到的List中。最后,它是CONCURRENT
,只有数据源无序时才会并行处理。
现在可以将前面代码进行融合在一起了
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)); //为收集器添加 IDENTITY_FINISH 和 CONCURRENT 标志
}
}
注意,这个实现与Collectors.toList
方法并不完全相同,但区别仅仅是一些小的优化。优化的一个主要方面是Java API所提供的收集器在需要返回空列表时使用了Collections.emptyList()
这个单例。这意味着它可安全地替代原生Java,来收集菜单流中的所有Dish的列表:
List<Dish> dishes = menuStream.collect(new ToListCollector<Dish>());
这个实现和标准的
List<Dish> dishes = menuStream.collect(toList());
构造之间的其他差异在于toList
是一个工厂,而ToListCollector
必须用new来实例化。进行自定义收集而不去实现 Collector
对于 IDENTITY_FINISH
的收集操作,还有一种方法可以得到同样的结果而无需自定义实现新的Collectors
接口。 Stream有一个重载的collect
方法可以接受另外三个函数—— supplier
、accumulator
和 combiner
,其语义和 Collector
接口的相应方法返回的函数完全相同。比如说可以像下面这样把菜肴流中的项目收集到一个 List中:
List<Dish> dishes = menuStream.collect(
ArrayList::new,//供应源
List::add,//累加器
List::addAll);//组合器
这种形式虽然比前一个写法更为紧凑和简洁,却不那么易读。此外,以恰当的类来实现自己的自定义收集器有助于重用并可避免代码重复。另外值得注意的是,这种方式的collect方法不能传递任何 Characteristics
,所以它永远都是一个 IDENTITY_FINISH
和CONCURRENT
但并非 UNORDERED
的收集器。
利用Collectors
类提供的工厂方法partitioningBy
来创建一个收集器,将前n个自然数划分为质数和非质数,如下所示:
public Map<Boolean, List<Integer>> partitionPrimes(int n) {
return IntStream.rangeClosed(2, n).boxed()
.collect(partitioningBy(candidate -> isPrime(candidate));
}
public boolean isPrime(int candidate) {
int candidateRoot = (int) Math.sqrt((double) candidate);
return IntStream.rangeClosed(2, candidateRoot)
.noneMatch(i -> candidate % i == 0);
}
此处对isPrime
有一个优化,限制除数不超过被测数的平方根。为了获得更好的性能,还可以开发一个自定义收集器。可以通过仅仅看被测试数是不是能够被质数整除,要是除数本身都不是质数就用不着测了。Collector
预定义的收集器,在收集过程中是没办法访问部分结果,这也意味着,当测试一个数字是否是质数时,没办法访问目前已经找到的其他质数的列表。这也是必须自己开发一个收集器的原因。
对于上面的测试是否为质数方法还可以继续优化,仅仅用小于被测数平方根的质数来测试,在下一个质数大于被测数平方根时立即停止测试,但Stream API中没有这样一种方法。可以通过使用filter(p -> p <= candidateRoot)
来是筛选出大于被测数平方根的质数。但filter方法要处理整个流才能返回恰当的结果。如果质数和非质数的列表非常大,这就是个问题了。因此可以创建一个takeWhile方法,给定一个排序列表和谓词Predicate,它会返回元素满足谓词的最长前缀:
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);
}
有了此方法可以实现自定义收集器了,首先需要声明一个实现Collector
接口的新类,然后开发Collector
所需要的五个方法。
【第一步:定义Collector
类的签名】
定义一个Collector
接口:
public interface Collector<T, A, R>
其中T
、A
和R
分别是流中元素的类型、用于累积部分结果的对象类型,以及collect
操作最终结果的类型。这里应该收集 Integer
流,而累加器和结果类型则都是Map
,键是true和false,对应值分别是质数和非质数的List。
public class PrimeNumbersCollector
implements Collector<Integer,//流中元素的类型
Map<Boolean, List<Integer>>,//累加器类型
Map<Boolean, List<Integer>>>//collect 操作的结果类型
【第二步:实现归约过程】
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) )//根据isPrime的结果,获取质数或非质数列表
.add(candidate);//将被测数添加到相应的列表中
};
}
在此方法中,调用了isPrime
方法,将待测试是否为质数的数以及迄今为止找到的是质数列表(也就是Map
中true键对应的值)传递给它。这次调用的结果随后被用作获取质数或非质数列表的键,这样就可以把新的被测数添加到恰当的列表中。
【第三步:让收集器并行工作(如果可能)】
下一个方法要在并行收集时把两个部分累加器合并起来,这里需要合并两个Map
,将第二个Map
中质数和非质数列表中的所有数字分别合并到第一个Map
的对应列表。
public BinaryOperator<Map<Boolean, List<Integer>>> combiner() {
return (Map<Boolean,List<Integer>> acc1,Map<Boolean,List<Integer>> acc2)->{
acc1.get(true).addAll(acc2.get(true));
acc1.get(false).addAll(acc2.get(false));
return acc1;
};
}
需要注意的是,实际上这个收集器是不能并行使用的,因为该算法本身是顺序的。这意味着永远都不会调用combiner
方法,因此可以把它的实现留空(更好的做法是抛出一个UnsupportedOperationException
异常)。
【第四步: 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<>());
put(false, new ArrayList<>());
}
};
}
@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>> acc1, Map<Boolean, List<Integer>> acc2) -> {
acc1.get(true).addAll(acc2.get(true));
acc1.get(false).addAll(acc2.get(false));
return acc1;
};
}
@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(Characteristics.IDENTITY_FINISH));
}
}
现在可以使用自定义收集器对质数和非质数分组了。
public Map<Boolean, List<Integer>>
partitionPrimesWithCustomCollector(int n) {
return IntStream.rangeClosed(2, n).boxed()
.collect(new PrimeNumbersCollector());
}
实际上可以将PrimeNumbersCollector
核心逻辑的三个函数传递collect
方法的重载版本来获得同样的结果。这样就可以避免为实现Collector
接口创建一个全新的类,代码更加紧凑,虽然可读性和可重用性稍差一点。
private static Map<Boolean, List<Integer>> partitionPrimesWithCollectors(int n) {
return IntStream.rangeClosed(2, n).boxed().collect(() -> new HashMap<Boolean, List<Integer>>() {
{
put(true, new ArrayList<>());
put(false, new ArrayList<>());
}
}, (acc, candidate) -> acc.get(isPrime(acc.get(true), candidate)).add(candidate), (map1, map2) -> {
map1.get(true).addAll(map2.get(true));
map2.get(false).addAll(map2.get(false));
});
}