【问题标题】:Do java objects used as hash keys need to be 'fully' immutable?用作哈希键的 java 对象是否需要“完全”不可变?
【发布时间】:2018-02-08 12:33:31
【问题描述】:

如果 hashCode() 计算 使用不可变字段 并且 equals() 使用所有字段,当类用作哈希键时会不会有问题?例如

import java.util.Objects;

public class Car {
    protected final long vin;
    protected String state;
    protected String plateNumber;

    public Car( long v, String s, String p ) {
        vin = v; state = s; plateNumber = p;
    }


    public void move( String s, String p ) {
        state = s; plateNumber = p;
    }


    public int hashCode() {
        return (int)( vin % Integer.MAX_VALUE );
    }


    public boolean equals( Object other ) {
        if (this == other) return true;
        else if (!(other instanceof Car)) return false;
        Car otherCar = (Car) other;
        return vin == otherCar.vin
                && Objects.equals( state, otherCar.state )
                && Objects.equals( plateNumber, otherCar.plateNumber );
    }
}

在汽车对象插入哈希集后,它会在汽车对象上调用 move(),这可能通过保存在别处的引用来实现。

我不关心这里的性能问题。只有正确性。

我已阅读 java hashCode contact,关于 SO 的答案很少,包括尊敬的 Jon Skeet 的 this 和来自 big blue 的 this。我觉得最后一个链接给出了最好的解释,暗示上面的代码是正确的。

编辑

结论:
此类满足 java 中对“equals()”和“hashCode()”的约束。然而,当用作集合中的键时,它违反了对“equals()”的附加要求,无论是否散列。
附加要求是,只要对象是键,'equals()' 需要保持一致。
请参阅下面 Louis Wasserman 的反例和 Douglas 提供的参考资料。

一些澄清:

A) 这个类满足 java 对象级别的约束:

  1. (carA == carB)暗示(carA.hashCode() == carB.hashCode())
  2. ( carA.hashCode() != carB.hashCode() ) 暗示 (carA != carB )
  3. equals() 需要是自反的、对称的、传递的。
  4. hashCode() 需要保持一致。即在其生命周期内无法更改对象。
  5. equals() 需要保持一致只要两个对象都没有被修改

请注意,“1.”和“2.”的倒数不是必需的。并且上面的类满足所有条件。
java 文档还提到 "equals() ... 在对象上实现了最有区别的可能等价关系",但不确定这是否是强制性的。

B) 至于性能,碰撞避免概率的增量随着我们组合的每个连续成员变量而降低。通常选择几个精心挑选的成员变量就足够了。

【问题讨论】:

  • 如果equals和hashcode不一致,那么问题就更大了

标签: java hashmap hashcode


【解决方案1】:

哈希通过将项目放入“桶”来工作。每个桶由hashcode 计算。找到存储桶后,搜索继续使用equals 逐一比较每个项目。

例如:

  • 插入过程中:将id为100的对象放入桶5(计算出的hashcode为5)。
  • 在检索期间:您要求哈希图查找项目 100。如果哈希现在计算为 7,那么算法将在存储桶 7 中搜索您的对象,但您的对象将永远不会被找到,因为它位于存储桶 5 中。李>

总而言之:哈希码和实际密钥一起工作。前者用于知道该项目应该在哪个桶中。后者由equals 比较使用,以寻找要返回的实际项目。

