65.9K
CodeProject 正在变化。 阅读更多。
Home

关于相等性的一项研究

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.22/5 (11投票s)

2007年12月26日

CPOL

6分钟阅读

viewsIcon

37962

downloadIcon

123

理解值类型和引用类型作为相等性测试以及作为集合中的键。

引言

当学习基于 C 的语言时,人们很快就会发现值类型和引用类型之间存在差异。当编写更复杂的应用程序时,我们偶尔需要将引用类型视为值类型以进行相等性比较,这意味着比较两个引用不应返回它们是否是同一实例的 true false,而是返回它们是否包含相同的。当我们将类用作集合(例如字典)中的键,并且我们希望键的来确定集合中是否包含另一个实例的时,按比较引用类型尤其有用。

测试代码

以下说明了如何创建适合按值比较的类。最后,这些类也将用于测试泛型 ListDictionary 集合如何工作。

步骤 1:一个基本类

public class AClass
{
  private readonly int i;

  public int I
  {
    get { return i; }
  }

  private AClass() {}

  public AClass(int i)
  {
    this.i = i;
  }
}

为什么默认构造函数标记为 private?这与应将按值比较的类设置为不可变的做法有关,这将在高级概念部分讨论。可以这样说,当字段被指定为只读且该字段的属性仅提供 getter 时,默认构造函数是无意义的,因为你只能在构造函数中设置字段。

我们将采用上面的类并对其进行修改,以便相等性测试将通过比较值而不是引用来处理。但首先,让我们看看这个类在当前的相等性测试中表现如何

static void CompareClasses()
{
  Console.WriteLine("\r\nCompareClasses:");
  AClass s1 = new AClass(10);
  AClass s2 = new AClass(10);
  Console.WriteLine("AClass.Equals(AClass) ? " + ((s1.Equals(s2)) ? "Yes" : "No"));
  Console.WriteLine("s1 == s2 ? " + ((s1 == s2) ? "Yes" : "No"));
}

此方法返回

No
No

这在意料之中——实例 s1s2 互不相等。

步骤 2:重写 Equals

public class AnEqualsClass
{
  public readonly int i;

  private AnEqualsClass() { }

  public AnEqualsClass(int i)
  {
    this.i = i;
  }

  public override bool Equals(object obj)
  {
    bool ret = false;
    AnEqualsClass s = obj as AnEqualsClass;

    if (s == null)
    {
      ret = false;
    }
    else
    {
      ret = i == s.i;
    }

    return ret;
  }

  /// <summary>
  /// Avoid the compiler warning by implementing this method.
  /// We need this method for Equals to work with generics like Lists and Dictionaries.
  /// </summary>
  public override int GetHashCode()
  {
    // This is very important!
    // We return the hash code of our field, not the base algorithm.
    // The base algorithm returns different values for different instances.
    return i.GetHashCode();
  }
}

上面的代码说明了重写 Equals 方法的最低要求,其中还包括重写 GetHasCode() 方法。
重要提示:如果省略 GetHashCode() 重写,那么我们的类将无法与集合一起使用!

static void CompareEqualsClasses()
{
  Console.WriteLine("\r\nCompareEqualsClasses:");
  AnEqualsClass s1 = new AnEqualsClass(10);
  AnEqualsClass s2 = new AnEqualsClass(10);
  Console.WriteLine("AnEqualsClass.Equals(AnEqualsClass) ? " + 
      ((s1.Equals(s2)) ? "Yes" : "No"));
  Console.WriteLine("s1 == s2 ? " + ((s1 == s2) ? "Yes" : "No"));
}

以上测试返回

Yes
No

这验证了我们现在正在按值比较实例,但 == 运算符仍然按引用比较。

步骤 3:实现 operator== 方法

public class AnOperatorEqualClass
{
  private readonly int i;

  public int I
  {
    get { return i; }
  }

  private AnOperatorEqualClass() { }

  public AnOperatorEqualClass(int i)
  {
    this.i = i;
  }

  public static bool operator ==(AnOperatorEqualClass s1, AnOperatorEqualClass s2)
  {
    bool ret = false;

    if (((object)s1 != null) && ((object)s2 != null))
    {
      ret = s1.i == s2.i;
    }

    return ret;
  }

  /// <summary>
  /// If one is defined, the other is required.
  /// </summary>
  public static bool operator !=(AnOperatorEqualClass s1, AnOperatorEqualClass s2)
  {
    return !(s1 == s2);
  }

  /// <summary>
  /// Also this is required!
  /// </summary>
  public override bool Equals(object obj)
  { 
    bool ret = false;
    AnOperatorEqualClass s = obj as AnOperatorEqualClass;

    if (s==null)
    {
      ret = false;
    }
    else
    {
      ret = i == s.i;
    }

    return ret;
  }

  /// <summary>
  /// Avoid the compiler warning by implementing this method.
  /// </summary>
  public override int GetHashCode()
  {
    // This is very important!
    // We return the hash code of our field, not the base algorithm.
    // The base algorithm returns different values for different instances.
    return i.GetHashCode();
  }
}

在上面的代码中,实现了 operator== 方法,这也要求同时实现 operator!= 方法。事实上,提供 operator== 实现要求重写 Equals() 方法,然后也强烈建议(这是一个强烈的建议)重写 GetHasCode() 方法。

operator== 方法中,我们有以下代码

if (((object)s1 != null) && ((object)s2 != null))

为什么 s1s2 被强制转换为对象?比较 s1 != null 将调用 operator!= 方法,而该方法又会调用 operator== 方法,直到发生堆栈溢出。通过将 s1s2 强制转换为 object,不会调用显式的 operator!= 方法,从而避免了否则会发生的无限递归。

