5.3 使用收集器

前面我们使用过collect(toList()),在流中生成列表。显然,List是能想到的从流中生成的最自然的数据结构,但是有时人们还希望从流生成其他值,比如MapSet,或者你希望定制一个类将你想要的东西抽象出来。

前面已经讲过,仅凭流上方法的签名,就能判断出这是否是一个及早求值的操作。reduce操作就是一个很好的例子,但有时人们希望能做得更多。

这就是收集器,一种通用的、从流生成复杂值的结构。只要将它传给collect方法,所有的流就都可以使用它了。

标准类库已经提供了一些有用的收集器,让我们先来看看。本章示例代码中的收集器都是从java.util.stream.Collectors类中静态导入的。

5.3.1 转换成其他集合

有一些收集器可以生成其他集合。比如前面已经见过的toList,生成了java.util.List类的实例。还有toSettoCollection,分别生成SetCollection类的实例。到目前为止,我已经讲了很多流上的链式操作,但总有一些时候,需要最终生成一个集合——比如:

  • 已有代码是为集合编写的,因此需要将流转换成集合传入;
  • 在集合上进行一系列链式操作后,最终希望生成一个值;
  • 写单元测试时,需要对某个具体的集合做断言。

通常情况下,创建集合时需要调用适当的构造函数指明集合的具体类型:

  1. List<Artist> artists = new ArrayList<>();

但是调用toList或者toSet方法时,不需要指定具体的类型。Stream类库在背后自动为你挑选出了合适的类型。本书后面会讲述如何使用Stream类库并行处理数据,收集并行操作的结果需要的Set,和对线程安全没有要求的Set类是完全不同的。

可能还会有这样的情况,你希望使用一个特定的集合收集值,而且你可以稍后指定该集合的类型。比如,你可能希望使用TreeSet,而不是由框架在背后自动为你指定一种类型的Set。此时就可以使用toCollection,它接受一个函数作为参数,来创建集合(见例5-5)。

例5-5 使用toCollection,用定制的集合收集元素

  1. stream.collect(toCollection(TreeSet::new));

5.3.2 转换成值

还可以利用收集器让流生成一个值。maxByminBy允许用户按某种特定的顺序生成一个值。例5-6展示了如何找出成员最多的乐队。它使用一个Lambda表达式,将艺术家映射为成员数量,然后定义了一个比较器,并将比较器传入maxBy收集器。

例5-6 找出成员最多的乐队

  1. public Optional<Artist> biggestGroup(Stream<Artist> artists) {
  2. Function<Artist,Long> getCount = artist -> artist.getMembers().count();
  3. return artists.collect(maxBy(comparing(getCount)));
  4. }

minBy就如它的方法名,是用来找出最小值的。

还有些收集器实现了一些常用的数值运算。让我们通过一个计算专辑曲目平均数的例子来看看,如例5-7所示。

例5-7 找出一组专辑上曲目的平均数

  1. public double averageNumberOfTracks(List<Album> albums) {
  2. return albums.stream()
  3. .collect(averagingInt(album -> album.getTrackList().size()));
  4. }

和以前一样,通过调用stream方法让集合生成流,然后调用collect方法收集结果。averagingInt方法接受一个Lambda表达式作参数,将流中的元素转换成一个整数,然后再计算平均数。还有和doublelong类型对应的重载方法,帮助程序员将元素转换成相应类型的值。

第4章介绍过一些特殊的流,如IntStream,为数值运算定义了一些额外的方法。事实上,Java 8也提供了能完成类似功能的收集器,如averagingInt。可以使用summingInt及其重载方法求和。SummaryStatistics也可以使用summingInt及其组合收集。

5.3.3 数据分块

另外一个常用的流操作是将其分解成两个集合。假设有一个艺术家组成的流,你可能希望将其分成两个部分,一部分是独唱歌手,另一部分是由多人组成的乐队。可以使用两次过滤操作,分别过滤出上述两种艺术家。

