collect 是一个归约操作,就像 reduce 一样可以接受各种做法作为参数,将流中的元素累积成一个汇总结果。具体的做法是通过定义新的Collector 接口来定义的,因此区分 Collection、Collector 和 collect 是很重要的。
用 collect 和收集器能够做什么。
- 对一个交易列表按货币分组,获得该货币的所有交易额总和(返回一个 Map<Currency, Integer>)。
- 将交易列表分成两组:贵的和不贵的(返回一个 Map<Boolean, List>)。
- 创建多级分组,比如按城市对交易分组,然后进一步按照贵或不贵分组(返回一个Map<String, Map<Boolean, List>>)。
用指令式风格对交易按照货币分组:
Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap<>();
for (Transaction transaction : transactions) {
Currency currency = transaction.getCurrency();
List<Transaction> transactionsForCurrency = transactionsByCurrencies.get(currency);
// 如果分组 Map 中没有这种货币的条目,就创建一个
if (transactionsForCurrency == null) {
transactionsForCurrency = new ArrayList<>();
transactionsByCurrencies.put(currency, transactionsForCurrency);
}
// 将当前遍历的 Transaction 加入同一货币的 Transaction 的 List
transactionsForCurrency.add(transaction);
}
Stream 中 collect 方法的一个更通用的 Collector 参数
Map<Currency, List> transactionsByCurrencies = transactions.stream().collect(groupingBy(Transaction::getCurrency));
一行代码搞定
6.1 收集器简介
函数式编程相对于指令式编程的一个主要优势:你只需指出希望的结果——“做什么”,而不用操心执行的步骤——“如何做”。
6.1.1 收集器用作高级归约
优秀的函数式 API 设计的另一个好处:更易复合和重用。
6.1.2 预定义收集器
三大功能:
- 将流元素归约和汇总为一个值;
- 元素分组;
- 元素分区。
6.2 归约和汇总
利用 counting 工厂方法返回的收集器,数一数菜单里有多少种菜:long howManyDishes = menu.stream().collect(Collectors.counting());
还可以写得更为直接:long howManyDishes = menu.stream().count();
6.2.1 查找流中的最大值和最小值
想要找出菜单中热量最高的菜。可以用这两个收集器,Collectors.maxBy 和Collectors.minBy,来计算流中的最大值或最小值。
还可以创建一个 Comparator 来根据所含热量对菜肴进行比较。Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);
6.2.2 汇总
Collectors 类专门为汇总提供了一个工厂方法:Collectors.summingInt。它可接受一个把对象映射为求和所需 int 的函数,并返回一个收集器;
求出菜单列表的总热量:int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
6.2.3 连接字符串
joining 工厂方法返回的收集器会把对流中每一个对象应用 toString 方法得到的所有字符串连接成一个字符串。String shortMenu = menu.stream().map(Dish::getName).collect(joining());
joining 在内部使用了 StringBuilder 来把生成的字符串逐个追加起来。
如果 Dish 类有一个 toString 方法来返回菜肴的名称,那你无需用提取每一道菜名称的函数来对原流做映射就能够得到相同的结果:String shortMenu = menu.stream().collect(joining());
joining 工厂方法有一个重载版本可以接受元素之间的分界符String shortMenu = menu.stream().map(Dish::getName).collect(joining(", "));
6.2.4 广义的归约汇总
- 收集框架的灵活性:以不同的方法执行同样的操作
int totalCalories = menu.stream().collect(reducing(0, Dish::getCalories, Integer::sum));
测验 6.1:用 reducing 连接字符串
以下哪一种 reducing 收集器的用法能够合法地替代 joining 收集器(如 6.2.3节用法)?
String shortMenu = menu.stream().map(Dish::getName).collect(joining());
(1) String shortMenu = menu.stream().map(Dish::getName) .collect( reducing ( (s1, s2) -> s1 + s2 ) ).get();
(2) String shortMenu = menu.stream().collect( reducing( (d1, d2) -> d1.getName() + d2.getName() ) ).get();
(3) String shortMenu = menu.stream().collect( reducing( “”,Dish::getName, (s1, s2) -> s1 + s2 ) );
答案:语句(1)和语句(3)是有效的,语句(2)无法编译。
(1) 这会将每道菜转换为菜名,就像原先使用 joining 收集器的语句一样。然后用一个String 作为累加器归约得到的字符串流,并将菜名逐个连接在它后面。
(2) 这无法编译,因为 reducing 接受的参数是一个 BinaryOperator,也就是一个BiFunction<T,T,T>。这就意味着它需要的函数必须能接受两个参数,然后返回一个相同类型的值,但这里用的 Lambda 表达式接受的参数是两个菜,返回的却是一个字符串。
(3) 这会把一个空字符串作为累加器来进行归约,在遍历菜肴流时,它会把每道菜转换成菜名,并追加到累加器上。请注意,前面讲过,reducing 要返回一个 Optional 并不需要三个参数,因为如果是空流的话,它的返回值更有意义——也就是作为累加器初始值的空字符串。
请注意,虽然语句(1)和语句(3)都能够合法地替代 joining 收集器,但是它们在这里是用来展示为何可以(至少在概念上)把 reducing 看作本章中讨论的所有其他收集器的概括。然而就实际应用而言,不管是从可读性还是性能方面考虑,我们始终建议使用 joining 收集器。
6.3 分组
一个常见的数据库操作是根据一个或多个属性对集合中的项目进行分组。
根据类型分组Map<Dish.Type, List<Dish>> dishesByType = menu.stream().collect(groupingBy(Dish::getType));
你给 groupingBy 方法传递了一个 Function(以方法引用的形式),它提取了流中每一道 Dish 的 Dish.Type。我们把这个 Function 叫作分类函数,因为它用来把流中的元素分成不同的组。
public enum CaloricLevel { DIET, NORMAL, FAT }
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream().collect(
groupingBy(dish -> {
if (dish.getCalories() <= 400) return CaloricLevel.DIET;
else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
} ));
6.3.1 操作分组的元素
执行完分组操作后,你往往还需要对每个分组中的元素执行操作。
假设你希望只按照菜肴的热量进行过滤操作,譬如找出那些热量大于 500 卡路里的菜肴。你可能会说,这种情况只要在分组之前执行过滤谓词就好了
Map<Dish.Type, List<Dish>> caloricDishesByType =
menu.stream().filter(dish -> dish.getCalories() > 500)
.collect(groupingBy(Dish::getType));
应该有三种类型的:OTHER、 MEAT、FISH
但是因为fish类型的没有符合条件,就没有显示,因为他是先过滤,再分组
{OTHER=[french fries, pizza], MEAT=[pork, beef]}
为了解决这个问题,Collectors 类重载了工厂方法 groupingBy,除了常见的分类函数,它的第二变量也接受一个 Collector 类型的参数。
Map<Dish.Type, List<Dish>> caloricDishesByType =
menu.stream().collect(groupingBy(Dish::getType,
filtering(dish -> dish.getCalories() > 500, toList())));
{OTHER=[french fries, pizza], MEAT=[pork, beef], FISH=[]}
这种方式就保存了fish的列表,即使他是空的
操作分组元素的另一种常见做法是使用一个映射函数对它们进行转换。
Collectors 类通过 mapping 方法提供了另一个 Collector 函数,它接受一个映射函数和另一个 Collector 函数作为参数。作为参数的 Collector 会收集对每个元素执行该映射函数的运行结果。
Map<Dish.Type, List<String>> dishNamesByType =
menu.stream().collect(groupingBy(Dish::getType, mapping(Dish::getName, toList())));
6.3.2 多级分组
要实现多级分组,可以使用一个由双参数版本的 Collectors.groupingBy 工厂方法创建的收集器,它除了普通的分类函数之外,还可以接受 collector 类型的第二个参数。那么要进行二级分组的话,可以把一个内层 groupingBy 传递给外层 groupingBy,并定义一个为流中项目分类的二级标准
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> 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;
} )
)
);
6.3.3 按子组收集数据
数一下每个分类的数量,可以传递 counting 收集器作为groupingBy 收集器的第二个参数Map<Dish.Type, Long> typesCount = menu.stream().collect(groupingBy(Dish::getType, counting()));
求每个分组中热量最高的
Map<Dish.Type, Optional<Dish>> mostCaloricByType =
menu.stream().collect(groupingBy(Dish::getType,
maxBy(comparingInt(Dish::getCalories))));
- 把收集器的结果转换为另一种类型
查找每个子组中热量最高的 Dish
Map<Dish.Type, Dish> mostCaloricByType =
menu.stream()
.collect(groupingBy(Dish::getType, // 分类函数
collectingAndThen(
maxBy(comparingInt(Dish::getCalories)), // 包装后的收集器
Optional::get))); // 转换函数
主要是转换函数 Optional::get把Optional的值给提取出来
- 与 groupingBy 联合使用的其他收集器的例子
对热量求和
Map<Dish.Type, Integer> totalCaloriesByType =
menu.stream().collect(groupingBy(Dish::getType,
summingInt(Dish::getCalories)));
然而常常和 groupingBy 联合使用的另一个收集器是 mapping 方法生成的。这个方法接受两个参数:一个函数对流中的元素做变换,另一个则将变换的结果对象收集起来。
6.4 分区
分区是分组的特殊情况:由一个谓词(返回一个布尔值的函数)作为分类函数,它称分区函数。
Map<Boolean, List<Dish>> partitionedMenu = menu.stream().collect(
partitioningBy(Dish::isVegetarian));
得出:
{false=[pork, beef, chicken, prawns, salmon],
true=[french fries, rice, season fruit, pizza]}
返回true的value值
List<Dish> vegetarianDishes = partitionedMenu.get(true);
也可以使用谓词区分List<Dish> vegetarianDishes = menu.stream().filter(Dish::isVegetarian).collect(toList());
6.4.1 分区的优势
分区的好处在于保留了分区函数返回 true 或 false 的两套流元素列表。
你可以使用两个筛选操作来访问 partitionedMenu 这个 Map 中false 键的值:一个利用谓词,一个利用该谓词的非。
Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishesByType =
menu.stream().collect(
partitioningBy(Dish::isVegetarian, // 分区函数
groupingBy(Dish::getType))); // 第二个收集器
找到素食和非素食中热量最高的菜:
Map<Boolean, Dish> mostCaloricPartitionedByVegetarian =
menu.stream().collect(
partitioningBy(Dish::isVegetarian,
collectingAndThen(maxBy(comparingInt(Dish::getCalories)),
Optional::get)));
测验 6.2:使用 partitioningBy
我们已经看到,和 groupingBy 收集器类似,partitioningBy 收集器也可以结合其他收集器使用。尤其是它可以与第二个 partitioningBy 收集器一起使用来实现多级分区。以下多级分区的结果会是什么呢?
(1) menu.stream().collect(partitioningBy(Dish::isVegetarian, partitioningBy(d -> d.getCalories() > 500)));
(2) menu.stream().collect(partitioningBy(Dish::isVegetarian, partitioningBy(Dish:: getType)));
(3) menu.stream().collect(partitioningBy(Dish::isVegetarian, counting()));
答案:
(1) 这是一个有效的多级分区,产生以下二级 Map:
{false={false=[chicken, prawns, salmon], true=[pork, beef]},
true={false=[rice, season fruit], true=[french fries, pizza]}}
(2) 这无法编译,因为 partitioningBy 需要一个谓词,也就是返回一个布尔值的函数。方法引用 Dish::getType 不能用作谓词。
(3) 它会计算每个分区中项目的数目,得到以下 Map:{false=5, true=4}
6.4.2 将数字按质数和非质数分区
public boolean isPrime(int candidate) {
return IntStream.range(2, candidate) // 产生一个自然数范围,从2开始,直至但不包括待测数
.noneMatch(i -> candidate % i == 0); // 如果待测数字不能被流中任何数字整除则返回 true
}
6.5 收集器接口
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();
}
T 是流中要收集的项目的泛型。
A 是累加器的类型,累加器是在收集过程中用于累积部分结果的对象。
R 是收集操作得到的对象(通常但并不一定是集合)的类型。
6.5.1 理解 Collector 接口声明的方法
上面的前四个方法都会返回一个会被 collect 方法调用的函数,第五个方法 characteristics 则提供了一系列特征,也就是一个提示列表,告诉 collect 方法在执行归约操作的时候可以应用哪些优化(比如并行化)。
- 建立新的结果容器:supplier 方法
supplier 方法必须返回一个结果为空的 Supplier,也就是一个无参数函数,在调用时它会创建一个空的累加器实例,供数据收集过程使用。
比如我们的 ToListCollector,在对空流执行操作的时候,这个空的累加器也代表了收集过程的结果。在我们的 ToListCollector 中,supplier 返回一个空的 List,如下所示:
public Supplier<List<T>> supplier() {
return () -> new ArrayList<T>();
}
也可以这样子写
public Supplier<List<T>> supplier() {
return ArrayList::new;
}
- 将元素添加到结果容器:accumulator 方法
accumulator 方法会返回执行归约操作的函数。
这个函数执行时会有两个参数:保存归约结果的累加器(已收集了流中的前 n–1 个项目),还有第 n 个元素本身。
public BiConsumer<List<T>, T> accumulator() {
return (list, item) -> list.add(item);
}
或
public BiConsumer<List<T>, T> accumulator() {
return List::add;
}
- 对结果容器应用最终转换:finisher 方法
finisher 方法必须返回在累积过程的最后要调用的一个函数,以便将累加器对象转换为整个集合操作的最终结果。
累加器对象恰好符合预期的最终结果,因此无须进行转换。所以 finisher 方法只需返回 identity 函数:
public Function<List<T>, List<T>> finisher() {
return Function.identity();
}
- 合并两个结果容器:combiner 方法
public BinaryOperator<List<T>> combiner() {
return (list1, list2) -> {
list1.addAll(list2);
return list1; }
}
- characteristics 方法
characteristics 会返回一个不可变的 Characteristics 集合,它定义了收集器的行为——尤其是关于流是否可以并行归约,以及可以使用哪些优化的提示。
Characteristics 是一个包含三个项目的枚举。
- UNORDERED——归约结果不受流中项目的遍历和累积顺序的影响。
- CONCURRENT——accumulator 函数可以从多个线程同时调用,且该收集器可以并行归约流。如果收集器没有标为 UNORDERED,那它仅在用于无序数据源时才可以并行归约。
- IDENTITY_FINISH——这表明完成器方法返回的函数是一个恒等函数,可以跳过。这种情况下,累加器对象将会直接用作归约过程的最终结果。这也意味着,将累加器 A 不加检查地转换为结果 R 是安全的。
6.5.2 全部融合到一起
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.identity(); // 恒等函数
}
@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 方法并不完全相同,但区别仅仅是一些小的优化。构造之间的其他差异在于,oList 是一个工厂,而 ToListCollector 必须用 new 来实例化。
进行自定义收集而不去实现 Collector
对于 IDENTITY_FINISH 的收集操作,还有一种方法可以得到同样的结果而无须从头实现新的 Collector 接口。
Stream 有一个重载的 collect 方法可以接受另外三个函数——supplier、accumulator 和 combiner.
List<Dish> dishes = menuStream.collect(
ArrayList::new, // 供应源
List::add, // 累加器
List::addAll); // 组合器
6.6 开发你自己的收集器以获得更好的性能
将前 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);
}
如果想更好的性能,就需要自己开发
6.6.1 仅用质数做除数
一个可能的优化是仅看被测试数是不是能够被质数整除,所以就得开发一个收集器
假设你有这个列表,那就可以把它传给 isPrime 方法,将方法重写如下:
public static boolean isPrime(List<Integer> primes, int candidate) {
return primes.stream().noneMatch(i -> candidate % i == 0);
}
在下一个质数大于被测数平方根时立即停止测试
public static boolean isPrime(List<Integer> primes, int candidate){
int candidateRoot = (int) Math.sqrt((double) candidate);
return primes.stream()
.takeWhile(i -> i <= candidateRoot)
.noneMatch(i -> candidate % i == 0);
}
- 第 1 步:定义 Collector 类的签名
public interface Collector<T, A, R>
public class PrimeNumbersCollector
implements Collector<Integer, // 流中元素的类型
Map<Boolean, List<Integer>>, // 累加器类型
Map<Boolean, List<Integer>>> // collect 操作的结果类型
- 第 2 步:实现归约过程
接下来,你需要实现 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 两个键初始化了对应的空列表
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 键对应的值)传递给它。
- 第 3 步:让收集器并行工作(如果可能)
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;
};
}
- 第 4 步: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>>() {{ // 从一个有两个空List的Map开始收集过程
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), // 将已经找到的质数列表传递给isPrime 方法
candidate) )
.add(candidate); // 根据 isPrime 方法的返回值,从 Map 中取质数或非质数列表,把当前的被测数加进去
};
}
@Override
public BinaryOperator<Map<Boolean, List<Integer>>> combiner() {
return (Map<Boolean, List<Integer>> map1,
Map<Boolean, List<Integer>> map2) -> { // 将第二个Map 合并到第一个
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(); // 收集过程最后无须转换,因此用identity函数收尾
}
@Override
public Set<Characteristics> characteristics() {
// 这个收集器是 IDENTITY_FINISH,但既不是 UNORDERED 也不是 CONCURRENT,因为质数是按顺序发现的
return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH));
}
}
public Map<Boolean, List<Integer>>
partitionPrimesWithCustomCollector(int n) {
return IntStream.rangeClosed(2, n).boxed()
.collect(new PrimeNumbersCollector());
}
6.6.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");
}
}
6.7 小结
collect 是一个终端操作,它接受的参数是将流中元素累积到汇总结果的各种方式(称为收集器)。
- 预定义收集器包括将流元素归约和汇总到一个值,例如计算最小值、最大值或平均值。这些收集器总结在表 6-1 中。
- 预定义收集器可以用 groupingBy 对流中元素进行分组,或用 partitioningBy 进行分区。
- 收集器可以高效地复合起来,进行多级分组、分区和归约。
- 你可以实现 Collector 接口中定义的方法来开发自己的收集器。