1. 概述
在Java编程中,处理集合和流(Stream)是常见任务,尤其在现代函数式编程范式中。Java 8引入的Stream API为处理数据集合提供了强大工具。
Stream API中两个核心的*Collector是Collectors.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);
接下来,我们将使用这两个Collector将City实例的Stream转换为Map,并讨论它们的区别。为简洁起见,后文用“toMap()”和“groupingBy()”指代这两个Collector,并通过单元测试断言验证转换结果。
3. 以City.country作为键
首先探索两个Collector的基本用法。我们将它们用于转换Stream,在转换后的Map结果中,以每个City.country作为键。
注意键可以是null,因此创建一个country为null的City:
static final City COUNTRY_NULL = new City("Unknown", null);
3.1. 使用toMap() Collector
toMap()允许我们定义如何从输入Stream的元素映射键和值。*可以向toMap()方法传递keyMapper和valueMapper参数**,这两个参数都是函数,分别提供结果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");
现在有了重复国家的城市(如BERLIN、HAMBURG和AACHEN的country都是"Germany")。接下来探索两个Collector如何处理重复键。
4.1. 使用toMap() Collector
若沿用之前的方式,仅向toMap() Collector传递keyMapper和valueMapper,遇到重复键时会抛出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 -> City或country -> City集合的映射。但有时需要将Stream元素映射为不同的值,例如得到country* -> City.name或country -> City.name集合的映射。
注意映射后的值可以是null。因此需要处理值为null的情况。创建一个name为null的City:
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)));
可见,若映射值包含null,toMap()会抛出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仓库。