原文地址:https://www.baeldung.com/java-hashmap-modify-key
1. 概述
在 Java 中,HashMap
是一个广泛使用的数据结构,它以键值对的形式存储元素,提供快速的数据访问和检索。有时,在使用 HashMap
时,我们可能想要修改现有条目的键。
在本教程中,我们将探讨如何在 Java 的 HashMap
中修改一个键。
2. 使用 remove() 然后 put()
首先,让我们看看 HashMap 是如何存储键值对的。HashMap
内部使用 Node
类型来维护键值对:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
...
}
如我们所见,键声明有 final 关键字。因此,我们不能在将其放入 HashMap 后重新分配一个键对象。
虽然我们不能简单地替换一个键,但我们仍然可以通过其他方式实现我们期望的结果。接下来,让我们从一个不同的角度来看待我们的问题。
假设我们在 HashMap
中有一个条目 K1 -> V
。现在,我们想要将 K1 更改为K2,以得到K2 -> V。实际上,实现这一点最直接的想法是找到 “K1” 的条目并用“K2”替换“K1”的键。然而,我们也可以删除 K1 -> V 的关联,并添加一个新的 K2 -> V的条目。
Map接口提供了 remove(key)
方法,可以通过其键从 map 中删除一个条目。此外,remove()
方法返回从 map 中删除的值。
接下来,让我们通过一个例子来看看这种方法是如何工作的。为了简单起见,我们将使用单元测试断言来验证结果是否符合我们的期望:
Map<String, Integer> playerMap = new HashMap<>();
playerMap.put("Kai", 42);
playerMap.put("Amanda", 88);
playerMap.put("Tom", 200);
上面的简单代码显示了一个 HashMap
,其中包含了几个玩家名(String)和他们的分数(Integer)。接下来,让我们将条目“Kai” -> 42中的玩家名“Kai”替换为“Eric”:
// 用Eric替换Kai
playerMap.put("Eric", playerMap.remove("Kai"));
assertFalse(playerMap.containsKey("Kai"));
assertTrue(playerMap.containsKey("Eric"));
assertEquals(42, playerMap.get("Eric"));
如我们所见,单行语句playerMap.put(“Eric”, playerMap.remove(“Kai”));
做了两件事。它删除了键为“Kai”的条目,取出其值(42),并添加了一个新的条目“Eric” -> 42。
当我们运行测试时,它通过了。所以,这种方法如我们所期望的那样工作。
尽管我们的问题已经解决了,但还有一个潜在的问题。我们知道 HashMap
的键是一个 final 变量。所以,我们不能重新分配变量。但是我们可以修改一个 final对象的值。好吧,在我们的 playerMap 示例中,键是 String。我们不能改变它的值,因为字符串是不可变的。但是如果它是一个可变对象,我们可以通过修改键来解决问题吗?
接下来,让我们弄清楚。
3. 永不修改 HashMap 中的键
首先,我们不应该在 Java 的 HashMap 中使用一个可变对象作为键,因为这可能导致潜在的问题和意外的行为。
这是因为 HashMap
中的键对象用于计算一个哈希码,该哈希码决定了相应的值将被存储在哪个桶中。如果键是可变的并且在被用作 HashMap
中的键之后被更改,哈希码也可以更改。结果,我们将无法正确检索与键关联的值,因为它将位于错误的桶中。
接下来,让我们通过一个例子来理解它。
首先,我们创建一个只有一个属性的Player类:
class Player {
private String name;
public Player(String name) {
this.name = name;
}
// 省略了getter和setter方法
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Player)) {
return false;
}
Player player = (Player) o;
return name.equals(player.name);
}
@Override
public int hashCode() {
return name.hashCode();
}
}
如我们所见,Player
类在 name
属性上有一个 setter
,所以它是可变的。此外,hashCode()
方法使用 name
属性来计算哈希码。这意味着更改 Player
对象的名字可以使它具有不同的哈希码。
接下来,让我们创建一个 map,并在其中放入一些条目,使用 Player
对象作为键:
Map<Player, Integer> myMap = new HashMap<>();
Player kai = new Player("Kai");
Player tom = new Player("Tom");
Player amanda = new Player("Amanda");
myMap.put(kai, 42);
myMap.put(amanda, 88);
myMap.put(tom, 200);
assertTrue(myMap.containsKey(kai));
接下来,让我们将玩家 kai 的名字从 “Kai” 更改为 “Eric”,然后验证我们是否可以得到预期的结果:
// 将Kai的名字更改为Eric
kai.setName("Eric");
assertEquals("Eric", kai.getName());
Player eric = new Player("Eric");
assertEquals(eric, kai);
// 现在,map中既不包含Kai也不包含Eric:
assertFalse(myMap.containsKey(kai));
assertFalse(myMap.containsKey(eric));
如上面的测试所示,更改 kai 的名字为 “Eric” 后,我们无法再使用 kai 或 eric 检索 “Eric” -> 42 的 Entry。然而,对象 Player(“Eric”) 存在于 map 中作为一个键:
// 虽然Player("Eric")存在:
long ericCount = myMap.keySet()
.stream()
.filter(player -> player.getName()
.equals("Eric"))
.count();
assertEquals(1, ericCount);
要理解为什么会这样,我们首先需要了解 HashMap
是如何工作的。
HashMap
维护一个内部哈希表来存储添加到 map 中的键的哈希码。一个哈希码引用一个 map 条目。当我们检索一个条目时,例如通过使用 get(key)方法,HashMap
计算给定键对象的哈希码,并在哈希表中查找哈希码。
在上面的例子中,我们将 kai(“Kai”) 放入 map 中。所以,哈希码是基于字符串“Kai”计算的。HashMap
存储了结果,让我们说 “hash-kai”,在哈希表中。后来,我们将 kai(“Kai”) 更改为 kai(“Eric”)。当我们试图通过 kai(“Eric”) 检索条目时,HashMap计算“hash-eric”作为哈希码。然后,它在哈希表中查找它。当然,它找不到它。
不难想象,如果我们在一个真正的应用程序中这样做,这种意外行为的根本原因将很难找到。
因此,我们不应该在 HashMap
中使用可变对象作为键。更进一步,我们永远不应该修改键。
4. 结论
在本文中,我们学习了remove()
然后 put()
方法来替换 HashMap
中的一个键。此外,我们通过一个例子讨论了为什么我们应该避免在 HashMap
中使用可变对象作为键,以及为什么我们永远不应该修改 HashMap
中的键。
一如既往,示例的完整源代码可以在 GitHub 上找到。
译者注
想要深入理解这个问题需要阅读 HashMap 的 put 和 containsKey 的源码。
这里作图并不严谨,只是帮助理解问题:
Kai、Tom、Amanda 分别 put 之后,效果大致如下:
其中 Eric 未真实 put,紫色这里只是模拟如果 put 之后,一个虚拟的位置(即 如果有 Eric 这个 Key put 之后应该在哪里)。
因为 Player 是可变的,那么直接 name 设置为 Eric 会导致 Map 中原本为 Kai 的对象的 name 改为了 Eric (注意此时该对象位置未发生变化)。
// 现在,map中既不包含Kai也不包含Eric:
assertFalse(myMap.containsKey(kai));
assertFalse(myMap.containsKey(eric));
myMap.containsKey(kai)
通过 containsKey 去查找时,此时 kai 的 name 已经是 Eric ,会根据 hashCode 计算到紫色 Eric 这个位置取对象,发现没有元素。
myMap.containsKey(eric)
通过 containsKey 去查找时,eric 的 name 是 Eric ,也会根据 hashCode 计算到紫色 Eric 这个位置取对象,发现没有元素。
而
// 虽然Player("Eric")存在:
long ericCount = myMap.keySet()
.stream()
.filter(player -> player.getName()
.equals("Eric"))
.count();
assertEquals(1, ericCount);
则是遍历所有 Entry 时,对象的 name 里面有 Eric (即对象 Kai 的值)。
如果最初存入 Eric, 因为重写了 hashCode 和 equals 方法,两个 eric 等价,所以 containsKey 会是 true。
Map<Player, Integer> myMap = new HashMap<>();
Player kai = new Player("Kai");
Player tom = new Player("Tom");
Player amanda = new Player("Amanda");
Player eric = new Player("Eric");
myMap.put(kai, 42);
myMap.put(amanda, 88);
myMap.put(tom, 200);
// 先存进去
myMap.put(eric, 300);
assertTrue(myMap.containsKey(kai));
// new 一个 Eric
Player ericNew = new Player("Eric");
//依然存在
assertTrue(myMap.containsKey(ericNew));