【讨论】:

    【解决方案2】:

    简短回答:应该没问题,但要为奇怪的行为做好准备。

    更长的答案:当您更改某个键上参与 equals() 的字段时,将不再找到由该键键入的值。

    更长的答案:这看起来像 X/Y 问题:你在问 X,但你确实需要 X 来完成 Y。也许你应该问 Y?

    您的汽车由vin 唯一标识。一辆车等于它自己。但是,汽车可以在不同的州注册。也许答案是在汽车上附加一个注册对象(或其中一些)?然后您可以将car.equals()registration.equals() 分开。

    【讨论】:

      【解决方案3】:

      当只考虑hashCodeequals 合约时,你是正确的,这个实现满足他们的要求。 hashCode 使用 equals 使用的字段的严格子集足以保证 a.equals(b) 根据需要隐含 a.hashCode() == b.hashCode()

      但是,当您引入 Map 时,情况会发生变化。来自Map javadoc,“如果对象的值以影响等于比较的方式更改,而对象是映射中的键,则不会指定映射的行为。”

      Car 上调用 move(这是 Map 中的一个键)后,该 Map 的行为现在未指定。在许多情况下,它实际上仍会按照您希望的方式工作,但奇怪的事情可能会以难以预测的方式发生。虽然从技术上讲,Map 自发清空或切换所有查找以使用随机数生成器在技术上是有效的,但更可能的情况可能是这样的:

      Car car1 = ... Car car2 = ... // a copy of car1 Map<Car, String> map1 = ... map1.put(car1, "value"); assert map1.get(car2).equals("value"); // true car1.move(...); assert map1.get(car2).equals("value"); // NullPointerException on the equals call, car2 is no longer found

      请注意,car2Map 本身都没有发生任何变化,但 car2 的映射无论如何都发生了变化(或者更确切地说,消失了)。这种行为没有正式规定,但我猜大多数 Map 实现确实有这种行为。

      【讨论】:

      • 当你说Car car2 = ... // a copy of car1时,你的意思是Car car2 = car1吗?
      • @Arkadiy 不,这会使car2 只是对同一个对象的引用。我的意思是car2Car 的一个新的、独立的实例,恰好满足car1.equals(car2) 的条件。
      【解决方案4】:

      您可以在在它们被用作键之前或之后(而不是期间)尽可能多地改变您的关键候选者。 在实践中,执行此规则非常困难。如果你改变对象,你无法控制是否有人将它们用作键。

      密钥的不变性更容易,消除了微妙的、难以发现的错误的来源,并且更好地为密钥工作。

      在您的情况下,我没有发现正确性问题。但是,为什么您会费心不在哈希码中包含所有字段呢?

      【讨论】:

        【解决方案5】:

        简短的回答是:不。

        长答案:

        不需要完全不变。但是:

        Equals 只能依赖于不可变的值。哈希码必须依赖于不可变的值,要么是常量,要么是 equals 中使用的值的子集,或者 equals 中使用的所有值。 equals 中未提及的值不能是哈希码的一部分。

        如果您改变值 equals 并且哈希码依赖于它,您可能不会在基于哈希的数据结构中再次找到您的对象。看看这个:

        public class Test {
        
        
            private static class TestObject {
                private String s;
        
                public TestObject(String s) {
                    super();
                    this.s = s;
                }
        
                public void setS(String s) {
                    this.s = s;
                }
        
                @Override
                public boolean equals(Object obj) {
                    boolean equals = false;
                    if (obj instanceof TestObject) {
                        TestObject that = (TestObject) obj;
                        equals = this.s.equals(that.s);
                    }
                    return equals;
                }
        
                @Override
                public int hashCode() {
                    return this.s.hashCode();
                }
            }
        
            public static void main(String[] args) {
        
                TestObject a1 = new TestObject("A");
                TestObject a2 = new TestObject("A");
        
                System.out.println(a1.equals(a2)); // true
        
                HashMap<TestObject, Object> hashSet = new HashMap<>(); // hash based datastructure
        
                hashSet.put(a1, new Object());
        
                System.out.println(hashSet.containsKey(a1)); // true
        
                a1.setS("A*");
        
                System.out.println(hashSet.containsKey(a1)); // false !!! // Object is not found as the hashcode used initially before mutation was used to determine the hash bucket
        
                a2.setS("A*");
        
                System.out.println(hashSet.containsKey(a2)); // false !!! Because a1 is in wrong hash bucket ...
        
                System.out.println(a1.equals(a2)); // ... even if the objects are equals
        
            }
        
        }
        

        【讨论】:

        • 这不是对 OP 问题的回答。在问题中,OP 声明 hashCode() 仅依赖于不可变字段。在您的示例中,hasCode() 使用已变异的 s
        • 我确实回答了这个问题。我说他使用哈希码等于覆盖并依赖不可变值的对象的方法没有问题。然后我加强了它永远不会导致问题的规则(哈希码必须是常量或依赖于 equals 中使用的值,equals 必须依赖于不可变的值)。最后我展示了一个例子,我违反了规则来展示机制。
        • 我现在调整了我的答案,以便该对象是哈希映射的键...
        • hashCode()/equals() 的唯一要求是当 equals==true 时 hashCode 返回相同的值。没有要求 hashCode 应该使用与 equals() 完全相同的字段。 hashCode 可以使用适当的字段子集。事实上,hashCode 可以使用字段的 EMPTY 子集 - 这对性能来说很糟糕,但它不会破坏不变量。
        • 另一个问题:您的示例代码演示了一个问题,但这与 OP 的问题无关。您表明不能在 hashCode 中使用可变字段。但 OP 并没有尝试这样做。
        【解决方案6】:

        当您的 hashCode() 实现仅使用有限数量的字段(与 equals 相比)时,您会降低几乎所有使用散列的算法/数据结构的性能:HashMapHashSet 等。您正在增加碰撞概率 - 这是两个不同对象(equals 返回 false)具有相同哈希值的情况。

        【讨论】:

        • 并非完全正确。碰撞概率取决于唯一哈希码值的数量。即使具有依赖于单个字段的哈希码,它也可能足够独特。示例 - 基于唯一 ID 的哈希码(来自数据库序列)。
        • @BartoszBilicki 在您具有唯一 ID 的示例中,您为什么要在 equals 方法中使用任何其他字段?
        • 如果在我的评论中描述,我会让 equals 和 hashcode 都只依赖于该 uniqueID。但是该 uniqueID 必须是不可变的,并且必须在创建对象时提供(通过 ctor/factory 方法)。这是使用 Hibernate/JPA 解决实体身份问题的方法之一。
        • @BartoszBilicki 在我的回答中,我说的是在 hashCode 中使用 equals 中使用的字段子集时的情况。
        【解决方案7】:

        Car 出现在地图中之后,您永远不会调用move,这是正确的。否则就错了。 hashCode equals 都必须在地图中出现键后保持一致。

        【讨论】:

        • 抱歉,刚刚编辑了问题。你能解释一下什么会导致问题或违反语言要求吗?谢谢。
        • 你的陈述有点笼统。是的,一般来说它是错误的,但在几乎所有情况下它都有效,具体取决于我们查看的用例。让我们假设 HashMap 并且我们使用 c 的一个实例 Car 作为映射的键(无论关联的值是什么)。如果c 更改,HashMap 仍将按预期工作。如果我传入满足something.equals(c)something(更改后的c),它将在HashMap 中找到相关值,因为chashCode 没有改变,而equals 是由HashMap 实现按需执行。
        • 你能举个例子,当 map'll 在调用 move 后开始工作不好时?现在我看到调用 move 是合法的,并且 key 是可以访问的。是的,最好使用不可变或有效的不可变对象,但这不是必须的,当哈希码不使用可变字段或者我不理解某些东西时。
        • 调用move后,两个开始不相等的键可以变为相等。这违反了 HashMap 契约,即映射中没有两个键是相同的。
        • 请注意,结果是第二次出现的键从地图中“消失”:您无法使用get 访问它。
        猜你喜欢
        • 2015-11-27
        • 2014-02-18
        • 2013-12-11
        • 2017-12-10
        • 2011-03-29
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2015-08-10
        相关资源
        最近更新 更多