但是这样操作起来有问题。首先,为了执行两次过滤操作,需要有两个流。其次,如果过滤操作复杂,每个流上都要执行这样的操作,代码也会变得冗余。

幸好我们有这样一个收集器partitioningBy,它接受一个流,并将其分成两部分(如图5-1所示)。它使用Predicate对象判断一个元素应该属于哪个部分,并根据布尔值返回一个Map到列表。因此,对于true List中的元素,Predicate返回true;对其他List中的元素,Predicate返回false

5.3 使用收集器 - 图1

图5-1:partitioningBy收集器

使用它,我们就可以将乐队(有多个成员)和独唱歌手分开了。在本例中,分块函数指明艺术家是否为独唱歌手。实现如例5-8所示。

例5-8 将艺术家组成的流分成乐队和独唱歌手两部分

  1. public Map<Boolean, List<Artist>> bandsAndSolo(Stream<Artist> artists) {
  2. return artists.collect(partitioningBy(artist -> artist.isSolo()));
  3. }

也可以使用方法引用代替Lambda表达式,如例5-9所示。

例5-9 使用方法引用将艺术家组成的Stream分成乐队和独唱歌手两部分

  1. public Map<Boolean, List<Artist>> bandsAndSoloRef(Stream<Artist> artists) {
  2. return artists.collect(partitioningBy(Artist::isSolo));
  3. }

5.3.4 数据分组

数据分组是一种更自然的分割数据操作,与将数据分成turefalse两部分不同,可以使用任意值对数据分组。比如现在有一个由专辑组成的流,可以按专辑当中的主唱对专辑分组。代码如例5-10所示。

例5-10 使用主唱对专辑分组

  1. public Map<Artist, List<Album>> albumsByArtist(Stream<Album> albums) {
  2. return albums.collect(groupingBy(album -> album.getMainMusician()));
  3. }

和其他例子一样,调用流的collect方法,传入一个收集器。groupingBy收集器(如图5-2所示)接受一个分类函数,用来对数据分组,就像partitioningBy一样,接受一个Predicate对象将数据分成turefalse两部分。我们使用的分类器是一个Function对象,和map操作用到的一样。

5.3 使用收集器 - 图2

图5-2:groupingBy收集器

5.3 使用收集器 - 图3读者可能知道SQL中的group by操作,我们的方法是和这类似的一个概念,只不过在Stream类库中实现了而已。

5.3.5 字符串

很多时候,收集流中的数据都是为了在最后生成一个字符串。假设我们想将参与制作一张专辑的所有艺术家的名字输出为一个格式化好的列表,以专辑Let It Be为例,期望的输出为:"[George Harrison, John Lennon, Paul McCartney, Ringo Starr, The Beatles]"

在Java 8还未发布前,实现该功能的代码可能如例5-11所示。通过不断迭代列表,使用一个StringBuilder对象来记录结果。每一步都取出一个艺术家的名字,追加到StringBuilder对象。

例5-11 使用for循环格式化艺术家姓名

  1. StringBuilder builder = new StringBuilder("[");
  2. for (Artist artist : artists) {
  3. if (builder.length() > 1)
  4. builder.append(", ");
  5. String name = artist.getName();
  6. builder.append(name);
  7. }
  8. builder.append("]");
  9. String result = builder.toString();

显然,这段代码不是非常好。如果不一步步跟踪,很难看出这段代码是干什么的。使用Java 8提供的流和收集器就能写出更清晰的代码,如例5-12所示。

例5-12 使用流和收集器格式化艺术家姓名

  1. String result =
  2. artists.stream()
  3. .map(Artist::getName)
  4. .collect(Collectors.joining(", ", "[", "]"));

这里使用map操作提取出艺术家的姓名,然后使用Collectors.joining收集流中的值,该方法可以方便地从一个流得到一个字符串,允许用户提供分隔符(用以分隔元素)、前缀和后缀。

5.3.6 组合收集器

虽然读者现在看到的各种收集器已经很强大了,但如果将它们组合起来,会变得更强大。

