何时应该在.NET类中重写Equals()方法?何时不应该?

16

《C#编程指南》中的重载Equals()和操作符==的指导方针部分表示:

不建议在非不可变类型中重写操作符==。

较新的.NET Framework 4文档实现Equals和相等运算符(==)的指导方针省略了该声明,尽管社区内容中的一篇帖子重复了这一说法并引用了旧文档。

似乎至少对于一些简单的可变类(例如),重写Equals()是合理的。

public class ImaginaryNumber
{
    public double RealPart { get; set; }
    public double ImaginaryPart { get; set; }
}

在数学中,两个虚数具有相同的实部和虚部时,在测试等式的时间点上它们实际上是相等的。如果使用具有相同RealPart和ImaginaryPart的单独对象进行Equals()未被覆盖,则断言它们“不相等”是不正确的。
另一方面,如果重写了Equals(),则也应该重写GetHashCode()。如果一个重写了Equals()和GetHashCode()的虚数被放置在HashSet中,并且可变实例改变了其值,那么这个对象将不再被找到。
MSDN是否有错误地删除了关于不覆盖非不变类型的Equals()和operator==的指南?
对于“在现实世界”中所有属性的等价性意味着对象本身相等的可变类型(例如ImaginaryNumber),覆盖Equals()是否合理?
如果是合理的,如何在对象实例参与依赖于GetHashCode()不改变的HashSet或其他内容时处理潜在的可变性?
更新

我刚在MSDN上看到了这个内容

通常,当预期将该类型的对象添加到某种集合中或其主要目的是存储一组字段或属性时,您会实现值相等性。您可以基于类型中的所有字段和属性进行值相等性的定义,也可以基于子集进行定义。但无论哪种情况,在类和结构体中,您的实现都应遵循等价性的五个保证:


5
我认为ImaginaryNumber作为一个可变类型是不合理的。 - Damien_The_Unbeliever
3
实际上,你应该将ImaginaryNumber实现为一个不可变的值类型(结构体)。 - Joe
3
intlongdouble等是不可变的(immutable)。一个5就是一个5,无法改变它的本质。 - Oded
1
@EricJ。虽然变量i已经改变,但Int32类型的0仍然是0 - dlev
1
一个ComplexNumber为什么应该是不可变的例子:考虑“ComplexNumber a,b,c;”。对于数字的期望是a,b,c是独立的。“a = new ComplexNumber(1, 2); b = a;”然后“a.RealPart = -1;”这也会改变b,除非将ComplexNumber定义为结构体。或者使RealPart只读,禁止设置a.RealPart。然后通过“a = new ComplexNumber(-1,a.ImaginaryPart)”来更改a。可以创建一个方便的方法public ComplexNumber SetReal(double value) {return new ComplexNumber(value,this.ImaginaryPart);}。调用:“a = a.SetReal(-1);” - ToolmakerSteve
显示剩余7条评论
4个回答

16
我意识到我希望Equals在不同的上下文中有两个不同的含义。在权衡了这里和这里的输入后,我已经为我的特定情况确定了以下内容:
我没有重写Equals()和GetHashCode(),而是保留了Equals()对于类来说意味着标识相等性,而对于结构体来说意味着值相等性的共同但并非普遍存在的约定。这个决定的最大推动力是集合中对象的行为(Dictionary<T,U>, HashSet<T>等),如果我偏离这个约定。
这个决定让我仍然缺少值相等的概念(如在MSDN上讨论的)。
当你定义一个类或结构体时,你需要决定是否为该类型创建自定义的值相等(或等价)定义。通常情况下,当期望将该类型的对象添加到某种集合中,或者其主要目的是存储一组字段或属性时,就会实现值相等。在单元测试中,希望使用值相等(或我所称的“等价性”)的典型案例是。

public class A
{
    int P1 { get; set; }
    int P2 { get; set; }
}

[TestMethod()]
public void ATest()
{
    A expected = new A() {42, 99};
    A actual = SomeMethodThatReturnsAnA();
    Assert.AreEqual(expected, actual);
}

测试将失败,因为Equals()测试引用相等性。
单元测试确实可以修改为逐个测试每个属性,但这会将等价概念从类中移出到类的测试代码中。
为了将该知识封装在类中,并提供一个一致的测试等价框架,我定义了一个接口,我的对象实现该接口。
public interface IEquivalence<T>
{
    bool IsEquivalentTo(T other);
}

实现通常遵循以下模式:
public bool IsEquivalentTo(A other)
{
    if (object.ReferenceEquals(this, other)) return true;

    if (other == null) return false;

    bool baseEquivalent = base.IsEquivalentTo((SBase)other);

    return (baseEquivalent && this.P1 == other.P1 && this.P2 == other.P2);
}

