可变哈希图键是一种危险的做法吗?

Posted

技术标签:

【中文标题】可变哈希图键是一种危险的做法吗?【英文标题】:Are mutable hashmap keys a dangerous practice? 【发布时间】:2011-12-12 03:00:58 【问题描述】:

使用可变对象作为 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) 会发生什么?这是安全的还是可取的?还是行为取决于语言?

【问题讨论】:

我想说,一般来说, 不建议使用可变密钥。但“安全”是一个不同的问题。您可以通过更新键值对(任何时候键更改)来保持“安全”。此外,它绝对依赖于语言,因为行为由合同决定——语言将键定义为特定对象或值并非不可想象(尽管不太可能),即 O1 等于 O2,但 O1 指向不同的值比哈希表中的 O2(同样,这种行为没有多大意义)。 【参考方案1】:

根据您对行为的期望,可变密钥可能会出现两个非常不同的问题。

第一个问题:(可能是最微不足道的——但它给我带来了我没有想到的问题!)

您正试图通过更新和修改 same 键对象将键值对放入映射中。你可能会做类似Map&lt;Integer, String&gt; 的事情,然后简单地说:

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 -&gt; "a"1 -&gt; "b"。在第一个示例中,如果我更改int key = 0,地图将(正确)给我"a"。为简单起见,我们假设 MyInteger 总是返回相同的 hashCode() (如果您能够设法为对象的所有可能状态创建唯一的 hashCode 值,这将不是问题,您应该获得奖励)。在这种情况下,我调用0 -&gt; "a",所以现在映射包含我的key 并将其映射到"a",然后我修改key = 1 并尝试放入1 -&gt; "b"我们有问题! hashCode() 是一样的,HashMap 中唯一的键是我的 MyInteger key 对象,它刚刚被修改为等于 1,所以它覆盖了键的值,所以现在,我只有1 -&gt; "b",而不是0 -&gt; "a"1 -&gt; "b" 的映射! 更糟糕的是,如果我改回key = 0,hashCode 指向@ 987654346@,但由于 HashMap 的唯一键我的键对象,它满足相等检查并返回"b",而不是预期的"a"

如果您像我一样陷入此类问题,诊断起来非常困难。为什么?因为如果你有一个不错的hashCode() 函数,它将生成(大部分)唯一值。在构建映射时,哈希值将在很大程度上解决不等式问题,但如果你有足够的值,最终你会在哈希值上遇到冲突,然后你会得到意想不到的和很大程度上无法解释的结果。 由此产生的行为是它适用于小型运行,但不适用于大型运行。

建议:

要查找此类问题,请修改hashCode() 方法,即使是微不足道的(即= 0--显然,在这样做时,请记住哈希值对于两个相等的对象应该相同*),并且看看你是否得到相同的结果——因为你应该得到相同的结果,如果你没有得到,那么你的实现可能存在语义错误,即使用哈希表。

*总是从 hashCode() 返回 0 应该没有危险(如果有的话 - 你有语义问题)(尽管它会破坏哈希的目的桌子)。但这就是重点:hashCode 是一种“快速而简单”的平等度量,它并不精确。所以两个非常不同的对象可能有相同的 hashCode() 但不相等。另一方面,两个相等的对象必须始终具有相同的 hashCode() 值。

附言在 Java 中,据我了解,如果你做了这么糟糕的事情(例如有许多 hashCode() 冲突),它将开始使用红黑树而不是 ArrayList。所以当你期望 O(1) 查找时,你会得到 O(log(n))——这比 ArrayList 的 O(n) 要好。

第二个问题:

这是大多数其他人似乎都在关注的问题,所以我会尽量简短。在这个用例中,我尝试映射一个键值对,然后对键做一些工作,然后想回来获取我的值。

期望:key -&gt; value被映射,然后我修改key并尝试get(key)。我期待这会给我value

这对我来说似乎很明显这行不通,但我并没有超越之前尝试使用 Collections 作为键的东西(并且很快意识到它不起作用)。它不起作用,因为 key 的哈希值很可能已更改,因此您甚至不会在正确的存储桶中查找。

这就是为什么非常不建议使用集合作为键的原因。我会假设,如果你这样做,你正在尝试建立一个多对一的关系。所以我有一个班级(如教学),我希望两个小组做两个不同的项目。我想要的是给定一个小组,他们的项目是什么?很简单,我把班级一分为二,我有group1 -&gt; project1group2 -&gt; project2。可是等等!一个新学生来了,所以我把他们放在group1。问题是 group1 现在已被修改,并且可能其哈希值已更改,因此尝试执行 get(group1) 可能会失败,因为它会查找错误或不存在的 HashMap 存储桶。

上述问题的明显解决方案是链接事物——而不是使用组作为键,而是给它们提供指向组和项目的标签(不会改变):g1 -&gt; group1g1 -&gt; project1等。