static void CompareOperatorEqualClasses()
{
  Console.WriteLine("\r\nCompareOperatorEqualClasses:");
  AnOperatorEqualClass s1 = new AnOperatorEqualClass(10);
  AnOperatorEqualClass s2 = new AnOperatorEqualClass(10);
  Console.WriteLine("AnOperatorEqualsClass.Equals(AnOperatorEqualsClass) ? " 
         + ((s1.Equals(s2)) ? "Yes" : "No"));
  Console.WriteLine("s1 == s2 ? " + ((s1 == s2) ? "Yes" : "No"));
}

以上测试代码返回

Yes
Yes

这说明我们现在正在使用 Equals()operator== 进行按值比较。

集合

以下代码探讨了这些类作为集合中的键的响应方式。

基本类

static void IndexClasses()
{
  Console.WriteLine("\r\nIndexClasses:");
  List<AClass> list = new List<AClass>();
  AClass s1 = new AClass(10);
  list.Add(s1);
  AClass s2 = new AClass(10);
  Console.WriteLine("List contains s2 : " + list.Contains(s2));
}

此测试返回...

False

...正如预期的那样,实例正在按引用进行比较。

类似地,对于字典

static void DictionaryClasses()
{
  Console.WriteLine("\r\nDictionaryClasses:");
  Dictionary<AClass, int> dict = new Dictionary<AClass, int>();
  AClass s1 = new AClass(10);
  dict[s1] = 1;
  AClass s2 = new AClass(10);
  Console.WriteLine("Dictionary contains s2 : " + dict.ContainsKey(s2));
}

结果是:

False

实现 Equals 的类

static void IndexEqualsClasses()
{
  Console.WriteLine("\r\nIndexEqualsClasses:");
  List<AnEqualsClass> list = new List<AnEqualsClass>();
  AnEqualsClass s1 = new AnEqualsClass(10);
  list.Add(s1);
  AnEqualsClass s2 = new AnEqualsClass(10);
  Console.WriteLine("List contains s2 : " + list.Contains(s2));
}

上面的测试代码,使用仅实现 Equals()GetHashCode() 的类,返回

True

当类用作泛型 Dictionary 中的键时,情况也是如此

static void DictionaryEqualsClasses()
{
  Console.WriteLine("\r\nDictionaryEqualsClasses:");
  Dictionary<AnEqualsClass, int> dict = new Dictionary<AnEqualsClass, int>();
  AnEqualsClass s1 = new AnEqualsClass(10);
  dict[s1] = 1;
  AnEqualsClass s2 = new AnEqualsClass(10);
  Console.WriteLine("Dictionary contains s2 : " + dict.ContainsKey(s2));
}

正确获取 GetHashCode 的重要性

当我们更改 GetHashCode() 方法时会发生什么

public override int GetHashCode()
{
  // This is very important!
  // We return the hash code of our field, not the base algorithm.
  // The base algorithm returns different values for different instances.
  // REMOVED: return i.GetHashCode();  REPLACED WITH:
  return base.GetHashCode();
}

在这里,我们通过调用基方法不正确地实现了 GetHashCode() 方法。虽然列表测试仍然通过,但字典测试不再通过!Dictionary 类正在使用哈希码来优化其搜索,而 List 类只是简单地比较列表中的每个元素。字典首先比较哈希码,由于两个实例的哈希码不相等,Contains() 调用返回 false。这说明了为要作为值进行比较的实例返回正确哈希码的重要性。

高级概念

以下讨论了使用 structclass 的其他主题,以及在将类用于值比较时使其不可变的最佳实践。

为什么不使用结构体?

public struct AStruct
{
  private readonly int i;

  public int I
  {
    get { return i; }
  }

  public AStruct(int i)
  {
    this.i = i;
  }
}

在上面的代码中,这个简单的 struct 将通过我们所有关于相等性的测试。这似乎是为 class 至少实现 Equals()GetHashCode() 的一个简单解决方案。我们为什么要使用类而不是 struct?提出这个问题很重要,因为答案(因此问题)不一定显而易见。正如 Luca Bolognese 在他的博客文章中指出的那样

  • Struct 不能为 nullnull 状态可能具有有价值的含义,当使用 struct 时,这种含义会丢失。
  • 您可能仍然需要实现 ==!= 运算符以提高代码可读性。
  • 如果您实现 ==!= 运算符,您将必须实现 Equals()GetHasCode()
  • Struct 分配在堆栈上,因此当它们作为参数传递时,它们会被复制,这可能会导致大型 struct 的性能问题。
  • Struct 总是有一个将所有字段清零的 public 默认构造函数。您会想要一个 private 默认构造函数,并且 class 的成员应该是不可变的(见下文)。
  • Struct 不能是 abstract。这可能会影响您的面向对象设计。
  • Struct 不能扩展其他 struct。这可能会影响您的面向对象设计。

这些都是在决定使用 struct 还是 class 时需要考虑的所有原因,以及它们对性能、设计和使用的影响。

使你的类不可变

如 MSDN(参见参考文献)所建议,重写 operator== 的类应该是不可变的。只要不可变对象具有相同的值,它们就可以被视为相同。可变对象不应被视为相同。例如,如果对象 A 和对象 B 在时间 T0 的值相等,您可以在某个过程中使用其中任何一个。但是,如果对象 B 在时间 T1 更改了其值,则 A 和 B 不再相等,并且如果该过程使用的是对象 B 而不是对象 A,则可能会产生后果。因此,在本文的示例代码中,字段“i”被标记为“private readonly”,并且属性 I 仅提供 getter 方法。

结论

我希望本文能对按值比较类的实际复杂问题有所启发。当值相同的实例用作集合键以及更常见的相等性测试时,这是一种有用的技术。

参考文献

历史

  • 2007 年 12 月 26 日:初始发布
© . All rights reserved.