当在没有不可变字段的类中覆盖Object.GetHashCode()方法时,应该返回什么?

13

好的,在你们看到互联网上有数百个类似问题的帖子之前不要生气,我可以向你保证,我刚刚花了几个小时阅读了所有这些帖子,并没有找到我问题的答案。

背景:

基本上,我的一个大型应用程序一直遭受着这样一种情况:当对当前选定的项目进行编辑后,ListBox.SelectedItem属性上的一些Binding会停止工作或程序会崩溃。我最初在这里发布了问题,但没有得到答案。

我一直没有时间解决这个问题,直到这周我被给了几天的时间来解决它。现在说长话短说,我找出了问题的原因。这是因为我的数据类型类覆盖了Equals方法,因此也覆盖了GetHashCode方法。

对于那些不知道这个问题的人,我发现你只能使用不可变的字段/属性实现GetHashCode方法。使用Harvey Kwok在Overriding GetHashCode()帖子中的一段摘录来解释这个问题:

问题在于Dictionary和HashSet集合使用GetHashCode将每个项放入桶中。如果哈希码基于某些可变字段进行计算,而对象被放置到HashSet或Dictionary后字段实际上发生了更改,则该对象将无法从HashSet或Dictionary中找到。

所以,实际上的问题是由于我在GetHashCode方法中使用了可变的属性。当用户在UI中更改这些属性值时,对象的关联哈希码值会发生改变,然后项目就无法在它们的集合中找到。

问题:

那么,我的问题是在没有不可变字段的类中实现GetHashCode方法的最佳方式是什么?抱歉,让我更具体一些,因为已经有人问过这个问题。

Overriding GetHashCode()帖子中的答案表明,在这些情况下,最好只需返回一个常量值......有些人建议返回值1,而其他人则建议返回质数。就个人而言,我看不出这些建议之间有任何区别,因为我认为任何一个值都只会使用一个桶。

此外,Eric Lippert的博客文章《GetHashCode的指导方针和规则》中有一节标题为指导方针:哈希码的分布必须是“随机”的,强调了使用算法导致使用的桶数量不足的缺点。他警告说,算法会减少使用的桶的数量,并在桶变得非常大时导致性能问题。显然,返回一个常量属于这个范畴。