之前我们使用主唱将专辑分组,现在来考虑如何计算一个艺术家的专辑数量。一个简单的方案是使用前面的方法对专辑先分组后计数,如例5-13所示。

例5-13 计算每个艺术家专辑数的简单方式

  1. Map<Artist, List<Album>> albumsByArtist
  2. = albums.collect(groupingBy(album -> album.getMainMusician()));
  3. Map<Artist, Integer> numberOfAlbums = new HashMap<>();
  4. for(Entry<Artist, List<Album>> entry : albumsByArtist.entrySet()) {
  5. numberOfAlbums.put(entry.getKey(), entry.getValue().size());
  6. }

这种方式看起来简单,但却有点杂乱无章。这段代码也是命令式的代码,不能自动适应并行化操作。

这里实际上需要另外一个收集器,告诉groupingBy不用为每一个艺术家生成一个专辑列表,只需要对专辑计数就可以了。幸好,核心类库已经提供了一个这样的收集器:counting。使用它,可将上述代码重写为例5-14所示的样子。

例5-14 使用收集器计算每个艺术家的专辑数

  1. public Map<Artist, Long> numberOfAlbums(Stream<Album> albums) {
  2. return albums.collect(groupingBy(album -> album.getMainMusician(),
  3. counting()));
  4. }

groupingBy先将元素分成块,每块都与分类函数getMainMusician提供的键值相关联,然后使用下游的另一个收集器收集每块中的元素,最好将结果映射为一个Map

让我们再看一个例子,这次我们不想生成一组专辑,只希望得到专辑名。这个问题仍然可以用前面的方法解决,先将专辑分组,然后再调整生成的Map中的值,如例5-15所示。

例5-15 使用简单方式求每个艺术家的专辑名

  1. public Map<Artist, List<String>> nameOfAlbumsDumb(Stream<Album> albums) {
  2. Map<Artist, List<Album>> albumsByArtist =
  3. albums.collect(groupingBy(album ->album.getMainMusician()));
  4. Map<Artist, List<String>> nameOfAlbums = new HashMap<>();
  5. for(Entry<Artist, List<Album>> entry : albumsByArtist.entrySet()) {
  6. nameOfAlbums.put(entry.getKey(), entry.getValue()
  7. .stream()
  8. .map(Album::getName)
  9. .collect(toList()));
  10. }
  11. return nameOfAlbums;
  12. }

同理,我们可以再使用一个收集器,编写出更好、更快、更容易并行处理的代码。我们已经知道,可以使用groupingBy将专辑按主唱分组,但是其输出为一个Map>对象,它将每个艺术家和他的专辑列表关联起来,但这不是我们想要的,我们想要的是一个包含专辑名的字符串列表。

此时,我们真正想做的是将专辑列表映射为专辑名列表,这里不能直接使用流的map操作,因为列表是由groupingBy生成的。我们需要有一种方法,可以告诉groupingBy将它的值做映射,生成最终结果。

每个收集器都是生成最终值的一剂良方。这里需要两剂配方,一个传给另一个。谢天谢地,Oracle公司的研究员们已经考虑到这种情况,为我们提供了mapping收集器。

mapping允许在收集器的容器上执行类似map的操作。但是需要指明使用什么样的集合类存储结果,比如toList。这些收集器就像乌龟叠罗汉,龟龟相驮以至无穷。

mapping收集器和map方法一样,接受一个Function对象作为参数,经过重构后的代码如例5-16所示。

例5-16 使用收集器求每个艺术家的专辑名

  1. public Map<Artist, List<String>> nameOfAlbums(Stream<Album> albums) {
  2. return albums.collect(groupingBy(Album::getMainMusician,
  3. mapping(Album::getName, toList())));
  4. }

这两个例子中我们都用到了第二个收集器,用以收集最终结果的一个子集。这些收集器叫作下游收集器。收集器是生成最终结果的一剂配方,下游收集器则是生成部分结果的配方,主收集器中会用到下游收集器。这种组合使用收集器的方式,使得它们在Stream类库中的作用更加强大。