附言

请确保为您希望用作键的任何对象定义hashCode()equals(...) 方法(eclipse 和我假设大多数 IDE 都可以为您执行此操作)。

代码示例:

这是一个展示两种不同“问题”行为的类。在这种情况下,我尝试映射0 -&gt; "a"1 -&gt; "b"2 -&gt; "c"(在每种情况下)。在第一个问题中,我通过修改同一个对象来做到这一点,在第二个问题中,我使用唯一对象,在第二个问题中“固定”我克隆了那些唯一对象。之后,我使用其中一个“唯一”键 (k0) 并对其进行修改以尝试访问地图。我预计当密钥为3 时,这将给我a, b, cnull

然而,会发生以下情况:

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,因此它在 k0 = 2 时正确返回 "c" 但返回 null对于其他两个(单个键不等于 0 或 1)。第二张地图失败了两次:最明显的是当我要求k0 时它返回"b"(因为它已被修改——这是“第二个问题”,当你这样做时似乎很明显)。它在修改k0 = 2(我希望是"c")后返回"a" 时第二次失败。这更多是由于“第一个问题”:存在哈希码冲突,并且决胜局是平等检查 - 但地图包含k0,它(显然对我来说 - 理论上可能与其他人不同)检查first 并因此返回第一个值,"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 = 0k = 2 因此具有相同的 hashCode0。因此,当我修改k = 2 并尝试k -&gt; "c" 时,映射k -&gt; "a" 被覆盖--k -&gt; "b" 仍然在那里,因为它存在于不同的存储桶中。

还有很多不同的方法可以检查上面代码中的映射,我鼓励那些好奇的人做一些事情,比如打印出映射的值,然后是值映射的键(你可能会感到惊讶根据你得到的结果)。尝试更改不同的“唯一”键(即k0k1k2),尝试更改单个键k。您还可以看到 secondProblemFixed 是如何实际上修复的,因为您还可以访问密钥(例如通过 Map::keySet)并修改它们。

【讨论】:

不,真正的问题是,改变一个键通常意味着它在错误的桶中,所以匹配组件相等的东西在错误的位置。根据实现重新存储表(例如,添加更多值)将更正此问题。请注意,通常的建议是avoid Java's clone() method and the Cloneable interface。并不是说它在所有情况下都有用,因为还有其他方法可以改变存储的密钥(例如,keySet()entrySet())。 @Clockwork-Muse 我认为我们在谈论两件不同的事情。我说的是,我有一个可变的键对象,我用它来放置到地图中;然后我期望如果我将键对象变回以前的状态,它将返回相同的条目(值)。我认为您正在谈论一个指向一个值的键对象,然后期望改变相同的键对象仍将指向相同的值;对我来说,这是一个不合理的期望,而不是可变键的真正问题。 ... 如果您将一个在变异后再次插入的密钥变回并调用get(),则不能保证返回 either 值。添加这样的密钥只是调用get() 的一种特殊情况,因为地图本质上是在幕后进行的,以确定是否需要替换。从地图的角度来看,它不知道您使用的是与地图中的键相同的(变异的)引用,而不是具有值相等的单独键。 @Clockwork-Muse 对……看我的回答(我解释了确切的情况)。【参考方案2】:

为了使答案紧凑: 根本原因是HashMap只计算了一次用户关键对象hashcode的内部hash并将其存储在里面以备不时之需。

地图内数据导航的所有其他操作都是通过这个预先计算的内部哈希来完成的。

因此,如果您更改键对象的哈希码(变异),它仍会使用更改后的键对象的哈希码很好地存储在地图中(您甚至可以通过HashMap.keySet() 观察它并查看更改后的哈希码)。

但是HashMap 内部哈希当然不会被重新计算,它将是旧存储的,并且地图将无法通过提供的变异键对象新哈希码来定位您的数据。 (例如,HashMap.get()HashMap.containsKey())。

您的键值对仍会在地图中,但要取回它,您需要在将数据放入地图时给出的旧哈希码值。

请注意,您也无法通过直接取自 HashMap.keySet() 的变异密钥对象取回数据。

【讨论】:

【参考方案3】:

我不会重复别人说过的话。是的,这是不可取的。但在我看来,文档在哪里说明这一点并不太明显。

你可以在the JavaDoc for the Map interface找到它:

注意:如果将可变对象用作映射,则必须非常小心 键。如果对象的值未指定映射的行为 以影响等于比较的方式更改,而 object 是地图中的一个键

【讨论】:

【参考方案4】:

如果对象的值以影响等于比较的方式更改,而对象(可变)是键,则不指定映射的行为。即使对于 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;




这里我们尝试将可变对象“Employee”添加到地图中。如果添加的所有键都是不同的,它会很好用。这里我已经覆盖了员工类的等号和哈希码。

