6.3 分组

一个常见的数据库操作是根据一个或多个属性对集合中的项目进行分组。就像前面讲到按货币对交易进行分组的例子一样,如果用指令式风格来实现的话,这个操作可能会很麻烦、啰唆而且容易出错。但是,如果用Java 8所推崇的函数式风格来重写的话,就很容易转化为一个非常容易看懂的语句。来看看这个功能的第二个例子:假设你要把菜单中的菜按照类型进行分类,将有肉的放一组,有鱼的放一组,其他的都放另一组。用Collectors.groupingBy工厂方法返回的收集器就可以轻松地完成这项任务,如下所示:

  1. Map<Dish.Type, List<Dish>> dishesByType =
  2. menu.stream().collect(groupingBy(Dish::getType));

其结果是下面的Map

  1. {FISH=[prawns, salmon], OTHER=[french fries, rice, season fruit, pizza],
  2. MEAT=[pork, beef, chicken]}

这里,你给groupingBy方法传递了一个Function(以方法引用的形式),它提取了流中每一道DishDish.Type。我们把这个Function叫作分类函数,因为它用来把流中的元素分成不同的组。如图6-4所示,分组操作的结果是一个Map,把分组函数返回的值作为映射的键,把流中所有具有这个分类值的项目的列表作为对应的映射值。在菜单分类的例子中,键就是菜的类型,值就是包含所有对应类型的菜肴的列表。

6.3 分组 - 图1

图 6-4 在分组过程中对流中的项目进行分类

但是,分类函数不一定像方法引用那样可用,因为你想用以分类的条件可能比简单的属性访问器要复杂。例如,你可能想把热量不到400卡路里的菜划为“低热量”(diet),把热量在400到700卡路里之间的菜划为“普通”(normal),而把高于700卡路里的菜划为“高热量”(fat)。由于Dish类的作者没有把这个操作写成一个方法,因此无法使用方法引用,但你可以把这个逻辑写成Lambda表达式:

  1. public enum CaloricLevel { DIET, NORMAL, FAT }
  2. Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream().collect(
  3. groupingBy(dish -> {
  4. if (dish.getCalories() <= 400) return CaloricLevel.DIET;
  5. else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
  6. else return CaloricLevel.FAT;
  7. } ));

现在,你已经知道如何同时按照菜肴的类型和热量对菜单中的菜肴进行分组。然而,如果你还需要对最初分组的结果做进一步操作——这也是很典型的应用场景,又该如何做呢?接下来的一节会介绍如何解决这个问题。

6.3.1 操作分组的元素

执行完分组操作后,你往往还需要对每个分组中的元素执行操作。举个例子,假设你希望只按照菜肴的热量进行过滤操作,譬如找出那些热量大于500卡路里的菜肴。你可能会说,这种情况只要在分组之前执行过滤谓词就好了,如下所示:

  1. Map<Dish.Type, List<Dish>> caloricDishesByType =
  2. menu.stream().filter(dish -> dish.getCalories() > 500)
  3. .collect(groupingBy(Dish::getType));

这种解决方案可以工作,不过它也伴随着相关的缺陷。如果你试着用它处理我们的菜单,得到的结果是下面这种Map

  1. {OTHER=[french fries, pizza], MEAT=[pork, beef]}

发现问题了么?由于没有任何一道类型是FISH的菜符合我们的过滤谓词,这个键在结果映射中完全消失了。为了解决这个问题,Collectors类重载了工厂方法groupingBy,除了常见的分类函数,它的第二变量也接受一个Collector类型的参数。通过这种方式,我们把过滤谓词挪到了第二个Collector中,如下所示:

  1. Map<Dish.Type, List<Dish>> caloricDishesByType =
  2. menu.stream()
  3. .collect(groupingBy(Dish::getType,
  4. filtering(dish -> dish.getCalories() > 500, toList())));

