Java函数式编程(十):收集器

时间:2021-08-05 10:22:20

前面我们已经用过几次collect()方法来将Stream返回的元素拼成ArrayList了。这是一个reduce操作,它对于将一个集合转化成另一种类型(通常是一个可变的集合)非常有用。collect()函数,如果和Collectors工具类里的一些方法结合起来使用的话,能提供极大的便利性,本节我们将会介绍到。

我们还是继续使用前面的Person列表作为例子,来看一下collect()方法到底有哪些能耐。假设我们要从原始列表中找出所有大于20岁的人。下面是使用了可变性和forEach()方法实现的版本:

复制代码代码如下:

List<Person> olderThan20 = new ArrayList<>(); people.stream()
        .filter(person -> person.getAge() > 20)
.forEach(person -> olderThan20.add(person)); System.out.println("People older than 20: " + olderThan20);

 

我们使用filter()方法来从列表中过滤出了所有年龄大于20的人。然后,在forEach方法里,我们将元素添加到一个在前面已经初始化好的ArrayList中。我们先看下这段代码的输出结果,一会儿再去重构它。

复制代码代码如下:

People older than 20: [Sara - 21, Jane - 21, Greg - 35]

 

程序输出的结果是对的,不过还有点小问题。首先,把元素添加到集合中,这种属于低级操作——它是命令式的,而非声明式的。如果我们想把这个迭代改造成并发的,还得去考虑线程安全的问题——可变性使得它难以并行化。幸运的是,使用collect()方法可以很容易解决掉这个问题。来看下如何实现的。

collect()方法接受一个Stream并将它们收集到一个结果容器中。要完成这个工作,它需要知道三个东西:

+如何创建结果容器(比如说,使用ArrayList::new方法) +如何把单个元素添加到容器中(比如使用ArrayList::add方法) +如何把一个结果集合并到另一个中(比如使用ArrayList::addAll方法)

对于串行操作而言,最后一条不是必需的;代码设计的目标是能同时支持串行和并行的。

我们把这些操作提供给collect方法,让它来把过滤后的流给收集起来。

 

复制代码代码如下:

List<Person> olderThan20 =
people.stream()
.filter(person -> person.getAge() > 20)
.collect(ArrayList::new, ArrayList::add, ArrayList::addAll);
System.out.println("People older than 20: " + olderThan20);

 

这段代码的结果和前面一样,不过这样写有诸多好处。

首先,我们编程的方式更聚焦了,表述性也更强,清晰的传达了你要把结果收集到一个ArrayList里去的目的。collect()的第一个参数是一工厂或者生产者,后面的参数是一个用来收集元素的操作。

第二,由于我们没有在代码中个执行显式的修改操作,可以很容易并行地执行这个迭代。我们让底层库来完成修改操作,它自己会处理好协作及线程安全的问题,尽管ArrayList本身不是线程安全的——干的漂亮。

如果条件允许的话,collect()方法可以并行地将元素添加到不同的子列表中,然后再用一个线程安全的方式将它们合并到一个大列表里(最后一个参数就是用来进行合并操作的)。

我们已经看到,相对于手动把元素添加到列表而言,使用collect()方法的好处真是太多了。下面我们来看下这个方法的一个重载的版本——它更简单也更方便——它是使用一个Collector作为参数。这个Collector是一个包含了生产者,添加器,以及合并器在内的接口——在前面的版本中这些操作是作为独立的参数分别传入方法中的——使用Collector则更简单并且可以复用。Collectors工具类提供了一个toList方法,可以生成一个Collector的实现,用来把元素添加到ArrayList中。我们来修改下前面那段代码,使用一下这个collect()方法。

复制代码代码如下:

List<Person> olderThan20 =
people.stream()
.filter(person -> person.getAge() > 20)
.collect(Collectors.toList());
System.out.println("People older than 20: " + olderThan20);

 

