1. 概述

在Java编程中,处理集合和流(Stream)是常见任务,尤其在现代函数式编程范式中。Java 8引入的Stream API为处理数据集合提供了强大工具。

Stream API中两个核心的*CollectorCollectors.toMap()Collectors.groupingBy(),它们在将Stream元素转换为Map*时各有独特用途。

本文将深入探讨这两个Collector的区别,并分析各自适用的场景。

2. City示例类

示例能帮我们更好地理解问题。先创建一个简单的不可变POJO类:

class City {
 
    private final String name;
    private final String country;
 
    public City(String name, String country) {
        this.name = name;
        this.country = country;
    }

   // ... getters, equals(), and hashCode() methods are omitted
}

如代码所示,City类只有两个属性:城市name和所在country

由于后续示例中会使用Collectors.toMap()Collectors.groupingBy()作为终端操作,先创建一些City对象来构建Stream

static final City PARIS = new City("Paris", "France");
static final City BERLIN = new City("Berlin", "Germany");
static final City TOKYO = new City("Tokyo", "Japan");

现在可以轻松从这些City实例创建Stream

Stream.of(PARIS, BERLIN, TOKYO);

接下来,我们将使用这两个CollectorCity实例的Stream转换为Map,并讨论它们的区别。为简洁起见,后文用“toMap()”和“groupingBy()”指代这两个Collector,并通过单元测试断言验证转换结果。

3. 以City.country作为键

首先探索两个Collector的基本用法。我们将它们用于转换Stream在转换后的Map结果中,以每个City.country作为键

注意键可以是null,因此创建一个countrynullCity

static final City COUNTRY_NULL = new City("Unknown", null);

3.1. 使用toMap() Collector

toMap()允许我们定义如何从输入Stream的元素映射键和值。*可以向toMap()方法传递keyMappervalueMapper参数**,这两个参数都是函数,分别提供结果Map*中的键和值。

若希望City实例本身作为值,得到Map<String, City>,**可以使用Function.identity()作为valueMapper**

Map<String, City> result = Stream.of(PARIS, BERLIN, TOKYO)
  .collect(Collectors.toMap(City::getCountry, Function.identity()));
 
Map<String, City> expected = Map.of(
  "France", PARIS,
  "Germany", BERLIN,
  "Japan", TOKYO
);
 
assertEquals(expected, result);

此外,*toMap()keyMapper返回*null时也能正常工作***:

Map<String, City> result = Stream.of(PARIS, COUNTRY_NULL)
  .collect(Collectors.toMap(City::getCountry, Function.identity()));
 
Map<String, City> expected = new HashMap<>() {{
    put("France", PARIS);
    put(null, COUNTRY_NULL);
}};
 
assertEquals(expected, result);

3.2. 使用groupingBy() Collector

*groupingBy() Collector擅长根据指定的分类器函数将*Stream元素分组。因此,结果Map中的值类型是Collection*(默认为List*)。

接下来按City.country分组:

Map<String, List<City>> result = Stream.of(PARIS, BERLIN, TOKYO)
  .collect(Collectors.groupingBy(City::getCountry));
 
Map<String, List<City>> expected = Map.of(
  "France", List.of(PARIS),
  "Germany", List.of(BERLIN),
  "Japan", List.of(TOKYO)
);
 
assertEquals(expected, result);

toMap()不同,groupingBy()** 无法处理null作为分类器

assertThrows(NullPointerException.class, () -> Stream.of(PARIS, COUNTRY_NULL)
  .collect(Collectors.groupingBy(City::getCountry)));

如示例所示,当分类器函数返回null时,会抛出NullPointerException

我们已通过示例探索了两个Collector的基本用法。但当前Stream中的City实例没有重复的国家。实际项目中,处理键冲突是常见需求。接下来看看两个Collector如何处理重复键。

4. 处理重复键的情况

创建三个额外的城市:

static final City NICE = new City("Nice", "France");
static final City AACHEN = new City("Aachen", "Germany");
static final City HAMBURG = new City("Hamburg", "Germany");

现在有了重复国家的城市(如BERLINHAMBURGAACHENcountry都是"Germany")。接下来探索两个Collector如何处理重复键。

4.1. 使用toMap() Collector

若沿用之前的方式,仅向toMap() Collector传递keyMappervalueMapper,遇到重复键时会抛出IllegalStateException

assertThrows(IllegalStateException.class, () -> Stream.of(PARIS, BERLIN, TOKYO, NICE, HAMBURG, AACHEN)
    .collect(Collectors.toMap(City::getCountry, Function.identity())));

当可能存在重复键时,*必须向toMap()提供第三个参数mergeFunction*来解决冲突**。

接下来提供一个lambda表达式作为mergeFunction当国家相同时,通过按字典序比较城市名选择“较小”的City