filtering方法也是Collectors类的一个静态工厂方法,它接受一个谓词对每一个分组中的元素执行过滤操作,你还可以更进一步地使用Collector对过滤的元素继续进行分组。通过这种方式,结果映射中依旧保存了FISH类型的条目,即便它映射的是一个空的列表:

  1. {OTHER=[french fries, pizza], MEAT=[pork, beef], FISH=[]}

操作分组元素的另一种常见做法是使用一个映射函数对它们进行转换,这种方式也很有效。为了达成这个目标,Collectors类通过mapping方法提供了另一个Collector函数,它接受一个映射函数和另一个Collector函数作为参数。作为参数的Collector会收集对每个元素执行该映射函数的运行结果。这与你之前看到的过滤收集器很相似。使用新的方法,你可以将每道菜肴的分类添加到它们各自的菜名中,如下所示:

  1. Map<Dish.Type, List<String>> dishNamesByType =
  2. menu.stream()
  3. .collect(groupingBy(Dish::getType,
  4. mapping(Dish::getName, toList())));

注意,这个例子中,结果映射的每个分组是一个由字符串构成的列表,而不是前面示例中的Dish类型。你还可以使用第三个Collector搭配groupingBy,再进行一次flatMap转换,这样得到的就不是一个普通的映射了。为了演示这种机制是如何工作的,假设我们有一个映射,它为每道菜肴关联了一个标签列表,如下所示:

  1. Map<String, List<String>> dishTags = new HashMap<>();
  2. dishTags.put("pork", asList("greasy", "salty"));
  3. dishTags.put("beef", asList("salty", "roasted"));
  4. dishTags.put("chicken", asList("fried", "crisp"));
  5. dishTags.put("french fries", asList("greasy", "fried"));
  6. dishTags.put("rice", asList("light", "natural"));
  7. dishTags.put("season fruit", asList("fresh", "natural"));
  8. dishTags.put("pizza", asList("tasty", "salty"));
  9. dishTags.put("prawns", asList("tasty", "roasted"));
  10. dishTags.put("salmon", asList("delicious", "fresh"));

如果你需要提取出每组菜肴对应的标签,使用flatMapping Collector可以轻松实现:

  1. Map<Dish.Type, Set<String>> dishNamesByType =
  2. menu.stream()
  3. .collect(groupingBy(Dish::getType,
  4. flatMapping(dish -> dishTags.get( dish.getName() ).stream(),
  5. toSet())));

我们会为每道菜肴获取一个标签列表。这与在上一章碰到的情况很像,需要执行一个flatMap操作,将两层的结果列表归并为一层。此外,也请注意,这一次我们会将每一组flatMapping操作的结果保存到一个Set中,而不是之前的List中,这么做是为了避免同一类型的多道菜由于关联了同样的标签而导致标签重复出现在结果集中。这一操作的结果映射如下所示:

  1. {MEAT=[salty, greasy, roasted, fried, crisp], FISH=[roasted, tasty, fresh,
  2. delicious], OTHER=[salty, greasy, natural, light, tasty, fresh, fried]}

截至目前,我们对菜单中的菜肴分组时使用的都是单一标准,譬如,按类型分,或者按热量分。然而,有些时候你可能希望同时使用多个标准进行分类,这种情况又该如何处理呢?分组操作的强大之处就在于它能高效地组合。来看看它是如何做到的这一点的。

6.3.2 多级分组

要实现多级分组,可以使用一个由双参数版本的Collectors.groupingBy工厂方法创建的收集器,它除了普通的分类函数之外,还可以接受collector类型的第二个参数。那么要进行二级分组的话,可以把一个内层groupingBy传递给外层groupingBy,并定义一个为流中项目分类的二级标准,如代码清单6-2所示。

代码清单 6-2 多级分组

  1. Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel = menu.stream().collect(
  2. groupingBy(Dish::getType, ←---- 一级分类函数
  3. groupingBy(dish -> { ←---- 二级分类函数
  4. if (dish.getCalories() <= 400) return CaloricLevel.DIET;
  5. else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
  6. else return CaloricLevel.FAT;
  7. } )
  8. )
  9. );