我想在我所有的数据类型类中添加一个额外的Guid字段(仅限于C#,而不是数据库),专门用于并仅用于GetHashCode方法。因此,经过这么长时间的介绍,我的实际问题是哪种实现更好?总结如下:

总结:

在没有不可变字段的类中重写 Object.GetHashCode() 方法时,是更好地从 GetHashCode 方法返回一个常量,还是创建一个额外的只用于 GetHashCode 方法的 readonly 字段?如果应该添加一个新字段,它应该是什么类型的,我是否应该将其包含在 Equals 方法中?
虽然我乐意收到任何人的答案,但我真的希望从具有对此主题有深入了解的高级开发人员那里得到答案。

如果你手头有《Effective C#》这本书,第7条就是关于这个的,如果你还没有读过的话。 - JMK
1
如果使用仅限于一个位置,则一个简单的解决方法是将类型包装在另一个类中,该类提供唯一的不可变值,可能是 Guid,并将其用于获取哈希码。我个人会尽量不为了在字典中使用而向类型添加 Guid。或者,您可以使用诸如标识映射之类的东西来基于分离的不可变 ID(再次可能是 Guid,因此实际上是相同的结果)为对象键入。或者再次替代方案是不要修改字典中的项。将它们键出,删除它们,修改它们,然后重新添加它们。 - Adam Houldsworth
2
很不清楚为什么需要覆盖这些方法。一个好的起点是完全删除Equals和GetHashCode的覆盖,从Object继承的默认实现非常出色,并保证对象的唯一性。你永远不会从它们那里得到“重复键”错误。 - Hans Passant
1
好的,它不必被命名为Equals(),对吧?您可以随意命名它,只有在.NET Framework代码起作用时才需要覆盖Equals()。这很重要,在这里很重要,因为WPF在绑定项时不太喜欢Equals()返回值发生变化。 - Hans Passant
1
@HansPassant,我要如何移除EqualsGetHashCode的覆写?在MSDN上的IEquatable<T>接口页面上说它应该被实现在任何可能存储在通用集合中的对象上,然后在IEquatable<T>.Equals方法页面上说如果你实现了Equals方法,你也应该覆盖基类的Object.Equals(Object)和GetHashCode方法,以保持其行为与IEquatable<T>一致 - Sheridan
显示剩余7条评论
5个回答

17

回归基础。你读了我的文章,再读一遍。与你的情况相关的两个绝对规则是:

  • 如果x等于y,则x的哈希码必须等于y的哈希码。同样地:如果x的哈希码不等于y的哈希码,则x和y必须是不相等的。
  • x的哈希码在x在哈希表中时必须保持稳定。

这些是“正确性”的要求。如果你不能保证这两个简单的东西,那么你的程序将不正确。

你提出了两个解决方案。

你的第一个解决方案是始终返回常量。这符合两个规则的要求,但你会降为在哈希表中进行线性搜索。你可能还不如使用列表。

你提出的另一个解决方案是以某种方式为每个对象生成哈希码并将其存储在对象中。只要相等的项有相等的哈希码,这是完全合法的如果你这样做,那么你就受到限制,即如果哈希码不同,则x=y 必须为false。这似乎使值相等基本上不可能。既然你如果想要引用相等就不会首先重写Equals,这似乎是一个非常糟糕的想法,但如果equals一致的话,它是合法的

我提出的第三个解决方案是:永远不要将你的对象放入哈希表中,因为哈希表在第一次使用时就是错误的数据结构。哈希表的目的是快速回答“这个给定值是否在这个不可变值集合中?”而你没有一组不可变的值,所以不要使用哈希表。使用正确的工具来完成任务。使用列表,并接受进行线性搜索的痛苦。

第四个解决方案是:对于用于相等性的可变字段进行哈希,在每次对其进行更改之前从所有哈希表中删除对象,然后在更改后重新放入哈希表中。这符合两个要求:哈希码与相等性一致,并且哈希表中的对象哈希保持稳定,同时仍能快速查找。


1
+1 感谢您花时间提供易于理解的答案。但是,我想要澄清一些您所说的事情... 我在我的代码中没有使用任何DictionaryHashTable。我使用WPF,我只能假设所涉及的HashTableDictionary是在框架内部使用的。从链接的先前问题的StackTrace中看到,我看到对System.Windows.DependencyObject.OnPropertyChanged的调用,以及后来的System.Windows.Controls.ListBoxItem.OnSelected。所以你看,我对此没有控制权。 - Sheridan

4
我会为此创建一个额外的只读字段,否则就会抛出NotSupportedException异常。在我看来,其他选项是没有意义的。让我们看看为什么。

不同(固定)的哈希码

提供不同的哈希码很容易,例如:
class Sample
{
    private static int counter;
    private readonly int hashCode;

    public Sample() { this.hashCode = counter++; }

    public override int GetHashCode()
    {
        return this.hashCode;
    }

    public override bool Equals(object other)
    {
        return object.ReferenceEquals(this, other);
    }
}

技术上,您需要注意创建过多的对象并溢出计数器,但实际上我认为这对任何人都不是问题。
这种方法的问题在于实例永远不会相等。然而,如果你只想将Sample的实例用作其他类型集合的索引,那么这是完全可以的。
常量哈希码
如果有任何情况下不同的实例应该比较相等,那么乍一看你别无选择,只能返回一个常量。但这样做有什么好处呢?
在容器中定位实例将总是退化为等效于线性搜索。因此,通过返回一个常量,您允许用户为您的类创建一个键控容器,但该容器将表现出LinkedList<T>的性能特征。这对于熟悉您的类的人来说可能是显而易见的,但就个人而言,我认为这是让人们自己给自己惹麻烦。如果您事先知道Dictionary不会像人们期望的那样行事,那么为什么要让用户创建它呢?在我看来,最好抛出NotSupportedException
但是,抛出异常是不允许的!
有些人会不同意上述观点,当这些人比自己聪明时,应该注意他们的意见。首先,此代码分析警告指出GetHashCode不应抛出异常。这是值得考虑的,但不要固守教条。有时你必须为了某个原因而打破规则。
然而,这还不是全部。在他的博客文章中,Eric Lippert说,如果你从GetHashCode内部抛出异常,则

您的对象不能成为许多使用哈希表进行性能优化的LINQ-to-objects查询的结果。

失去LINQ当然很糟糕,但幸运的是,道路并没有止步于此。许多(所有?)使用哈希表的LINQ方法都有重载,接受一个IEqualityComparer<T>用于哈希时使用。因此,您实际上可以使用LINQ,但会更不方便。
最后,您将不得不自行权衡选项。我的看法是只要技术可行,最好采用白名单策略(每当需要提供一个IEqualityComparer<T>),因为这使得代码变得明确:如果有人试图天真地使用该类,他们将得到一个异常,告诉他们发生了什么,并且在使用它的代码中可见等式比较器,使类的非凡行为立即清晰。

1
好的!+1 我很想尝试回答,但还是放弃了! - JMK
+1 赞同!感谢你花时间写出如此完整的答案,@Jon。如果我选择“添加一个字段”的想法,返回(相当于)Guid.NewGuid().GetHashCode() 的值可以吗?那么在 Equals 方法中也应该使用该字段吗? - Sheridan
@Sheridan:这样做也可以,但可能有些过度。从技术上讲,你也可以在Equals内部使用它进行比较,但由于你知道它旨在提供引用相等的语义,因此你也可以直接这样做。 - Jon
嗨@Jon,我进一步阅读了有关IEqualityComparer<T>接口的内容,有点困惑...实现该接口或扩展建议在MSDN上使用的EqualityComparer<T>类,是否会导致与在集合中更改对象的属性值时覆盖Equals相同的问题? - Sheridan
@Sheridan:这是不可能的,没有魔法可以使得你一边拥有蛋糕一边吃掉它。但是,通过发现该类不能按原样工作,创建一个自定义的相等比较器并使用它,应该已经足够表明“是的,我知道返回常量会影响性能,但还是这么做吧”。其想法是让你类的用户明确地选择而不是让他们幸福地“走入陷阱”。 - Jon
使用ReferenceEquals和永远不相等的哈希码并没有比object已经为您做的更好,因此在这种情况下,您可能根本不需要实现它们。该问题只有在您不希望Equals是引用相等时才有意义,在这种情况下,哈希码不能纯粹是唯一的,因为如果Equals返回true,则它们必须相等。 - Miral

1
当我想要覆盖 Equals ,但没有一个明智的不可变“键”用于对象(而且由于某种原因使整个对象不可变似乎没有意义)时,在我看来只有一种“正确”的选择:
  • 实现 GetHashCode 以哈希与 Equals 使用相同的字段。 (这可能是所有字段。)
  • 记录这些字段在字典中不能被更改。
  • 相信用户要么不将这些对象放入字典中,要么遵守第二条规则。

(返回常量值会损害字典性能。抛出异常会禁止太多有用的情况,其中对象被缓存但未被修改。任何其他实现 GetHashCode 的方法都是错误的。)

在任何情况下,这可能会给用户带来麻烦,这可能是他们的错。(具体来说:在不应该使用字典的地方使用字典,或者在应该使用引用相等性的视图模型类型的上下文中使用模型类型。)

或者也许我一开始就不应该覆盖 Equals


但是我们希望避免任何用户陷入麻烦,无论是否有错。 - Sheridan
用户只有在不遵守规则时才会遇到麻烦。我没有看到任何有效的替代方案,除了使类型不可变 - 这也是一个有效的选择,但在某些情况下可能会产生其他不希望出现的后果。 - Miral

0
一个简单的方法是将hashCode存储在私有成员中,并在第一次使用时生成它。 如果您的实体不经常更改,并且您不会将两个不同的Equal对象(其中Equals方法返回true)用作字典中的键,则这应该是可以的:
private int? _hashCode;

public override int GetHashCode() {
   if (!_hashCode.HasValue)
      _hashCode = Property1.GetHashCode() ^ Property2.GetHashCode() etc... based on whatever you use in your equals method
   return _hashCode.Value;
}

然而,如果您有对象a和对象b,其中a.Equals(b) == true,并且您使用a作为键在字典中存储条目(dictionary[a] = value)。
如果a不改变,则dictionary[b]将返回value,但是,如果在将条目存储在字典中之后更改a,则dictionary[b]很可能会失败。 唯一的解决方法是在任何键更改时重新哈希字典。


感谢@MartinErnst。我对这种方法的问题在于,Equals方法中使用了所有类属性,并且它们都是可变的。起初,我认为我可以在GetHashCode中使用“Id”和DateCreated属性,因为它们永远不会改变,但后来我意识到,即使在添加新项时,那些属性也是可变的。 - Sheridan
看起来你正在使用的键是实体 - 最简单的方法是将id用作字典中的键,并且在它们分配了id之前不要添加新条目(如果可能的话)。或者,如果实体不是新实体,则可以使用上述方法并仅基于id属性生成哈希码,如果它是新实体(例如id为0),则使用base.GetHashCode()。如果您正在使用新实体作为字典中的键,而该字典超出了数据上下文/会话的生命周期,则需要在实体分配新id时重新计算字典的哈希值。 - Martin Ernst
谢谢您回复我关于这个问题...您能否解释一下您所说的“重新生成字典”的意思?此外,对于绑定到WPF ListBox的集合,是否可能进行此操作? - Sheridan
基本上从字典中删除该项,然后再将其添加回去。 - Martin Ernst

0
如果类中真的没有任何常量可以计算哈希值,那么我会使用比 GUID 更简单的东西。只需在类中(或包装类中)使用一个持久化的随机数即可。

谢谢@Dweeberly。我可以问一下,你认为这比Guid更好的原因是什么? - Sheridan
生成的代码更小、更简单。大多数哈希使用将值与质数进行模运算以获取桶地址。GetHashCode返回32位整数,因此任何大于该值的东西都是过度设计。 - Dweeberly

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接