将可变对象用作Hashmap键是不好的做法吗?当您尝试使用已经修改足以更改其哈希码的密钥从Hashmap检索值时会发生什么?
例如,给定
class Key
{
int a; //mutable field
int b; //mutable field
public int hashcode()
return foo(a, b);
// setters setA and setB omitted for brevity
}
代码
HashMap<Key, Value> map = new HashMap<Key, Value>();
Key key1 = new Key(0, 0);
map.put(key1, value1); // value1 is an instance of Value
key1.setA(5);
key1.setB(10);
如果我们现在致电map.get(key1)
会怎样?这是安全的还是可取的?或者行为是否取决于语言?
答案 0 :(得分:69)
许多备受推崇的开发商如Brian Goetz和Josh Bloch都注意到:
如果对象的hashCode()值可以根据其状态改变,那么我们 在基于哈希的键中使用此类对象时必须小心 集合,以确保我们不允许他们的状态改变时 它们被用作哈希键。所有基于散列的集合都假定 对象的哈希值在使用时不会改变 集合中的关键。如果密钥的哈希码在其中发生变化 是一个集合,一些不可预测和混乱的后果 可以跟着。这在实践中通常不是问题 - 事实并非如此 通常的做法是使用像List这样的可变对象作为关键字 HashMap中。
答案 1 :(得分:21)
这不安全或不可取。无法检索由key1映射的值。在进行检索时,大多数哈希映射都会执行类似
的操作Object get(Object key) {
int hash = key.hashCode();
//simplified, ignores hash collisions,
Entry entry = getEntry(hash);
if(entry != null && entry.getKey().equals(key)) {
return entry.getValue();
}
return null;
}
在此示例中,key1.hashcode()现在指向哈希表的错误存储桶,您将无法使用key1检索value1。
如果你做过类似的事,
Key key1 = new Key(0, 0);
map.put(key1, value1);
key1.setA(5);
Key key2 = new Key(0, 0);
map.get(key2);
这也不会检索value1,因为key1和key2不再相等,所以这个检查
if(entry != null && entry.getKey().equals(key))
会失败。
答案 2 :(得分:5)
这不起作用。您正在更改键值,因此您基本上将其丢弃。它就像创建一个真实的生活密钥并锁定,然后更改密钥并尝试将其放回锁中。
答案 3 :(得分:5)
哈希映射使用哈希码和相等比较来识别具有给定键的某个键值对。如果has映射将键保留为对可变对象的引用,则在使用相同实例来检索值的情况下,它将起作用。但请考虑以下情况:
T keyOne = ...;
T keyTwo = ...;
// At this point keyOne and keyTwo are different instances and
// keyOne.equals(keyTwo) is true.
HashMap myMap = new HashMap();
myMap.push(keyOne, "Hello");
String s1 = (String) myMap.get(keyOne); // s1 is "Hello"
String s2 = (String) myMap.get(keyTwo); // s2 is "Hello"
// because keyOne equals keyTwo
mutate(keyOne);
s1 = myMap.get(keyOne); // returns "Hello"
s2 = myMap.get(keyTwo); // not found
如果将密钥存储为参考,则以上情况属实。在Java中通常就是这种情况。例如,在.NET中,如果键是值类型(始终按值传递),则结果将不同:
T keyOne = ...;
T keyTwo = ...;
// At this point keyOne and keyTwo are different instances
// and keyOne.equals(keyTwo) is true.
Dictionary myMap = new Dictionary();
myMap.Add(keyOne, "Hello");
String s1 = (String) myMap[keyOne]; // s1 is "Hello"
String s2 = (String) myMap[keyTwo]; // s2 is "Hello"
// because keyOne equals keyTwo
mutate(keyOne);
s1 = myMap[keyOne]; // not found
s2 = myMap[keyTwo]; // returns "Hello"
其他技术可能有其他不同的行为。但是,几乎所有这些都会出现使用可变键的结果不确定的情况,这在应用程序中是非常非常糟糕的情况 - 难以调试甚至更难理解。
答案 4 :(得分:4)
如果键值对(条目)存储在HashMap中后键的哈希码发生了变化,则地图将无法检索条目。
如果密钥对象是可变的,则密钥的哈希码可以改变。 HahsMap中的可变键可能导致数据丢失。
答案 5 :(得分:2)
正如其他人解释的那样,这很危险。
避免这种情况的一种方法是让const字段明确地给出可变对象中的哈希值(因此你可以在它们的“身份”上哈希,而不是它们的“状态”)。您甚至可能或多或少地随机初始化该哈希字段。
另一个技巧是使用地址,例如(intptr_t) reinterpret_cast<void*>(this)
作为哈希的基础。
在所有情况下,你必须放弃散列对象的变化状态。
答案 6 :(得分:2)
根据您对行为的期望,可变键可能会出现两个截然不同的问题。
第一个问题:(可能是最微不足道的——但它给我带来了我没有想到的问题!)
您试图通过更新和修改相同键对象将键值对放入映射中。您可以执行类似 Map<Integer, String>
的操作,然后简单地说:
int key = 0;
loop {
map.put(key++, newString);
}
我正在重用“对象”key
来创建地图。这在 Java 中运行良好,因为自动装箱 key
的每个新值都被自动装箱为一个新的 Integer 对象。如果我创建了自己的(可变的)整数对象, 会做什么:
MyInteger {
int value;
plusOne(){
value++;
}
}
然后尝试了同样的方法:
MyInteger key = new MyInteger(0);
loop{
map.put(key.plusOne(), newString)
}
我的期望是,例如,我映射 0 -> "a"
和 1 -> "b"
。在第一个示例中,如果我更改 int key = 0
,地图将(正确地)给我 "a"
。为简单起见,让我们假设 MyInteger
总是返回相同的 hashCode()
(如果您能以某种方式设法为对象的所有可能状态创建唯一的 hashCode 值,这将不是问题,您应该获得奖励)。在这种情况下,我调用 0 -> "a"
,所以现在地图持有我的 key
并将其映射到 "a"
,然后我修改 key = 1
并尝试将 1 -> "b"
. 我们有问题! hashCode()
是相同的,HashMap 中唯一的键是我的 MyInteger key
对象,它刚刚被修改为等于 {{1} },所以它会覆盖那个键的值,所以现在,我只有 1
,而不是带有 0 -> "a"
和 1 -> "b"
的映射!更糟糕的是,如果我改回1 -> "b"
,hashCode指向key = 0
,但是由于HashMap的唯一键是我的键对象,它满足等式检查并返回1 -> "b"
,不像预期的那样"b"
。
如果像我一样,您成为此类问题的牺牲品,则诊断起来非常困难。为什么?因为如果你有一个不错的 "a"
函数,它会生成(大部分)唯一值。在构造映射时,散列值将在很大程度上解决不等式问题,但是如果您有足够的值,最终您会在散列值上发生冲突,然后您会得到意想不到的和大部分无法解释的结果。 由此产生的行为是它适用于小型运行,但不适用于大型运行。
建议:
要找到这种类型的问题,修改 hashCode()
方法,即使是微不足道的(即 hashCode()
--显然在这样做时,请记住两个相等对象的哈希值应该相同*),看看您是否得到相同的结果——因为您应该这样做,如果您不这样做,那么您使用哈希表的实现可能存在语义错误。
*总是从 hashCode() 返回 0 应该没有危险(如果有的话——你有语义问题)(尽管它会破坏哈希的目的桌子)。但这就是重点:hashCode 是一种不准确的“快速而简单”的相等性度量。因此,两个截然不同的对象可能具有相同的 hashCode() 但不相等。另一方面,两个相等的对象必须始终具有相同的 hashCode() 值。
附言在 Java 中,根据我的理解,如果你做了这么糟糕的事情(因为有很多 hashCode() 冲突),它将开始使用红黑树而不是 ArrayList。所以当你期望 O(1) 查找时,你会得到 O(log(n))——这比给出 O(n) 的 ArrayList 更好。
第二个问题:
这是大多数其他人似乎都在关注的问题,所以我会尽量简短。在这个用例中,我尝试映射一个键值对,然后我对键做了一些工作,然后想回来获取我的值。
预期:= 0
已映射,然后我修改 key -> value
并尝试 key
。我期望会给我get(key)
。
对我来说这似乎很明显这行不通,但我之前尝试过使用 Collections 之类的东西作为关键(并且很快意识到它不起作用)。它不起作用,因为很可能 value
的哈希值已更改,因此您甚至不会在正确的存储桶中查找。
这就是为什么非常不建议使用集合作为键。我会假设,如果你这样做,你是在尝试建立一种多对一的关系。所以我有一堂课(如教学),我希望两个小组做两个不同的项目。我想要的是给定一个小组,他们的项目是什么?很简单,我把班级分成两部分,我有 key
和 group1 -> project1
。可是等等!一个新学生来了,所以我把他们放在group2 -> project2
。问题是 group1
现在已被修改,并且其哈希值可能已更改,因此尝试执行 group1
可能会失败,因为它会在 HashMap 的错误或不存在的存储桶中查找。
上述问题的明显解决方案是链接事物——而不是使用组作为键,给它们指定指向组和项目的标签(不会改变):get(group1)
和 { {1}} 等
附注
请确保为您希望用作键的任何对象定义 g1 -> group1
和 g1 -> project1
方法(eclipse,我假设大多数 IDE 可以为您执行此操作)。< /p>
代码示例:
这是一个展示两种不同“问题”行为的类。在本例中,我尝试映射 hashCode()
、equals(...)
和 0 -> "a"
(在每种情况下)。在第一个问题中,我通过修改同一个对象来做到这一点,在第二个问题中,我使用了唯一对象,在第二个问题“固定”中,我克隆了这些唯一对象。之后,我使用其中一个“唯一”键 (1 -> "b"
) 并对其进行修改以尝试访问地图。我期望当键是 2 -> "c"
时,这会给我 k0
和 a, b, c
。
然而,会发生以下情况:
null
第一个映射(“第一个问题”)失败,因为它只包含一个键,该键上次更新并放置为等于 3
,因此为什么它在 {{1} 时正确返回 map.get(0) map1: 0 -> null, map2: 0 -> a, map3: 0 -> a
map.get(1) map1: 1 -> null, map2: 1 -> b, map3: 1 -> b
map.get(2) map1: 2 -> c, map2: 2 -> a, map3: 2 -> c
map.get(3) map1: 3 -> null, map2: 3 -> null, map3: 3 -> null
} 但为其他两个返回 2
(单个键不等于 0 或 1)。第二张地图失败了两次:最明显的是,当我要求 "c"
时它返回 k0 = 2
(因为它已被修改——这是“第二个问题”,当你做类似的事情时,这似乎很明显这)。在修改 null
(我希望它是 "b"
)后返回 k0
时它第二次失败。这更多是由于“第一个问题”:有一个哈希码冲突,而决胜局是一个相等性检查——但地图持有 "a"
,它(显然对我来说——理论上可能对其他人有所不同) 首先检查并因此返回第一个值 k0 = 2
即使它一直检查,"c"
也会匹配。最后,第三个映射完美运行,因为无论我做什么(通过在插入过程中克隆对象),我都强制该映射保存唯一键。
我想明确表示我同意,克隆不是解决方案!我只是添加了一个例子,说明为什么地图需要唯一键以及强制执行唯一键如何“修复”问题。
k0
一些注意事项:
在上面应该注意的是,"a"
只包含两个值,因为我设置了 "c"
函数来分割赔率和偶数。 public class HashMapProblems {
private int value = 0;
public HashMapProblems() {
this(0);
}
public HashMapProblems(final int value) {
super();
this.value = value;
}
public void setValue(final int i) {
this.value = i;
}
@Override
public int hashCode() {
return value % 2;
}
@Override
public boolean equals(final Object o) {
return o instanceof HashMapProblems
&& value == ((HashMapProblems) o).value;
}
@Override
public Object clone() {
return new HashMapProblems(value);
}
public void reset() {
this.value = 0;
}
public static void main(String[] args) {
final HashMapProblems k0 = new HashMapProblems(0);
final HashMapProblems k1 = new HashMapProblems(1);
final HashMapProblems k2 = new HashMapProblems(2);
final HashMapProblems k = new HashMapProblems();
final HashMap<HashMapProblems, String> map1 = firstProblem(k);
final HashMap<HashMapProblems, String> map2 = secondProblem(k0, k1, k2);
final HashMap<HashMapProblems, String> map3 = secondProblemFixed(k0, k1, k2);
for (int i = 0; i < 4; ++i) {
k0.setValue(i);
System.out.printf(
"map.get(%d) map1: %d -> %s, map2: %d -> %s, map3: %d -> %s",
i, i, map1.get(k0), i, map2.get(k0), i, map3.get(k0));
System.out.println();
}
}
private static HashMap<HashMapProblems, String> firstProblem(
final HashMapProblems start) {
start.reset();
final HashMap<HashMapProblems, String> map = new HashMap<>();
map.put(start, "a");
start.setValue(1);
map.put(start, "b");
start.setValue(2);
map.put(start, "c");
return map;
}
private static HashMap<HashMapProblems, String> secondProblem(
final HashMapProblems... keys) {
final HashMap<HashMapProblems, String> map = new HashMap<>();
IntStream.range(0, keys.length).forEach(
index -> map.put(keys[index], "" + (char) ('a' + index)));
return map;
}
private static HashMap<HashMapProblems, String> secondProblemFixed(
final HashMapProblems... keys) {
final HashMap<HashMapProblems, String> map = new HashMap<>();
IntStream.range(0, keys.length)
.forEach(index -> map.put((HashMapProblems) keys[index].clone(),
"" + (char) ('a' + index)));
return map;
}
}
和 map1
因此具有相同的 hashCode()
和 k = 0
。因此,当我修改 k = 2
并尝试 hashCode
时,映射 0
被覆盖--k = 2
仍然 存在,因为它存在于不同的存储桶中.
还有很多不同的方法来检查上面代码中的映射,我鼓励那些好奇的人做一些事情,比如打印出映射的值,然后是值映射的关键(你可能会感到惊讶根据你得到的结果)。尝试更改不同的“唯一”键(即 k -> "c"
、k -> "a"
和 k -> "b"
),尝试更改单个键 k0
。您还可以看到即使 k1
也不是实际上固定的,因为您还可以访问密钥(例如通过 k2
)并修改它们。
答案 7 :(得分:0)
如果对象的值以影响等于比较的方式更改而对象(Mutable)是关键字,则不指定Map的行为。即使对于Set也使用可变对象作为键不是一个好主意。
让我们看一个例子:
public class MapKeyShouldntBeMutable {
/**
* @param args
*/
public static void main(String[] args) {
// TODO Auto-generated method stub
Map<Employee,Integer> map=new HashMap<Employee,Integer>();
Employee e=new Employee();
Employee e1=new Employee();
Employee e2=new Employee();
Employee e3=new Employee();
Employee e4=new Employee();
e.setName("one");
e1.setName("one");
e2.setName("three");
e3.setName("four");
e4.setName("five");
map.put(e, 24);
map.put(e1, 25);
map.put(e2, 26);
map.put(e3, 27);
map.put(e4, 28);
e2.setName("one");
System.out.println(" is e equals e1 "+e.equals(e1));
System.out.println(map);
for(Employee s:map.keySet())
{
System.out.println("key : "+s.getName()+":value : "+map.get(s));
}
}
}
class Employee{
String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public boolean equals(Object o){
Employee e=(Employee)o;
if(this.name.equalsIgnoreCase(e.getName()))
{
return true;
}
return false;
}
public int hashCode() {
int sum=0;
if(this.name!=null)
{
for(int i=0;i<this.name.toCharArray().length;i++)
{
sum=sum+(int)this.name.toCharArray()[i];
}
/*System.out.println("name :"+this.name+" code : "+sum);*/
}
return sum;
}
}
这里我们试图添加可变对象&#34; Employee&#34;到地图。如果添加的所有键都是不同的,它将很有用。我已经覆盖了员工类的equals和hashcode。
首先我添加了&#34; e&#34;然后&#34; e1&#34;。对于它们两者,equals()将为true,并且hashcode将是相同的。因此map看起来好像添加了相同的键,因此它应该用e1的值替换旧值。然后我们添加了e2,e3,e4,我们现在很好。
但是当我们改变已经添加的密钥的值时,即&#34; e2&#34;作为一个,它成为类似于之前添加的关键。现在地图将表现为有线。理想情况下,e2应该替换现有的相同密钥,即e1.But now map也是如此。你会在o / p中得到这个:
is e equals e1 true
{Employee@1aa=28, Employee@1bc=27, Employee@142=25, Employee@142=26}
key : five:value : 28
key : four:value : 27
key : one:value : 25
key : one:value : 25
在这里看到两个键也有一个显示相同的值。所以它出乎意料。现在通过更改e2.setName("diffnt");
e2.setName("one");
来再次运行相同的程序......现在o / p将是这样的:
is e equals e1 true
{Employee@1aa=28, Employee@1bc=27, Employee@142=25, Employee@27b=26}
key : five:value : 28
key : four:value : 27
key : one:value : 25
key : diffnt:value : null
因此,不鼓励在地图中添加更改可变键。
答案 8 :(得分:0)
为了使答案紧凑:
根本原因是 HashMap
只计算用户密钥对象哈希码的内部哈希一次,并将其存储在内部以满足自己的需要。
地图内部数据导航的所有其他操作都是通过这个预先计算的内部哈希来完成的。
因此,如果您更改键对象的哈希码(变异),它仍将使用更改后的键对象的哈希码很好地存储在映射中(您甚至可以通过 HashMap.keySet()
观察它并查看更改后的哈希码)。
但是 HashMap
内部哈希当然不会被重新计算,它将是旧存储的,并且地图将无法通过提供的变异键对象新哈希码来定位您的数据。 (例如通过 HashMap.get()
或 HashMap.containsKey()
)。
您的键值对仍将位于地图内,但要取回它,您将需要将数据放入地图时提供的旧哈希码值。
请注意,您也将无法通过直接从 HashMap.keySet()
获取的变异键对象取回数据。