为什么 C# 不实现 GetHashCode for Collections?

2022-09-02 00:51:25

我正在将一些东西从Java移植到C#。在 Java 中,a 的取决于其中的项。在C#中,我总是从...hashcodeArrayListList

这是为什么呢?

对于我的一些对象,哈希码需要不同,因为其 list 属性中的对象使对象不相等。我希望哈希码对于对象的状态始终是唯一的,并且仅在对象相等时才等于另一个哈希码。我错了吗?


答案 1

为了正常工作,哈希码必须是不可变的 - 对象的哈希代码必须永远不会改变。

如果对象的哈希码确实发生了变化,则包含该对象的任何字典都将停止工作。

由于集合不是不可变的,因此它们无法实现 。
相反,它们继承了默认值 ,该默认值为对象的每个实例返回一个(希望)唯一的值。(通常基于内存地址)GetHashCodeGetHashCode


答案 2

哈希码必须依赖于所使用的相等性的定义,以便如果那么(但不一定是逆的; 不需要 )。A == BA.GetHashCode() == B.GetHashCode()A.GetHashCode() == B.GetHashCode()A == B

默认情况下,值类型的相等定义基于其值,而引用类型的相等定义基于其标识(即,默认情况下,引用类型的实例仅等于其自身),因此值类型的默认哈希码取决于它所包含的字段的值*,对于引用类型,它依赖于标识。事实上,由于我们理想地希望不相等对象的哈希码不同,特别是在低阶位中(最有可能影响重新哈希的值),我们通常希望两个等效但不相等的对象具有不同的哈希值。

由于对象将保持等于自身,因此还应该清楚,即使对象发生突变,此默认实现也将继续具有相同的值(即使对于可变对象,标识也不会改变)。GetHashCode()

现在,在某些情况下,引用类型(或值类型)重新定义了相等性。这方面的一个例子是字符串,例如 .虽然有两个不同的字符串比较实例,但它们被认为是相等的。在这种情况下,必须重写该值,以便该值与定义相等性所依据的状态(在本例中为包含的字符序列)相关。"ABC" == "AB" + "C"GetHashCode()

虽然对同样不可变的类型执行此操作更为常见,但由于各种原因,GetHashCode() 不依赖于不可变性。相反,在面对可变性时必须保持一致 - 更改我们在确定哈希时使用的值,并且哈希必须相应地更改。但请注意,如果我们使用哈希将此可变对象用作结构的键,这是一个问题,因为改变对象会更改其应存储的位置,而不会将其移动到该位置(对于集合中对象的位置取决于其值的任何其他情况也是如此 - 例如,如果我们对列表进行排序,然后改变其中一个项目在列表中,列表不再排序)。但是,这并不意味着我们必须只在字典和哈希集中使用不可变对象。相反,这意味着我们绝不能改变处于这种结构中的对象,并且使其不可变是保证这一点的明确方法。GetHashCode()

事实上,在很多情况下,在这样的结构中存储可变对象是可取的,只要我们在此期间不改变它们,这就可以了。由于我们没有不可变性带来的保证,因此我们希望以另一种方式提供它(例如,在集合中花费很短的时间并且只能从一个线程访问)。

因此,键值的不可变性是有可能但通常是一个想法的情况之一。然而,对于定义哈希码算法的人来说,他们并不认为任何这样的情况总是一个坏主意(他们甚至不知道突变发生在对象存储在这样的结构中时);这是为了让他们实现在对象的当前状态上定义的哈希码,无论在给定的点调用它是否好。因此,例如,哈希码不应该在可变对象上被记忆,除非在每次突变上清除记忆。(无论如何,记忆哈希值通常是一种浪费,因为重复命中相同对象哈希码的结构将有自己的记忆)。

现在,在手头的案例中,ArrayList在基于身份的平等的默认情况下运行,例如:

ArrayList a = new ArrayList();
ArrayList b = new ArrayList();
for(int i = 0; i != 10; ++i)
{
  a.Add(i);
  b.Add(i);
}
return a == b;//returns false

现在,这实际上是一件好事。为什么?那么,你怎么知道在上面我们要考虑a等于b?我们可能会这样做,但在其他情况下也有很多很好的理由不这样做。

更重要的是,重新定义从基于身份到基于价值的平等,比从基于价值到基于身份的平等要容易得多。最后,对于许多对象,存在多个基于值的相等定义(经典情况是对字符串相等的不同观点),因此甚至没有一个唯一有效的定义。例如:

ArrayList c = new ArrayList();
for(short i = 0; i != 10; ++i)
{
  c.Add(i);
}

如果我们考虑上面,我们应该考虑aslo吗?答案取决于我们在使用的平等定义中关心什么,所以框架无法知道所有情况的正确答案是什么,因为所有情况都不一致。a == ba == c

现在,如果我们确实关心在给定的情况下基于价值的平等,我们有两个非常简单的选择。首先是子类化和覆盖相等性:

public class ValueEqualList : ArrayList, IEquatable<ValueEqualList>
{
  /*.. most methods left out ..*/
  public Equals(ValueEqualList other)//optional but a good idea almost always when we redefine equality
  {
    if(other == null)
      return false;
    if(ReferenceEquals(this, other))//identity still entails equality, so this is a good shortcut
      return true;
    if(Count != other.Count)
      return false;
    for(int i = 0; i != Count; ++i)
      if(this[i] != other[i])
        return false;
    return true;
  }
  public override bool Equals(object other)
  {
    return Equals(other as ValueEqualList);
  }
  public override int GetHashCode()
  {
    int res = 0x2D2816FE;
    foreach(var item in this)
    {
        res = res * 31 + (item == null ? 0 : item.GetHashCode());
    }
    return res;
  }
}

这假设我们总是希望以这种方式处理此类列表。我们还可以为给定的情况实现 IEqualityComparer:

public class ArrayListEqComp : IEqualityComparer<ArrayList>
{//we might also implement the non-generic IEqualityComparer, omitted for brevity
  public bool Equals(ArrayList x, ArrayList y)
  {
    if(ReferenceEquals(x, y))
      return true;
    if(x == null || y == null || x.Count != y.Count)
      return false;
    for(int i = 0; i != x.Count; ++i)
      if(x[i] != y[i])
        return false;
    return true;
  }
  public int GetHashCode(ArrayList obj)
  {
    int res = 0x2D2816FE;
    foreach(var item in obj)
    {
        res = res * 31 + (item == null ? 0 : item.GetHashCode());
    }
    return res;
  }
}

综上所述:

  1. 引用类型的默认相等定义仅取决于标识。
  2. 大多数时候,我们想要这样。
  3. 当定义类的人决定这不是我们想要的时,他们可以覆盖这种行为。
  4. 当使用该类的人再次想要一个不同的平等定义时,他们可以使用并且他们的字典,哈希映射,哈希集等使用他们的平等概念。IEqualityComparer<T>IEqualityComparer
  5. 在对象是基于哈希的结构的关键时对其进行突变是灾难性的。不变性可以用来确保这种情况不会发生,但不是强制性的,也不是总是可取的。

总而言之,该框架为我们提供了很好的默认值和详细的覆盖可能性。

*结构中的小数点有一个错误,因为在某些情况下,当结构是安全的而不是其他时候,有一个快捷方式与捔像一起使用,但是当包含小数的结构是快捷方式不安全时的一种情况,它被错误地标识为安全的情况。