Map<String, City> result = Stream.of(PARIS, BERLIN, TOKYO, NICE, HAMBURG, AACHEN)
  .collect(Collectors.toMap(City::getCountry, Function.identity(), (c1, c2) -> c1.getName()
     .compareTo(c2.getName()) < 0 ? c1 : c2));
 
Map<String, City> expected = Map.of(
  "France", NICE, // <-- 从Paris和Nice中选出
  "Germany", AACHEN, // <-- 从Berlin、Hamburg和Aachen中选出
  "Japan", TOKYO
);
 
assertEquals(expected, result);

如上所示,mergeFunction根据规则返回一个City实例,因此调用*collect()后仍得到Map<String, City>*。

4.2. 使用groupingBy() Collector

另一方面,由于groupingBy()使用分类器将Stream元素分组到Collection中,即使输入Stream中的城市有相同country值,之前的代码依然有效

Map<String, List<City>> result = Stream.of(PARIS, BERLIN, TOKYO, NICE, HAMBURG, AACHEN)
  .collect(Collectors.groupingBy(City::getCountry));
 
Map<String, List<City>> expected = Map.of(
  "France", List.of(PARIS, NICE),
  "Germany", List.of(BERLIN, HAMBURG, AACHEN),
  "Japan", List.of(TOKYO)
);
 
assertEquals(expected, result);

可见,相同country的城市被归入同一个List,结果中的"France"和"Germany"条目就是证明。

5. 使用值映射器

目前我们使用两个Collector得到了country -> Citycountry -> City集合的映射。但有时需要将Stream元素映射为不同的值,例如得到country* -> City.namecountry -> City.name集合的映射

注意映射后的值可以是null。因此需要处理值为null的情况。创建一个namenullCity

static final City FRANCE_NULL = new City(null, "France");

接下来探索如何对两个Collector应用值映射器。

5.1. 使用toMap() Collector

如前所述,*可以向toMap()方法传递valueMapper函数作为第二个参数,将输入Stream*中的对象映射为不同值**:

Map<String, String> result = Stream.of(PARIS, BERLIN, TOKYO)
  .collect(Collectors.toMap(City::getCountry, City::getName));
 
Map<String, String> expected = Map.of(
  "France", "Paris",
  "Germany", "Berlin",
  "Japan", "Tokyo"
);
 
assertEquals(expected, result);

本例中,使用方法引用City::getName作为valueMapper参数,将City映射为其name

但当映射值包含null时,*toMap()*会出问题:

assertThrows(NullPointerException.class, () -> Stream.of(PARIS, FRANCE_NULL)
  .collect(Collectors.toMap(City::getCountry, City::getName)));

可见,若映射值包含nulltoMap()会抛出NullPointerException

5.2. 使用groupingBy() Collector

toMap()不同,groupingBy()不直接支持valueMapper函数作为参数。但可以groupingBy()提供另一个Collector作为第二个参数,执行下游归约操作**。例如,使用mapping() Collector将分组的City实例映射为它们的name

Map<String, List<String>> result = Stream.of(PARIS, BERLIN, TOKYO)
  .collect(Collectors.groupingBy(City::getCountry, mapping(City::getName, toList())));
 
Map<String, List<String>> expected = Map.of(
  "France", List.of("Paris"),
  "Germany", List.of("Berlin"),
  "Japan", List.of("Tokyo")
);
  
assertEquals(expected, result);

此外,groupingBy()mapping()的组合能无缝处理null

Map<String, List<String>> resultWithNull = Stream.of(PARIS, BERLIN, TOKYO, FRANCE_NULL)
  .collect(Collectors.groupingBy(City::getCountry, mapping(City::getName, toList())));
 
Map<String, List<String>> expectedWithNull = Map.of(
  "France", newArrayList("Paris", null),
  "Germany", newArrayList("Berlin"),
  "Japan", List.of("Tokyo")
);
 
assertEquals(expectedWithNull, resultWithNull);

6. 总结

如本文所示,Collectors.toMap()Collectors.groupingBy()都是强大的Collector,各有独特用途:

核心区别

  • toMap():适合直接将Stream转换为Map
  • groupingBy():擅长根据特定条件将Stream元素分组

⚠️ 关键注意事项

  • toMap()keyMapper返回null时仍能工作,但*groupingBy()在分类器返回null时会抛出*NullPointerException**
  • toMap()支持valueMapper参数,方便映射值类型,但valueMapper返回null会抛出NullPointerException
  • groupingBy()依赖其他Collector映射元素类型,能有效处理null

理解它们的差异和适用场景后,就能在Java应用中有效使用这些Collector处理数据流。

完整示例代码见GitHub仓库


原始标题:Collecting into Map using Collectors.toMap() vs Collectors.groupingBy()