那些为基本类型特殊定制的函数,如averagingIntsummarizingLong等,事实上和调用特殊Stream上的方法是等价的,加上它们是为了将它们当作下游收集器来使用的。

5.3.7 重构和定制收集器

尽管在常用流操作里,Java内置的收集器已经相当好用,但收集器框架本身是极其通用的。JDK提供的收集器没有什么特别的,完全可以定制自己的收集器,而且定制起来相当简单,这就是本节要讲的内容。

读者可能还没忘记在例5-11中,如何使用Java 7连接字符串,尽管形式并不优雅。让我们逐步重构这段代码,最终用合适的收集器实现原有代码功能。在工作中没有必要这样做,JDK已经提供了一个完美的收集器joining。这里只是为了展示如何定制收集器,以及如何使用Java 8提供的新功能来重构遗留代码。

例5-17复制了例5-11,展示了如何在Java 7中连接字符串。

例5-17 使用for循环和StringBuilder格式化艺术家姓名

  1. StringBuilder builder = new StringBuilder("[");
  2. for (Artist artist : artists) {
  3. if (builder.length() > 1)
  4. builder.append(", ");
  5. String name = artist.getName();
  6. builder.append(name);
  7. }
  8. builder.append("]");
  9. String result = builder.toString();

显然,可以使用map操作,将包含艺术家的流映射为包含艺术家姓名的流。例5-18展示了使用了流的map操作重构后的代码。

例5-18 使用forEachStringBuilder格式化艺术家姓名

  1. StringBuilder builder = new StringBuilder("[");
  2. artists.stream()
  3. .map(Artist::getName)
  4. .forEach(name -> {
  5. if (builder.length() > 1)
  6. builder.append(", ");
  7. builder.append(name);
  8. });
  9. builder.append("]");
  10. String result = builder.toString();

将艺术家映射为姓名,就能更快看出最终是要生成什么,这样代码看起来更清楚一点。可惜forEach方法看起来还是有点笨重,这与我们通过组合高级操作让代码变得易读的目标不符。

暂且不必考虑定制一个收集器,让我们想想怎么通过流上已有的操作来解决该问题。和生成字符串目标最近的操作就是reduce,使用它将例5-18中的代码重构如下。

例5-19 使用reduceStringBuilder格式化艺术家姓名

  1. StringBuilder reduced =
  2. artists.stream()
  3. .map(Artist::getName)
  4. .reduce(new StringBuilder(), (builder, name) -> {
  5. if (builder.length() > 0)
  6. builder.append(", ");
  7. builder.append(name);
  8. return builder;
  9. }, (left, right) -> left.append(right));
  10. reduced.insert(0, "[");
  11. reduced.append("]");
  12. String result = reduced.toString();

我曾经天真地以为上面的重构会让代码变得更清晰,可惜恰好相反,代码看起来比以前更糟糕。让我们先来看看怎么回事。和前面的例子一样,都调用了streammap方法,reduce操作生成艺术家姓名列表,艺术家与艺术家之间用“,”分隔。首先创建一个StringBuilder对象,该对象是reduce操作的初始状态,然后使用Lambda表达式将姓名连接到builder上。reduce操作的第三个参数也是一个Lambda表达式,接受两个StringBuilder对象做参数,将两者连接起来。最后添加前缀和后缀。

在接下来的重构中,我们还是使用reduce操作,不过需要将杂乱无章的代码隐藏掉——我的意思是使用一个StringCombiner类对细节进行抽象。代码如例5-20所示。

例5-20 使用reduceStringCombiner类格式化艺术家姓名

  1. StringCombiner combined =
  2. artists.stream()
  3. .map(Artist::getName)
  4. .reduce(new StringCombiner(", ", "[", "]"),
  5. StringCombiner::add,
  6. StringCombiner::merge);
  7. String result = combined.toString();

