收集器简介
- 我们在前一篇中学到,流可以用类似于数据库的操作帮助你处理集合。你可以把Java 8的流看作花哨又懒惰的数据集迭代器。它们支持两种类型的操作:中间操作(如filter或map)和终端操作(如count、findFirst、forEach和reduce)。中间操作可以链接起来,将一个流转换为另一个流。这些操作不会消耗流,其目的是建立一个流水线。与此相反,终端操作会消耗流,以产生一个最终结果,例如返回流中的最大元素。它们通常可以通过优化流水线来缩短计算时间。
- 在之前的篇章里,我们已经使用过collect进行终端操作了,当时主要是为了把Stream中的所有元素结合成一个List。在本篇中,你会发现collect是一个归约操作,就像reduce一样可以接受各种做法作为参数,将流中的元素累积成一个汇总结果。具体的做法是通过Collector接口定义的。
- 使用collect收集器可以做什么?、
- 对一个交易列表按货币分组,获得该货币的所有交易额总和(返回一个Map
) - 将交易列表分成两组:贵的和不贵的(返回一个Map
>) - 创建多级分组,比如按城市对交易分组,然后进一步按照贵或不贵分组(返回一个Map
>) 不使用Lambda的情况
@Data
public class Transaction {
private Currency currency;
public Transaction(Currency currency) {
this.currency = currency;
}
}
```java public class TransactionClient { public static final List
getTransactions() { Set<Currency> availableCurrencies = Currency.getAvailableCurrencies();
List<Transaction> transactions = new ArrayList<>();
Iterator<Currency> iterator = availableCurrencies.iterator();
while (iterator.hasNext()) {
Currency next = iterator.next();
transactions.add(new Transaction(next));
}
return transactions;
} }
```java
public class Test1 {
public static void main(String[] args) {
List<Transaction> transactions = TransactionClient.getTransactions();
HashMap<Currency, List<Transaction>> hashMap = new HashMap<>();
// 遍历 transactions
for (Transaction transaction : transactions) {
// 获取 currency
Currency currency = transaction.getCurrency();
// 获取 currency -> List<Transaction>
List<Transaction> list = hashMap.get(currency);
// 如果为 null 则新建
if (list == null) {
list = new ArrayList<>();
hashMap.put(currency, list);
}
// 增加
list.add(transaction);
}
}
}
- 上面Test1的main方法里,写了一大堆操作,新手看起来是不是挺复杂的,其实它就做了一件事情:把列表中的交易按货币分组。
- 使用Stream如果实现?
```java
public class Test2 {
public static void main(String[] args) {
} }List<Transaction> transactions = TransactionClient.getTransactions();
Map<Currency, List<Transaction>> hashMap = transactions.stream().collect(Collectors.groupingBy(Transaction::getCurrency));
- **是不是非常快乐就完成了?**
- 收集器的优势:你只需要关心做什么,而不用关心怎么做。
- 上述的案例可以使用收集器非常方便的搞定,而多层嵌套则会增加程序的复杂度和不容易理解度。
- **收集器用作高级归约**
- 收集器可以使用更简洁、灵活的方式定义收集集合的标准。

- **预定义收集器**
- 在本篇剩下的部分中,主要探讨预定义收集器的功能,也就是那些可以从Collectors类提供的工厂方法(例如groupingBy)创建的收集器。它们主要提供了三大功能:
- 将流元素归约和汇总为一个值
- 元素分组
- 元素分区
<a name="I3WKN"></a>
# 归约和汇总
- 为了说明从Collectors工厂类中能创建出多少种收集器实例,我们重用一下前一章的例子:包含一张佳肴列表的菜单!
- **返回菜单中有多少菜**
```java
public class Test3 {
public static void main(String[] args) {
List<Dish> menu = DishClient.getMenu();
Long nums = menu.stream().collect(Collectors.counting());
long nums2 = menu.stream().count();
}
}
- 查找流中的最大值和最小值
- 如果想找出热量最高的菜
- 你可以使用两个收集器,Collectors.maxBy和Collectors.minBy,来计算流中的最大或最小值。
```java
public class Test4 {
public static void main(String[] args) {
} }List<Dish> menu = DishClient.getMenu();
Optional<Dish> optionalMin = menu.stream().collect(Collectors.minBy(Comparator.comparing(Dish::getCalories)));
Optional<Dish> optionalMax = menu.stream().collect(Collectors.maxBy(Comparator.comparing(Dish::getCalories)));
- **汇总**
- Collectors类专门为汇总提供了一个工厂方法:Collectors.summingInt。它可接受一个把对象映射为求和所需int的函数,并返回一个收集器;该收集器在传递给普通的collect方法后即执行我们需要的汇总操作。举个例子来说,你可以这样求出菜单列表的总热量
```java
public class Test5 {
public static void main(String[] args) {
List<Dish> menu = DishClient.getMenu();
Integer total = menu.stream().collect(Collectors.summingInt(Dish::getCalories));
}
}
- summingInt收集器的累积过程如下图
- Collectors.summingLong和Collectors.summingDouble方法的作用完全一样,可以用于求和字段为long或double的情况
- 汇总不仅仅是求和;还有Collectors.averagingInt,连同对应的averagingLong和averagingDouble可以计算数值的平均数
```java
public class Test6 {
public static void main(String[] args) {
} }List<Dish> menu = DishClient.getMenu();
double total = menu.stream().collect(Collectors.averagingInt(Dish::getCalories));
- 一次求出最大值、最小值、总和、平均值
```java
public class Test7 {
public static void main(String[] args) {
List<Dish> menu = DishClient.getMenu();
IntSummaryStatistics statistics = menu.stream().collect(Collectors.summarizingInt(Dish::getCalories));
long sum = statistics.getSum();
int max = statistics.getMax();
int min = statistics.getMin();
double average = statistics.getAverage();
}
}
连接字符串
public class Test8 {
public static void main(String[] args) {
List<Dish> menu = DishClient.getMenu();
String names = menu.stream().map(Dish::getName).collect(Collectors.joining());
}
}
// porkbeefchickenfrench friesriceseason fruitpizzaprawnssalmon
public class Test8 {
public static void main(String[] args) {
List<Dish> menu = DishClient.getMenu();
String names = menu.stream().map(Dish::getName).collect(Collectors.joining(","));
System.out.println(names);
}
}
// pork,beef,chicken,french fries,rice,season fruit,pizza,prawns,salmon
广义的归约汇总
- 事实上,我们已经讨论的所有收集器,都是一个可以用reducing工厂方法定义的归约过程的特殊情况而已。
- Collectors.reducing工厂方法是所有这些特殊情况的一般化。可以说,先前讨论的案例仅仅是为了方便程序员而已。(但是,请记得方便程序员和可读性是头等大事!)例如,可以用reducing方法创建的收集器来计算你菜单的总热量,如下所示
```java
public class Test9 {
public static void main(String[] args) {
} }List<Dish> menu = DishClient.getMenu();
Integer total = menu.stream().collect(Collectors.reducing(0, Dish::getCalories, (i, j) -> i + j));
- 其实很好理解,我们来解释一下
```java
public class Test9 {
public static void main(String[] args) {
List<Dish> menu = DishClient.getMenu();
Integer total = menu //
.stream() // 获取流
.collect( // 收集器
Collectors //
.reducing( //
0, // 初始值
Dish::getCalories, // 需要收集的数据
(i, j) -> i + j) // 需要收集的策略
); //
}
}
- 三个参数:
- 第一个参数是归约操作的起始值,也是流中没有元素时的返回值,所以很显然对于数值和而言0是一个合适的值。
- 第二个参数就是你在之前使用的函数,将菜肴转换成一个表示其所含热量的int。
- 第三个参数是一个BinaryOperator,将两个项目累积成一个同类型的值。这里它就是对两个int求和。
- 使用下面这样单参数形式的reducing来找到热量最高的菜
```java
public class Test10 {
public static void main(String[] args) {
} }List<Dish> menu = DishClient.getMenu();
Optional<Dish> optional = menu.stream() //
.collect( //
Collectors //
.reducing((d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2) //
);//
- 可以把单参数reducing工厂方法创建的收集器看作三参数方法的特殊情况,它把流中的第一个项目作为起点,把恒等函数(即一个函数仅仅是返回其输入参数)作为一个转换函数。这也意味着,要是把单参数reducing收集器传递给空流的collect方法,收集器就没有起点,它将因此而返回一个Optional<Dish>对象。
- Stream接口的collect和reduce方法有何不同,因为两种方法通常会获得相同的结果。
- 语义问题和实际问题:语义问题在于,reduce方法旨在把两个值结合起来生成一个新值,它是一个不可变的归约;collect方法的设计就是要改变容器,从而累积要输出的结果。
- **收集框架的灵活性:以不同的方法执行同样的操作**
- 之前的求和案例
```java
public class Test11 {
public static void main(String[] args) {
List<Dish> menu = DishClient.getMenu();
Integer total = menu.stream().collect(Collectors.reducing(0, Dish::getCalories, Integer::sum));
}
}
- 之前用到的counting收集器也是一个类似的利用三参reducing工厂方法实现的
-
分组
一个常见的数据库操作是根据一个或多个属性对集合中的项目进行分组。就像前面讲到按货币对交易进行分组的例子一样,如果用指令式风格来实现的话,这个操作可能会很麻烦、啰嗦而且容易出错。
- 假设你要把菜单中的菜按照类型进行分类,有肉的放一组,有鱼的放一组,其他的都放另一组。用Collectors.groupingBy工厂方法返回的收集器就可以轻松地完成这项任务
```java
public class Test2 {
public static void main(String[] args) {
} }List<Dish> menu = DishClient.getMenu();
Map<Dish.Type, List<Dish>> map = menu.stream().collect(Collectors.groupingBy(Dish::getType));

- 把热量不到400卡路里的菜划分为“低热量”(diet),热量400到700卡路里的菜划为“普通”(normal),高于700卡路里的划为“高热量”(fat)
```java
public enum CaloricLevel {
DIET,NORMAL,FAT
}
public class Test3 {
public static void main(String[] args) {
List<Dish> menu = DishClient.getMenu();
Map<CaloricLevel, List<Dish>> map = menu.stream().collect(Collectors.groupingBy(dish -> {
if (dish.getCalories() <= 400)
return CaloricLevel.DIET;
else if (dish.getCalories() <= 700)
return CaloricLevel.NORMAL;
return CaloricLevel.FAT;
}));
}
}
- 多级分组
如何同时对菜单中的菜肴按照类型和热量进行分组
public class Test4 {
public static void main(String[] args) {
List<Dish> menu = DishClient.getMenu();
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> map = menu.stream().collect(Collectors.groupingBy(Dish::getType, Collectors.groupingBy(dish -> {
if (dish.getCalories() <= 400)
return CaloricLevel.DIET;
else if (dish.getCalories() <= 700)
return CaloricLevel.NORMAL;
return CaloricLevel.FAT;
})));
}
}
一般来说,把groupingBy看作“桶”比较容易明白。第一个groupingBy给每个键建立了一个桶。然后再用下游的收集器去收集每个桶中的元素,以此得到n级分组。
- 按子组收集数据
- 要数一数菜单中每类菜有多少个,可以传递counting收集器作为groupingBy收集器的第二个参数
```java
public class Test5 {
public static void main(String[] args) {
} }List<Dish> menu = DishClient.getMenu();
Map<Dish.Type, Long> longMap = menu.stream().collect(Collectors.groupingBy(Dish::getType, Collectors.counting()));
- 可以把前面用于查找菜单中热量最高的菜肴的收集器改一改,按照菜的类型分类
```java
public class Test6 {
public static void main(String[] args) {
List<Dish> menu = DishClient.getMenu();
Map<Dish.Type, Optional<Dish>> map = menu.stream().collect(Collectors.groupingBy(Dish::getType, Collectors.maxBy(Comparator.comparingInt(Dish::getCalories))));
}
}
- 这个Map中的值是Optional,因为这是maxBy工厂方法生成的收集器的类型,但实际上,如果菜单中没有某一类型的Dish,这个类型就不会对应一个Optional. empty()值,而且根本不会出现在Map的键中。groupingBy收集器只有在应用分组条件后,第一次在流中找到某个键对应的元素时才会把键加入分组Map中。这意味着Optional包装器在这里不是很有用,因为它不会仅仅因为它是归约收集器的返回类型而表达一个最终可能不存在却意外存在的值。
- 把收集器的结果转换为另一种类型
```java
public class Test7 {
public static void main(String[] args) {
} }List<Dish> menu = DishClient.getMenu();
Map<Dish.Type, Dish> map = menu.stream().collect(
Collectors.groupingBy(Dish::getType, Collectors.collectingAndThen(Collectors.maxBy(Comparator.comparingInt(Dish::getCalories)), Optional::get)));
- 这个工厂方法接受两个参数——要转换的收集器以及转换函数,并返回另一个收集器。
- 这个收集器相当于旧收集器的一个包装,collect操作的最后一步就是将返回值用转换函数做一个映射。在这里,被包起来的收集器就是用maxBy建立的那个,而转换函数Optional::get则把返回的Optional中的值提取出来。前面已经说过,这个操作放在这里是安全的,因为reducing收集器永远都不会返回Optional.empty()。

- 收集器用虚线表示,因此groupingBy是最外层,根据菜肴的类型把菜单流分组,得到三个子流
- groupingBy收集器包裹着collectingAndThen收集器,因此分组操作得到的每个子流都用这第二个收集器做进一步归约
- collectingAndThen收集器又包裹着第三个收集器maxBy
- 随后由归约收集器进行子流的归约操作,然后包含它的collectingAndThen收集器会对其结果应用Optional:get转换函数
- 对三个子流分别执行这一过程并转换而得到的三个值,也就是各个类型中热量最高的Dish,将成为groupingBy收集器返回的Map中与各个分类键(Dish的类型)相关联的值
- **与groupingBy联合使用的其他收集器的例子**
- 一般来说,通过groupingBy工厂方法的第二个参数传递的收集器将会对分到同一组中的所有流元素执行进一步归约操作
- 求出所有菜肴热量总和的收集器,对每一组Dish求和
```java
public class Test8 {
public static void main(String[] args) {
List<Dish> menu = DishClient.getMenu();
Map<Dish.Type, Integer> map = menu.stream().collect(Collectors.groupingBy(Dish::getType, Collectors.summingInt(Dish::getCalories)));
}
}
- 常常和groupingBy联合使用的另一个收集器是mapping方法生成的。这个方法接受两个参数:一个函数对流中的元素做变换,另一个则将变换的结果对象收集起来。其目的是在累加之前对每个输入元素应用一个映射函数,这样就可以让接受特定类型元素的收集器适应不同类型的对象。
- 对于每种类型的Dish,菜单中都有哪些CaloricLevel。我们可以把groupingBy和mapping收集器结合起来
```java
public class Test9 {
public static void main(String[] args) {
} }List<Dish> menu = DishClient.getMenu();
Map<Dish.Type, Set<CaloricLevel>> map = menu.stream().collect(Collectors.groupingBy(Dish::getType, Collectors.mapping(dish -> {
if (dish.getCalories() <= 400)
return CaloricLevel.DIET;
else if (dish.getCalories() <= 700)
return CaloricLevel.NORMAL;
return CaloricLevel.FAT;
}, Collectors.toSet())));
- 你也可以指定具体的实现类
```java
public class Test10 {
public static void main(String[] args) {
List<Dish> menu = DishClient.getMenu();
Map<Dish.Type, Set<CaloricLevel>> map = menu.stream().collect(Collectors.groupingBy(Dish::getType, Collectors.mapping(dish -> {
if (dish.getCalories() <= 400)
return CaloricLevel.DIET;
else if (dish.getCalories() <= 700)
return CaloricLevel.NORMAL;
return CaloricLevel.FAT;
}, Collectors.toCollection(HashSet::new))));
}
}
分区
- 分区是分组的特殊情况:由一个谓词(返回一个布尔值的函数)作为分类函数,它称分区函数。分区函数返回一个布尔值,这意味着得到的分组Map的键类型是Boolean,于是它最多可以分为两组——true是一组,false是一组。
- 把菜单按照素食和非素食分开
```java
public class Test11 {
public static void main(String[] args) {
} }List<Dish> menu = DishClient.getMenu();
Map<Boolean, List<Dish>> map = menu.stream().collect(Collectors.partitioningBy(Dish::isVegetarian));
- **分区的优势**
- 分区的好处在于保留了分区函数返回true或false的两套流元素列表。
- 分区内再分区
```java
public class Test12 {
public static void main(String[] args) {
List<Dish> menu = DishClient.getMenu();
Map<Boolean, Map<Boolean, List<Dish>>> map = menu.stream()
.collect(Collectors.partitioningBy(Dish::isVegetarian, Collectors.partitioningBy(dish -> dish.getCalories() > 500)));
}
}
自定义收集器
- 收集器接口:Collector接口包含了一系列方法,为实现具体的归约操作(即收集器)提供了范本。我们已经看过了Collector接口中实现的许多收集器,例如toList或groupingBy。这也意味着,你可以为Collector接口提供自己的实现,从而自由地创建自定义归约操作
- Collector接口的定义和声明
```java
// T是流中要收集的项目的泛型
// A是累加器的类型,累加器是在收集过程中用于累积部分结果的对象
// R是收集操作得到的对象(通常但并不一定是集合)的类型
public interface Collector
{ // 建立新的结果容器 // supplier方法必须返回一个结果为空的Supplier,也就是一个无参数函数,在调用时它会创建一个空的累加器实例,供数据收集过程使用。 Supplier supplier(); // 将元素添加到结果容器 // accumulator方法会返回执行归约操作的函数。 // 当遍历到流中第n个元素时,这个函数执行时会有两个参数:保存归约结果的累加器(已收集了流中的前n-1个项目),还有第n个元素本身。 // 该函数将返回void,因为累加器是原位更新,即函数的执行改变了它的内部状态以体现遍历的元素的效果。 BiConsumer accumulator(); // 合并两个结果容器 // combiner方法会返回一个供归约操作使用的函数,它定义了对流的各个子部分进行并行处理时,各个子部分归约所得的累加器要如何合并。 BinaryOperator combiner(); // 对结果容器应用最终转换 // 在遍历完流后,finisher方法必须返回在累积过程的最后要调用的一个函数,以便将累加器对象转换为整个集合操作的最终结果。 Function finisher(); // characteristics会返回一个不可变的Characteristics集合,它定义了收集器的行为——尤其是关于流是否可以并行归约,以及可以使用哪些优化的提示。 Set characteristics();
public static<T, A, R> Collector<T, A, R> of(Supplier<A> supplier,
BiConsumer<A, T> accumulator,
BinaryOperator<A> combiner,
Function<A, R> finisher,
Characteristics... characteristics) {
Objects.requireNonNull(supplier);
Objects.requireNonNull(accumulator);
Objects.requireNonNull(combiner);
Objects.requireNonNull(finisher);
Objects.requireNonNull(characteristics);
Set<Characteristics> cs = Collectors.CH_NOID;
if (characteristics.length > 0) {
cs = EnumSet.noneOf(Characteristics.class);
Collections.addAll(cs, characteristics);
cs = Collections.unmodifiableSet(cs);
}
return new Collectors.CollectorImpl<>(supplier, accumulator, combiner, finisher, cs);
}
enum Characteristics {
// accumulator函数可以从多个线程同时调用,且该收集器可以并行归约流。
// 如果收集器没有标为UNORDERED,那它仅在用于无序数据源时才可以并行归约。
CONCURRENT,
// 归约结果不受流中项目的遍历和累积顺序的影响
UNORDERED,
// 这表明完成器方法返回的函数是一个恒等函数,可以跳过。
// 这种情况下,累加器对象将会直接用作归约过程的最终结果。
// 这也意味着,将累加器A不加检查地转换为结果R是安全的。
IDENTITY_FINISH
}
}
- **自定义案例:ToListCollector**
```java
public class ToListCollector<T> implements Collector<T,List<T>,List<T>> {
/**
* 返回空的 Supplier
* 遍历集合的起点
*
* @return 返回空的 Supplier
*/
@Override
public Supplier<List<T>> supplier() {
return ArrayList::new;
}
/**
* 把当前项目添加至已经遍历过的项目的列表
* 累计遍历过的项目,原位修改累加器
*
* @return BiConsumer
*/
@Override
public BiConsumer<List<T>, T> accumulator() {
return List::add;
}
/**
* 修改第一个累加器,将其与第二个累加器合并
* 返回第一个累加器
*
* @return BinaryOperator
*/
@Override
public BinaryOperator<List<T>> combiner() {
return (list1, list2) -> {
list1.addAll(list2);
return list1;
};
}
/**
* 恒等函数
*
* @return Function
*/
@Override
public Function finisher() {
return Function.identity();
}
@Override
public Set<Characteristics> characteristics() {
return Collections.unmodifiableSet(EnumSet.of(Characteristics.IDENTITY_FINISH, Characteristics.CONCURRENT));
}
}
public class Test {
public static void main(String[] args) {
List<Dish> menu = DishClient.getMenu();
List<Dish> collect = menu.stream().collect(new ToListCollector<Dish>());
}
}
- 顺序归约过程的逻辑步骤图解
- 基于supplier、accumulator、finisher可以完成顺序归约如下
- 并行归约过程逻辑步骤图解
- 基于supplier、accumulator、finisher、combiner可以完成并行归约如下
- 进行自定义收集而不去实现Collector
- 对于IDENTITY_FINISH的收集操作,还有一种方法可以得到同样的结果而无需从头实现新的Collectors接口。Stream有一个重载的collect方法可以接受另外三个函数——supplier、accumulator和combiner,其语义和Collector接口的相应方法返回的函数完全相同。
```java
public class Test1 {
public static void main(String[] args) {
} }List<Dish> menu = DishClient.getMenu();
ArrayList<Object> list = menu.stream().collect(ArrayList::new, List::add, List::addAll);
```
第二种形式虽然比前一个写法更为紧凑和简洁,却不那么易读,不推荐。
小结
collect是一个终端操作,它接受的参数是将流中元素累积到汇总结果的各种方式(称为收集器)
- 预定义收集器包括将流元素归约和汇总到一个值,例如计算最小值、最大值或平均值
- 预定义收集器可以用groupingBy对流中元素进行分组,或用partitioningBy进行分区
- 收集器可以高效地复合起来,进行多级分组、分区和归约
可以实现Collector接口中定义的方法来开发你自己的收集器
参考文章
《Java 8 in Action》
- 《Java8函数式编程》