当然,如果我有足够的类和属性,我可以编写一个辅助程序,通过反射构建表达式树来实现IsEquivalentTo()
最后,我实现了一个扩展方法,用于测试两个IEnumerable<T>的等价性:
static public bool IsEquivalentTo<T>
    (this IEnumerable<T> first, IEnumerable<T> second)

如果 T 实现了 IEquivalence<T> 接口,那么使用该接口进行比较元素的序列,否则使用 Equals() 进行比较。允许回退到 Equals() 可以使其能够与 ObservableCollection<string> 一起使用,而不仅仅是我的业务对象。
现在,我的单元测试中的断言是:
Assert.IsTrue(expected.IsEquivalentTo(actual));

这比考虑哈希码生成、担心冲突和副作用等等要懒得多。而这是一件好事 :) - hubson bropa
2
我希望.NET定义了两组虚拟相等性测试(或包含一个参数来指示应该测试哪种类型的相等性)。通过持有对永远不会被暴露给可能会改变它的任何东西的对象实例的可变类型引用来封装数据是一种非常常见的模式。持有引用的对象应该提供在该场景下有意义的相等性测试。 - supercat
@supercat 和 EricJ 我同意。在 .Net 中似乎缺少了这个功能。 - ToolmakerSteve
@ToolmakerSteve:我花了一段时间才想出一个好的定义,适用于所有对象的特征,来进行两个相等性测试。最终我确定的是,X.Identical(Y) 应该意味着将对 X 的某些引用替换为对 Y 的引用不应该产生语义效果,而 X.EquivState(Y) 则意味着同时交换对 XY 的所有引用(反之亦然)不应该产生语义效果,除非可能更改 X.IdentityHash()Y.IdentityHash() 的值。 - supercat
我发现对象可以很好地分为两类:实体(可变的,引用相等性)和值(不可变的,结构相等性,实现IEquatable)。你为什么需要一个具有结构相等性的可变类型?对我来说这是一种异味。 - Asik

12

MSDN文档关于不给可变类型重载==的说法是错误的。实现相等语义对于可变类型来说绝对没有问题。即使两个对象在未来会改变,它们现在也可以相等。

通常,当可变类型用作哈希表中的键或允许可变成员参与GetHashCode函数时,围绕可变类型和相等性的危险就会出现。


由于在C#中,==实际上代表了两个不同的运算符,因此是否应该重载任何类类型是有争议的,因为它可能并不总是清楚表示引用相等性测试或调用==重载。我进一步建议,适用于所有对象的唯一相等概念是相等关系,只有当没有任何方法可以使不同类对象不同才能等价。虽然某些“可变”类型符合这一标准,但可以独立进行突变的事物则不符合。 - supercat
关于“现在即使它们将来会改变,两个项目也可以相等”的问题。问题在于,如果您为可变类型更改==,则没有人可以安全地将该类型的对象用作字典键。这是一个大问题。即使是我自己编写的类型,也曾经让我遇到过这个问题,所以我应该知道得更清楚。这是微软的一个严重设计缺陷。对于可变类型,EricJ和supercat在其他答案中讨论的唯一安全答案是保留Equals,并定义自己的相等函数,使用不同的名称。这很烦人,因为现在您必须在比较对象时使用不同的函数。 - ToolmakerSteve

7

请查看 GetHashCode 的准则和规则,作者是Eric Lippert

规则:当对象被包含在依赖于哈希码保持稳定的数据结构中时,由 GetHashCode 返回的整数不得更改。

尽管危险,但可以创建一个对象,其哈希码值可以随对象字段的变化而变化。


1
我之前实际上已经读过了,对于那些还没有阅读的人来说,它值得一读。实际上,我认为这行代码最好地回答了我的问题:“如果你有这样一个对象,并且将其放入哈希表中,那么改变对象的代码和维护哈希表的代码需要有一些约定俗成的协议,以确保该对象在哈希表中不被改变。这个协议看起来由您决定。” - Eric J.

0

我不理解你对于GetHashCodeHashSet方面的担忧。 GetHashCode只是返回一个数字,帮助HashSet内部存储和查找值。如果对象的哈希码发生变化,则该对象不会从HashSet中删除,它只是不会被存储在最优位置。

编辑

感谢@Erik J的指点。

HashSet<T>是一种性能集合,为了实现这种性能,它完全依赖于GetHashCode在集合的生命周期内保持恒定。如果您想要这种性能,则需要遵循这些规则。如果您无法这样做,则必须切换到其他内容,如List<T>


如果在将对象添加到HashSet后更改其哈希码,则myHashSet.Contains(myMutatedObject)将返回false。但是,HashSet本身仍然包含(现在已变异的)对象。因此,允许哈希值更改会破坏HashSet合同(Contains会出错)。 - Eric J.

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