使用了Collectors工具类的简洁版的collect()方法,可不止这一种用法。Collectors工具类中还有好几种不同的方法来可以进行不同的收集和添加的操作。比如说,除了toList()方法,还有toSet()方法,可以添加到一个Set中,toMap()方法可以用来收集到一个key-value的集合中,还有joining()方法,可以拼接成一个字符串。我们还可以将mapping(),collectingAndThen(),minBy(), maxBy()和groupingBy()等方法组合起来进行使用。

我们来用下groupingBy()方法来将人群按年龄进行分组。

 

复制代码代码如下:

Map<Integer, List<Person>> peopleByAge =
people.stream()
.collect(Collectors.groupingBy(Person::getAge));
System.out.println("Grouped by age: " + peopleByAge);

 

只需简单的调用下collect()方法便能完成分组。groupingBy()接受一个lambda表达式或者方法引用——这种叫分类函数——它返回需要分组的对象的某个属性的值。根据我们这个函数返回的值,来把调用上下文中的元素放进某个分组中。在输出中可以看到分组的结果:

复制代码代码如下:

Grouped by age: {35=[Greg - 35], 20=[John - 20], 21=[Sara - 21, Jane - 21]}

 

这些人已经按年龄进行了分组。

在前面这个例子中我们按人群的年龄对他们进行了分组收集。groupingBy()方法的一个变种可以按多个条件进行分组。简单的groupingBy()方法使用了分类器进行元素收集。而通用的groupingBy()收集器,则可以为每一个分组指定一个收集器。也就是说,元素在收集的过程中会途经不同的分类器和集合,下面我们将会看到。

继续使用上面这个例子,这回我们不按年龄分组了,我们只获取人的名字,按他们的年龄进行排序。

 

复制代码代码如下:

Map<Integer, List<String>> nameOfPeopleByAge =
people.stream()
.collect(
groupingBy(Person::getAge, mapping(Person::getName, toList())));
System.out.println("People grouped by age: " + nameOfPeopleByAge);

 

这个版本的groupingBy()接受两个参数:第一个是年龄,这是分组的条件,第二个是一个收集器,它是由mapping()函数返回的结果。这些方法都来自Collectors工具类,在这段代码中进行了静态的导入。mapping()方法接受两个参数,一个是映射用的属性,一个是对象要收集到的地方,比如说list或者set。来看下上面这段代码的输出结果:

 

复制代码代码如下:

People grouped by age: {35=[Greg], 20=[John], 21=[Sara, Jane]}

 

可以看到,人们的名字已经按年龄进行分组了。

我们再来看一个组合的操作:按名字的首字母进行分组,然后选出每个分组中年纪最大的那位。

 

复制代码代码如下:

Comparator<Person> byAge = Comparator.comparing(Person::getAge);
Map<Character, Optional<Person>> oldestPersonOfEachLetter =
people.stream()
.collect(groupingBy(person -> person.getName().charAt(0),
reducing(BinaryOperator.maxBy(byAge))));
System.out.println("Oldest person of each letter:");
System.out.println(oldestPersonOfEachLetter);

 

我们先是按名字的首字母进行了排序。为了实现这个,我们把一个lambda表达式作为groupingBy()的第一个参数传了进去。这个lambda表达式是用来返回名字的首字母的,以便进行分组。第二个参数不再是mapping()了,而是执行了一个reduce操作。在每个分组内,它使用maxBy()方法,从所有元素中递推出最年长的那位。由于组合了许多操作,这个语法看起来有点臃肿,不过整个读起来是这样的:按名字首字母进行分组,然后递推出分组中最年长的那位。来看下这段代码的输出,它列出了指定字母开头的那组名字中年纪最大的那个人。

 

复制代码代码如下:

Oldest person of each letter:
{S=Optional[Sara - 21], G=Optional[Greg - 35], J=Optional[Jane - 21]}

 

我们已经领教到了collect()方法以及Collectors工具类的威力。在你的IDE或者JDK的官方文档里面,再花点时间去研究下Collectors工具类吧,熟悉下它提供的各种方法。下面我们将会用lambda表达式来完成一些过滤器的实现。