尽管代码看起来和上个例子大相径庭,其实背后做的工作是一样的。我们使用reduce操作将姓名和分隔符连接成一个StringBuilder对象。不过这次连接姓名操作被代理到了StringCombiner.add方法,而连接两个连接器操作被StringCombiner.merge方法代理。让我们现在来看看这些方法,先从例5-21中的add方法开始。

例5-21 add方法返回连接新元素后的结果

  1. public StringCombiner add(String element) {
  2. if (areAtStart()) {
  3. builder.append(prefix);
  4. } else {
  5. builder.append(delim);
  6. }
  7. builder.append(element);
  8. return this;
  9. }

add方法在内部其实将操作代理给一个StringBuilder对象。如果刚开始进行连接,则在最前面添加前缀,否则添加分隔符,然后再添加新的元素。这里返回一个StringCombiner对象,因为这是传给reduce操作所需要的类型。合并代码也是同样的道理,内部将操作代理给StringBuilder对象,如例5-22所示。

例5-22 merge方法连接两个StringCombiner对象

  1. public StringCombiner merge(StringCombiner other) {
  2. builder.append(other.builder);
  3. return this;
  4. }

reduce阶段的重构还差一小步就差不多结束了。我们要在最后调用toString方法,将整个步骤串成一个方法链。这很简单,只需要排列好reduce代码,准备好将其转换为Collector API就行了(如例5-23所示)。

例5-23 使用reduce操作,将工作代理给StringCombiner对象

  1. String result =
  2. artists.stream()
  3. .map(Artist::getName)
  4. .reduce(new StringCombiner(", ", "[", "]"),
  5. StringCombiner::add,
  6. StringCombiner::merge)
  7. .toString();

现在的代码看起来已经差不多完美了,但是在程序中还是不能重用。因此,我们想将reduce操作重构为一个收集器,在程序中的任何地方都能使用。不妨将这个收集器叫作StringCollector,让我们重构代码使用这个新的收集器,如例5-24所示。

例5-24 使用定制的收集器StringCollector收集字符串

  1. String result =
  2. artists.stream()
  3. .map(Artist::getName)
  4. .collect(new StringCollector(", ", "[", "]"));

既然已经将所有对字符串的连接操作代理给了定制的收集器,应用程序就不需要关心StringCollector对象的任何内部细节,它和框架中其他Collector对象用起来是一样的。

先来实现Collector接口(例5-25),由于Collector接口支持泛型,因此先得确定一些具体的类型:

  • 待收集元素的类型,这里是String
  • 累加器的类型StringCombiner
  • 最终结果的类型,这里依然是String