首先看我添加了“e”,然后是“e1”。对于他们两个,equals() 将是 true 并且 hashcode 将是相同的。所以 map 看起来好像添加了相同的键,所以它应该用 e1 的值替换旧值。然后我们添加了 e2,e3,e4 我们现在很好。

但是当我们改变一个已经添加的键的值时,即“e2”,它变成了一个类似于之前添加的键。现在地图的行为是有线的。理想情况下,e2 应该替换现有的相同密钥,即 e1。但是现在 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

因此,不鼓励在地图中添加更改可变键。

【讨论】:

【参考方案5】:

如果键值对(Entry)存入HashMap后,key的哈希码发生变化,则map将无法检索到Entry。

如果键对象是可变的,键的哈希码可以改变。 HahsMap 中的可变键可能会导致数据丢失。

【讨论】:

【参考方案6】:

Brian Goetz 和 Josh Bloch 等许多受人尊敬的开发人员指出:

如果一个对象的 hashCode() 值可以根据它的状态而改变,那么我们 使用这些对象作为基于哈希的键时必须小心 集合以确保我们不允许它们的状态在何时更改 它们被用作哈希键。所有基于哈希的集合都假设 一个对象的哈希值在它被用作一个对象时不会改变 集合中的关键。如果一个键的哈希码在它发生变化时 在一个集合中,一些不可预测和令人困惑的后果 可以跟随。这在实践中通常不是问题——它不是 使用可变对象(如 List)作为 a 中的键的常见做法 哈希映射。

【讨论】:

你能发布出处吗? 可能来源:来自“Java 理论与实践”系列 - Hashing it out,作者 Brian Goetz,2003 年 5 月 27 日 这也在官方 Java API 文档中,例如对于java.util.Map:“注意:如果将可变对象用作映射键,则必须非常小心。如果对象的值以影响相等比较的方式更改,而对象是输入地图。”。 @Jared 这句话中有什么不清楚的地方:“所有基于散列的集合都假定对象的散列值在用作集合中的键时不会改变。” ?这就是为什么在哈希映射中使用可变对象作为键是不好的解释。他们在插入时计算哈希码......这里没有谬误,从书中摘录的引用段落中所写的内容是正确的。你是唯一一个对答案投反对票的人,所以你可能在接受真相方面有问题。可能您只是想在没有任何真正原因的情况下制造一些争论...... @aleroot 这也不是一个完整的解释。问题不仅仅是哈希值。这实际上只是问题的一半。这并没有解决哈希表将存储用于相等检查的关键对象的事实——也许这似乎是一个微不足道的问题,但我(至少)发现它不是。尤其是,当尝试优化 Java 的内存消耗时(当需要可变对象时),这可能会成为一个问题。【参考方案7】:

正如其他人解释的那样,这很危险。

避免这种情况的一种方法是使用一个 const 字段明确地给出可变对象中的哈希值(这样您就可以对它们的“身份”而不是它们的“状态”进行哈希处理)。您甚至可以或多或少地随机初始化该哈希字段。

另一个技巧是使用地址,例如(intptr_t) reinterpret_cast&lt;void*&gt;(this) 作为哈希的基础。

在所有情况下,您都必须放弃散列对象的变化状态。

【讨论】:

假设这是 Java,通过代码 sn-ps,程序员可以安全地依赖原生 Object.hashcode()。它应该根据创建顺序生成一个 int 值,或者其他简单的、不可变的和唯一的值。 虽然您可以在技术上做到这一点,但我真的不明白为什么你会这样做。这似乎很复杂(尽管仍然是一个很好的答案)。只是不要使用可变键,孩子们:-)。【参考方案8】:

哈希映射使用哈希码和相等比较来识别具有给定键的某个键值对。如果 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"

其他技术可能有其他不同的行为。但是,几乎所有这些都会遇到使用可变键的结果不确定的情况,这在应用程序中是非常非常糟糕的情况 - 难以调试,甚至更难以理解。

【讨论】:

【参考方案9】:

这既不安全也不可取。 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)) 

会失败。

【讨论】:

我喜欢这个答案,因为对密钥突变后实际发生的情况进行了清晰、具体的解释。也用于给出另一个失败的案例。【参考方案10】:

这行不通。您正在更改键值,因此您基本上是在丢弃它。这就像创建一个现实生活中的钥匙和锁,然后更换钥匙并试图将其放回锁中。

【讨论】:

以上是关于可变哈希图键是一种危险的做法吗?的主要内容,如果未能解决你的问题,请参考以下文章

如何从它们插入哈希图中的顺序打印哈希图值

哈希图和向量很慢

如何“忽略”哈希图的模板参数?

HashMap 和排序

危险!在HashMap中将可变对象用作Key

在数据仓库(关系)中有外键是一种好习惯吗?