这个二级分组的结果就是像下面这样的两级Map

  1. {MEAT={DIET=[chicken], NORMAL=[beef], FAT=[pork]},
  2. FISH={DIET=[prawns], NORMAL=[salmon]},
  3. OTHER={DIET=[rice, seasonal fruit], NORMAL=[french fries, pizza]}}

这里的外层Map的键就是第一级分类函数生成的值:“fish, meat, other”,而这个Map的值又是一个Map,键是二级分类函数生成的值:“normal, diet, fat”。最后,第二级Map的值是流中元素构成的List,是分别应用第一级和第二级分类函数所得到的对应第一级和第二级键的值:“salmon,pizza…” 这种多级分组操作可以扩展至任意层级,n级分组就会得到一个代表n级树形结构的nMap

图6-5显示了为什么结构相当于n维表格,并强调了分组操作的分类目的。

6.3 分组 - 图6

图 6-5 n层嵌套映射和n维分类表之间的等价关系

一般来说,把groupingBy看作“桶”比较容易明白。第一个groupingBy给每个键建立了一个桶。然后再用下游的收集器去收集每个桶中的元素,以此得到n级分组。

6.3.3 按子组收集数据

在上一节中,我们看到可以把第二个groupingBy收集器传递给外层收集器来实现多级分组。但进一步说,传递给第一个groupingBy的第二个收集器可以是任何类型,而不一定是另一个groupingBy。例如,要数一数菜单中每类菜有多少个,可以传递counting收集器作为groupingBy收集器的第二个参数:

  1. Map<Dish.Type, Long> typesCount = menu.stream().collect(
  2. groupingBy(Dish::getType, counting()));

其结果是下面的Map

  1. {MEAT=3, FISH=2, OTHER=4}

还要注意,普通的单参数groupingBy(f)(其中f是分类函数)实际上是groupingBy(f, toList())的简便写法。

再举一个例子,你可以把前面用于查找菜单中热量最高的菜肴的收集器改一改,按照菜的类型分类:

  1. Map<Dish.Type, Optional<Dish>> mostCaloricByType =
  2. menu.stream()
  3. .collect(groupingBy(Dish::getType,
  4. maxBy(comparingInt(Dish::getCalories))));

这个分组的结果显然是一个Map,以Dish的类型作为键,以包装了该类型中热量最高的DishOptional作为值:

  1. {FISH=Optional[salmon], OTHER=Optional[pizza], MEAT=Optional[pork]}

注意 这个Map中的值是Optional,因为这是maxBy工厂方法生成的收集器的类型,但实际上,如果菜单中没有某一类型的Dish,这个类型就不会对应一个Optional.empty()值,而且根本不会出现在Map的键中。groupingBy收集器只有在应用分组条件后,第一次在流中找到某个键对应的元素时才会把键加入分组Map中。这意味着Optional包装器在这里不是很有用,因为它不会仅仅因为是归约收集器的返回类型而表达一个最终可能不存在却意外存在的值。

  • 把收集器的结果转换为另一种类型

因为分组操作的Map结果中的每个值上包装的Optional没什么用,所以你可能想要把它们去掉。要做到这一点,或者更一般地来说,把收集器返回的结果转换为另一种类型,你可以使用Collectors.collectingAndThen工厂方法返回的收集器,如下所示。

代码清单 6-3 查找每个子组中热量最高的Dish

  1. Map<Dish.Type, Dish> mostCaloricByType =
  2. menu.stream()
  3. .collect(groupingBy(Dish::getType, ←---- 分类函数
  4. collectingAndThen(
  5. maxBy(comparingInt(Dish::getCalories)), ←---- 包装后的收集器
  6. Optional::get))); ←---- 转换函数