例5-25 定义字符串收集器

  1. public class StringCollector implements Collector<String, StringCombiner, String> {

一个收集器由四部分组成。首先是一个Supplier,这是一个工厂方法,用来创建容器,在这个例子中,就是StringCombiner。和reduce操作中的第一个参数类似,它是后续操作的初值(如例5-26所示)。

例5-26 Supplier是创建容器的工厂

  1. public Supplier<StringCombiner> supplier() {
  2. return () -> new StringCombiner(delim, prefix, suffix);
  3. }

让我们一边阅读代码,一边看图,这样就能看清到底是怎么工作的。由于收集器可以并行收集,我们要展示的收集操作在两个容器上(比如StringCombiners)并行进行。

收集器的每一个组件都是函数,因此我们使用箭头表示,流中的值用圆圈表示,最终生成的值用椭圆表示。收集操作一开始,Supplier先创建出新的容器(如图5-3)。

5.3 使用收集器 - 图4

图5-3:Supplier

收集器的accumulator的作用和reduce操作的第二个参数一样,它结合之前操作的结果和当前值,生成并返回新的值。这一逻辑已经在StringCombinersadd方法中得以实现,直接引用就好了(如例5-27所示)。

例5-27 accumulator是一个函数,它将当前元素叠加到收集器

  1. public BiConsumer<StringCombiner, String> accumulator() {
  2. return StringCombiner::add;
  3. }

这里的accumulator用来将流中的值叠加入容器中(如图5-4所示)。

5.3 使用收集器 - 图5

图5-4:Accumulator

combine方法很像reduce操作的第三个方法。如果有两个容器,我们需要将其合并。同样,在前面的重构中我们已经实现了该功能,直接使用StringCombiner.merge方法就行了(例5-28)。

例5-28 combiner合并两个容器

  1. public BinaryOperator<StringCombiner> combiner() {
  2. return StringCombiner::merge;
  3. }

在收集阶段,容器被combiner方法成对合并进一个容器,直到最后只剩一个容器为止(如图5-5所示)。

5.3 使用收集器 - 图6

图5-5:Combiner

读者可能还记得,在使用收集器之前,重构的最后一步将toString方法内联到方法链的末端,这就将StringCombiners转换成了我们想要的字符串(如图5-6所示)。

5.3 使用收集器 - 图7

图5-6:Finisher

收集器的finisher方法作用相同。我们已经将流中的值叠加入一个可变容器中,但这还不是我们想要的最终结果。这里调用了finisher方法,以便进行转换。在我们想创建字符串等不可变的值时特别有用,这里容器是可变的。

为了实现finisher方法,只需将该操作代理给已经实现的toString方法即可(例5-29)。

例5-29 finisher方法返回收集操作的最终结果

  1. public Function<StringCombiner, String> finisher() {
  2. return StringCombiner::toString;
  3. }

从最后剩下的容器中得到最终结果。

关于收集器,还有一点一直没有提及,那就是特征。特征是一组描述收集器的对象,框架可以对其适当优化。characteristics方法定义了特征。

在这里我有必要重申,这些代码只作教学用途,和joining收集器的内部实现略有出入。读者也许会认为StringCombiner看起来非常有用,别担心——你没必要亲自去编写,Java 8有一个java.util.StringJoiner类,它的作用和StringCombiner一样,有类似的API。

做这些练习的主要目的不仅在于展示定制收集器的工作原理,而且还在于帮助读者编写自己的收集器。特别是你有自己特定领域内的类,希望从集合中构建一个操作,而标准的集合类并没有提供这种操作时,就需要定制自己的收集器。

StringCombiner为例,收集值的容器和我们想要创建的值(字符串)不一样。如果想要收集的是不可变对象,而不是可变对象,那么这种情况就非常普遍,否则收集操作的每一步都需要创建一个新值。

想要收集的最终结果和容器一样是完全有可能的。事实上,如果收集的最终结果是集合,比如toList收集器,就属于这种情况。

此时,finisher方法不需要对容器做任何操作。更正式地说,此时的finisher方法其实是identity函数:它返回传入参数的值。如果这样,收集器就展现出IDENTITY_FINISH的特征,需要使用characteristics方法声明。

5.3.8 对收集器的归一化处理

就像之前看到的那样,定制收集器其实不难,但如果你想为自己领域内的类定制一个收集器,不妨考虑一下其他替代方案。最容易想到的方案是构建若干个集合对象,作为参数传给领域内类的构造函数。如果领域内的类包含多种集合,这种方式又简单又适用。

当然,如果领域内的类没有这些集合,需要在已有数据上计算,那这种方法就不合适了。但即使如此,也不见得需要定制一个收集器。你还可以使用reducing收集器,它为流上的归一操作提供了统一实现。例5-30展示了如何使用reducing收集器编写字符串处理程序。

例5-30 reducing是一种定制收集器的简便方式

  1. String result =
  2. artists.stream()
  3. .map(Artist::getName)
  4. .collect(Collectors.reducing(
  5. new StringCombiner(", ", "[", "]"),
  6. name -> new StringCombiner(", ", "[", "]").add(name),
  7. StringCombiner::merge))
  8. .toString();

这和我在例5-20中讲到的基于reduce操作的实现很像,这点从方法名中就能看出。区别在于Collectors.reducing的第二个参数,我们为流中每个元素创建了唯一的StringCombiner。如果你被这种写法吓到了,或是感到恶心,你不是一个人!这种方式非常低效,这也是我要定制收集器的原因之一。