Java中将多个Map扁平化为单个Map

自 Java 8 推出以来,处理数据流已成为 Java 开发中的一项常见任务。通常,这些流包含复杂的结构(例如映射),这在进一步处理它们时可能会带来挑战。

在本教程中,我们将探讨如何将地Map映射流展平为单个Map映射。

在深入研究解决方案之前,让我们先澄清一下“展平Map流”的含义。本质上,我们希望将映射流转换为单个Map映射,其中包含流中每个Map映射的所有键值对。

像往常一样,一个例子可以帮助我们快速理解问题。假设我们有三个存储玩家姓名和分数之间关联的Map映射:

Map<String, Integer> playerMap1 = new HashMap<String, Integer>() {{
    put("Kai", 92);
    put("Liam", 100);
}};
Map<String, Integer> playerMap2 = new HashMap<String, Integer>() {{
    put("Eric", 42);
    put("Kevin", 77);
}};
Map<String, Integer> playerMap3 = new HashMap<String, Integer>() {{
    put("Saajan", 35);
}};
我们的输入是包含这些映射的流。为简单起见,我们将在本教程中使用Stream.of(playerMap1, playerMap2 , …)构建输入流。然而,值得注意的是,流不一定具有定义的遇到顺序。

现在,我们的目标是将包含上述三个Map映射的流合并为一个名称-分数Map映射:

Map<String, Integer> expectedMap = new HashMap<String, Integer>() {{
    put("Saajan", 35);
    put("Liam", 100);
    put("Kai", 92);
    put("Eric", 42);
    put("Kevin", 77);
}};
值得一提的是,由于我们使用的是HashMap对象,因此无法保证最终结果中的条目顺序。

此外,流中的Map映射可能包含重复的键和空值。稍后,我们将扩展示例以涵盖本教程中的这些场景。

接下来,让我们深入研究代码。

使用flatMap()和Collectors.toMap()
合并Map的一种方法是使用flatMap()方法和toMap ()收集器:

Map<String, Integer> mergedMap = Stream.of(playerMap1, playerMap2, playerMap3)
  .flatMap(map -> map.entrySet()
    .stream())
  .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
assertEquals(expectedMap, mergedMap);

  • 在上面的代码中,flatMap ()方法将流中的每个映射展平为其条目流。
  • 然后,我们使用toMap()收集器将流的元素收集到单个映射中。

toMap ()收集器需要两个函数作为参数:
  1. 一个用于提取键 ( Map.Entry::getKey ),
  2. 另一个用于提取值 ( Map.Entry::getValue )。

这里,我们使用方法引用来表示这两个函数。这些函数应用于流中的每个条目以构造结果映射。

处理重复键
我们学习了如何使用toMap()收集器将HashMap流合并到一个映射中。然而,如果Map映射流包含重复的键,这种方法将会失败。例如,如果我们将具有重复键“Kai”的新映射添加到流中,则会抛出IllegalStateException:

Map<String, Integer> playerMap4 = new HashMap<String, Integer>() {{
    put("Kai", 76);
}};
assertThrows(IllegalStateException.class, () -> Stream.of(playerMap1, playerMap2, playerMap3, playerMap4)
  .flatMap(map -> map.entrySet()
    .stream())
  .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)),
"Duplicate key Kai (attempted merging values 92 and 76)");

为了解决重复键的问题,我们可以将合并函数作为第三个参数传递给toMap()方法来处理与重复键关联的值。

对于重复键场景,我们可能有不同的合并要求。在我们的示例中,一旦出现重复名称,我们希望选择较高的分数。因此,我们的目标是得到这Map:

Map<String, Integer> expectedMap = new HashMap<String, Integer>() {{
    put("Saajan", 35);
    put(
"Liam", 100);
    put(
"Kai", 92); // <- max of 92 and 76
    put(
"Eric", 42);
    put(
"Kevin", 77);
}};

接下来我们看看如何实现:

Map<String, Integer> mergedMap = Stream.of(playerMap1, playerMap2, playerMap3, playerMap4)
  .flatMap(map -> map.entrySet()
    .stream())
  .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, Integer::max));
 
assertEquals(expectedMap, mergedMap);

如代码中所示,我们使用方法引用Integer::max作为toMap()中的合并函数。这确保了当出现重复键时,最终映射中的结果值将是与这些键关联的两个值中较大的一个。

处理空值
我们已经看到Collectors.toMap()可以方便地将条目收集到单个映射中。但是,Collectors.toMap ()方法无法将null处理为 map 的值。如果任何映射条目的值为null,我们的解决方案将引发NullPointerException 。

让我们添加一个新Map来验证:

Map<String, Integer> playerMap5 = new HashMap<String, Integer>() {{
    put("Kai", null);
    put(
"Jerry", null);
}};
assertThrows(NullPointerException.class, () -> Stream.of(playerMap1, playerMap2, playerMap3, playerMap4, playerMap5)
  .flatMap(map -> map.entrySet()
    .stream())
  .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, Integer::max)));

现在,输入流中的映射包含重复的键和空值。这一次,我们仍然希望重复的玩家名字能够获得更高的分数。此外,我们将null视为最低分数。 然后,我们的预期结果如下:

Map<String, Integer> expectedMap = new HashMap<String, Integer>() {{
    put("Saajan", 35);
    put(
"Liam", 100);
    put(
"Kai", 92); // <- max of 92, 76, and null
    put(
"Eric", 42);
    put(
"Kevin", 77);
    put(
"Jerry", null);
}};

由于Integer.max()无法处理null值,因此我们创建一个 null 安全方法来从两个可为 null 的Integer对象中获取较大的值:

private Integer maxInteger(Integer int1, Integer int2) {
    if (int1 == null) {
        return int2;
    }
    if (int2 == null) {
        return int1;
    }
    return max(int1, int2);
}

接下来我们来解决这个问题。

使用flatMap()和forEach()
解决这个问题的一个简单方法是首先初始化一个空映射,然后在forEach()中将put()所需的键值对放入其中:

Map<String, Integer> mergedMap = new HashMap<>();
Stream.of(playerMap1, playerMap2, playerMap3, playerMap4, playerMap5)
  .flatMap(map -> map.entrySet()
    .stream())
  .forEach(entry -> {
      String k = entry.getKey();
      Integer v = entry.getValue();
      if (mergedMap.containsKey(k)) {
          mergedMap.put(k, maxInteger(mergedMap.get(k), v));
      } else {
          mergedMap.put(k, v);
      }
    });
assertEquals(expectedMap, mergedMap);

使用groupingBy()、  mapping()和reducing()
flatMap () + forEach()解决方案很简单。然而,它不是一种函数式方法,需要我们编写一些样板合并逻辑。

或者,我们可以结合groupingBy()、mapping()和ducing() 收集器来从功能上解决这个问题:

Map<String, Integer> mergedMap = Stream.of(playerMap1, playerMap2, playerMap3, playerMap4, playerMap5)
  .flatMap(map -> map.entrySet()
    .stream())
  .collect(groupingBy(Map.Entry::getKey, mapping(Map.Entry::getValue, reducing(null, this::maxInteger))));
 
assertEquals(expectedMap, mergedMap);

如上面的代码所示,我们在collect()方法中组合了三个收集器。接下来,让我们快速了解一下他们是如何协同工作的:
  • groupingBy(Map.Entry::getKey, mapping(…)) –按键对映射条目进行分组以获取键 -> 条目结构,这些条目将转到映射()
  • Map(Map.Entry :: getValue,reducing(…) -使用Map.Entry :: getValue将每个 Entry映射到Integer并将Integer值移交给另一个下游收集器reducing()的下游收集器
  • reduce(null, this::maxInteger) – 下游收集器通过执行maxInteger函数来应用减少重复键的整数值的逻辑,该函数返回两个整数值中的最大值