这个工厂方法接受两个参数——要转换的收集器以及转换函数,并返回另一个收集器。这个收集器相当于旧收集器的一个包装,collect操作的最后一步就是将返回值用转换函数做一个映射。在这里,被包起来的收集器就是用maxBy建立的那个,而转换函数Optional::get则把返回的Optional中的值提取出来。前面已经说过,这个操作放在这里是安全的,因为reducing收集器永远都不会返回Optional.empty()。其结果是下面的Map

  1. {FISH=salmon, OTHER=pizza, MEAT=pork}

把好几个收集器嵌套起来很常见,它们之间到底发生了什么可能不那么明显。图6-6可以直观地展示它们是怎么工作的。从最外层开始逐层向里,注意以下几点。

  • 收集器用虚线表示,因此groupingBy是最外层,根据菜肴的类型把菜单流分组,得到三个子流。
  • groupingBy收集器包裹着collectingAndThen收集器,因此分组操作得到的每个子流都用这第二个收集器做进一步归约。
  • collectingAndThen收集器又包裹着第三个收集器maxBy
  • 随后由归约收集器进行子流的归约操作,然后包含它的collectingAndThen收集器会对其结果应用Optional:get转换函数。
  • 对三个子流分别执行这一过程并转换而得到的三个值,也就是各个类型中热量最高的Dish,将成为groupingBy收集器返回的Map中与各个分类键(Dish的类型)相关联的值。
    6.3 分组 - 图10

图 6-6 嵌套收集器来获得多重效果

  • groupingBy联合使用的其他收集器的例子

一般来说,通过groupingBy工厂方法的第二个参数传递的收集器将会对分到同一组中的所有流元素执行进一步归约操作。例如,你还重用求出所有菜肴热量总和的收集器,不过这次是对每一组Dish求和:

  1. Map<Dish.Type, Integer> totalCaloriesByType =
  2. menu.stream().collect(groupingBy(Dish::getType,
  3. summingInt(Dish::getCalories)));

然而常常和groupingBy联合使用的另一个收集器是mapping方法生成的。这个方法接受两个参数:一个函数对流中的元素做变换,另一个则将变换的结果对象收集起来。其目的是在累加之前对每个输入元素应用一个映射函数,这样就可以让接受特定类型元素的收集器适应不同类型的对象。我们来看一个使用这个收集器的实际例子。比方说你想要知道,对于每种类型的Dish,菜单中都有哪些CaloricLevel。可以把groupingBymapping收集器结合起来,如下所示:

  1. Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType =
  2. menu.stream().collect(
  3. groupingBy(Dish::getType, mapping(dish -> {
  4. if (dish.getCalories() <= 400) return CaloricLevel.DIET;
  5. else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
  6. else return CaloricLevel.FAT; },
  7. toSet() )));

这里,就像前面见到过的,传递给映射方法的转换函数将Dish映射成了它的CaloricLevel:生成的CaloricLevel流传递给一个toSet收集器,它和toList类似,不过是把流中的元素累积到一个Set而不是List中,以便仅保留各不相同的值。如先前的示例所示,这个映射收集器将会收集分组函数生成的各个子流中的元素,让你得到这样的Map结果:

  1. {OTHER=[DIET, NORMAL], MEAT=[DIET, NORMAL, FAT], FISH=[DIET, NORMAL]}

由此你就可以轻松地做出选择了。如果你想吃鱼并且在减肥,那很容易找到一道菜;同样,如果你饥肠辘辘,想要很多热量的话,菜单中肉类部分就可以满足你的饕餮之欲了。请注意在上一个示例中,对于返回的Set是什么类型并没有任何保证。但通过使用toCollection,你就可以有更多的控制。例如,你可以给它传递一个构造函数引用来要求HashSet

  1. Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType =
  2. menu.stream().collect(
  3. groupingBy(Dish::getType, mapping(dish -> {
  4. if (dish.getCalories() <= 400) return CaloricLevel.DIET;
  5. else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
  6. else return CaloricLevel.FAT; },
  7. toCollection(HashSet::new) )));