关于相等性的一项研究






4.22/5 (11投票s)
理解值类型和引用类型作为相等性测试以及作为集合中的键。
引言
当学习基于 C 的语言时,人们很快就会发现值类型和引用类型之间存在差异。当编写更复杂的应用程序时,我们偶尔需要将引用类型视为值类型以进行相等性比较,这意味着比较两个引用不应返回它们是否是同一实例的 true
或 false
,而是返回它们是否包含相同的值。当我们将类用作集合(例如字典)中的键,并且我们希望键的值来确定集合中是否包含另一个实例的值时,按值比较引用类型尤其有用。
测试代码
以下说明了如何创建适合按值比较的类。最后,这些类也将用于测试泛型 List
和 Dictionary
集合如何工作。
步骤 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
这在意料之中——实例 s1
和 s2
互不相等。
步骤 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))
为什么 s1
和 s2
被强制转换为对象?比较 s1 != null
将调用 operator!=
方法,而该方法又会调用 operator==
方法,直到发生堆栈溢出。通过将 s1
和 s2
强制转换为 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
。这说明了为要作为值进行比较的实例返回正确哈希码的重要性。
高级概念
以下讨论了使用 struct
与 class
的其他主题,以及在将类用于值比较时使其不可变的最佳实践。
为什么不使用结构体?
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
不能为null
。null
状态可能具有有价值的含义,当使用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 方法。
结论
我希望本文能对按值比较类的实际复杂问题有所启发。当值相同的实例用作集合键以及更常见的相等性测试时,这是一种有用的技术。
参考文献
- Luca Bolognese 对不可变值对象的讨论非常有用
- MSDN:重写
Equals()
和operator==
的指南 - C# 类实现的通用指南:一篇出色的专题论文
历史
- 2007 年 12 月 26 日:初始发布