如何比较两个引用类型的实例?最佳实践是什么?

48

最近我遇到了这个问题,直到现在我一直很高兴地重载等号运算符(==)和/或Equals方法,以便查看两个引用类型是否实际包含相同的数据(即两个不同的实例看起来相同)。

自从我更多地使用自动化测试(比较引用/期望的数据与返回的数据)以来,我已经更多地使用了它。

在查看MSDN编码标准指南中的一些内容时,我发现了一篇建议不要这样做的文章。现在我明白了这篇文章为什么这么说(因为它们不是同一个实例),但它并没有回答以下问题:

  1. 比较两个引用类型的最佳方法是什么?
  2. 我们应该实现IComparable吗?(我也看到有人提到这应该只保留给值类型)
  3. 有没有我不知道的一些接口?
  4. 我们应该自己编写吗?!

非常感谢 ^_^

更新

看起来我误读了一些文档(已经是漫长的一天了),覆盖Equals可能是正确的方法。

如果您正在实现引用类型,应该考虑重写引用类型上的Equals方法,如果您的类型看起来像基本类型,例如Point、String、BigNumber等,则应该这样做。大多数引用类型不应该重载相等运算符,即使它们重载了Equals。然而,如果您正在实现一个旨在具有值语义的引用类型,例如复数类型,则应该重载相等运算符。


4
大多数参考类型不应重载相等运算符,即使它们重写了 Equals 方法。嗯,我觉得有点奇怪。所以 a.Equals(b) 可能是 true,但 a==b 可能是 false。如果我想知道引用是否相等(实际上很少需要),我会使用 .ReferenceEquals(a,b)。我希望 a==b 返回与 a.Equals(b) 相同的结果。这不是最佳实践吗? - Flipster
@FlipScript:重载==运算符的一个主要问题在于它实际上是两个运算符;当它与存在重载的类型一起使用时,它使用重载;否则,如果操作数是引用类型,则进行引用相等性检查。由于==是静态绑定而不是虚拟绑定,即使在使用泛型时,这种行为也可能导致意外结果。在vb.net中,为可重写的相等性和引用相等性使用单独的运算符,避免了这种歧义。 - supercat
9个回答

27

在.NET中正确、高效地实现相等性并且避免代码重复是很困难的。特别是对于具有值语义的引用类型(即将等价视为相等的不可变类型),您应该实现System.IEquatable<T>接口,并且您应该实现所有不同的操作(EqualsGetHashCode==!=)。

以下是一个实现值相等的类作为示例:

class Point : IEquatable<Point> {
    public int X { get; }
    public int Y { get; }

    public Point(int x = 0, int y = 0) { X = x; Y = y; }

    public bool Equals(Point other) {
        if (other is null) return false;
        <b>return X.Equals(other.X) && Y.Equals(other.Y);</b>
    }

    public override bool Equals(object obj) => Equals(obj as Point);

    public static bool operator ==(Point lhs, Point rhs) => object.Equals(lhs, rhs);

    public static bool operator !=(Point lhs, Point rhs) => ! (lhs == rhs);

    public override int GetHashCode() => <b>X.GetHashCode() ^ Y.GetHashCode();</b>
}

以上代码中唯一可移动的部分是加粗的部分:在Equals(Point other)的第二行和GetHashCode()方法中。其他代码应保持不变。
对于不表示不可变值的参考类,请不要实现运算符==!=。而是使用它们的默认含义,即比较对象标识。
此代码有意地将派生类类型的对象视为相等。通常,这可能是不希望的,因为基类和派生类之间的相等性没有明确定义。不幸的是,.NET和编码指南在这里并不十分清晰。Resharper创建的代码(在另一个答案中发布)在这种情况下容易出现不希望的行为,因为Equals(object x)Equals(SecurableResourcePermission x)会以不同的方式处理这种情况。
为了改变这种行为,在上面的强类型Equals方法中必须插入一个额外的类型检查:
public bool Equals(Point other) {
    if (other is null) return false;
    if (other.GetType() != GetType()) return false;
    <b>return X.Equals(other.X) && Y.Equals(other.Y);</b>
}

2
对于类,为什么要重写等式和不等式运算符来执行引用比较,而这个功能默认由System.Object基类提供? - Zach Burlingame
1
最佳实践是始终使 Equals== 执行等效操作。这在我的代码片段中得到了体现。显然,只有在这样的语义有意义时才使用它。但是,始终使 Equals== 以一致的方式执行。如果它们不这样做,那将是绝对的可用性恐怖。 - Konrad Rudolph
1
你为什么认为Equals和==应该保持一致?这与MSDN文档所述相反,也会导致==不再表示引用相等。这会产生类似的可用性问题,因为.NET统一提供了此行为。 - Zach Burlingame
1
就此而言,我完全能够理解你的观点,尤其是我自己也来自C++世界。然而,由于MSDN文档/指南明确建议不要做你正在做的事情,我正在寻找一个支持你立场的有力论据。也许这值得成为一个单独的问题。 - Zach Burlingame
1
@nawfal,我自己没有代码了,我需要访问我的网络空间... :( - Konrad Rudolph
显示剩余20条评论

23

看起来你正在使用C#编码,它有一个名为Equals的方法,你的类应该实现它,如果你想使用其他度量标准比较两个对象,而不是“这两个指针(因为对象句柄就是指针)是否指向同一内存地址?”。

我从这里提取了一些示例代码:

class TwoDPoint : System.Object
{
    public readonly int x, y;

    public TwoDPoint(int x, int y)  //constructor
    {
        this.x = x;
        this.y = y;
    }

    public override bool Equals(System.Object obj)
    {
        // If parameter is null return false.
        if (obj == null)
        {
            return false;
        }

        // If parameter cannot be cast to Point return false.
        TwoDPoint p = obj as TwoDPoint;
        if ((System.Object)p == null)
        {
            return false;
        }

        // Return true if the fields match:
        return (x == p.x) && (y == p.y);
    }

    public bool Equals(TwoDPoint p)
    {
        // If parameter is null return false:
        if ((object)p == null)
        {
            return false;
        }

        // Return true if the fields match:
        return (x == p.x) && (y == p.y);
    }

    public override int GetHashCode()
    {
        return x ^ y;
    }
}

Java有非常相似的机制。 equals() 方法是Object类的一部分,如果您想要这种类型的功能,则可以重载您的类。

对于对象来说,重载'=='运算符不是一个好主意的原因是,通常情况下,您仍然希望能够进行“这些是否为同一指针”的比较。通常,这些比较被依赖于,例如,在不允许重复项的列表中插入元素时,并且如果在非标准方式中重载此操作符,则可能导致某些框架出现问题。


好的回答,谢谢。我很高兴你添加了关于为什么不要重载等号运算符的部分。 - Rob Cooper
4
这实际上是 C# 的一个弱点。只要实现者遵循指南,这不是问题,因为 == 的语义不会因引用相等而改变。不过,在关键情况下,我仍然会使用 object.ReferenceEquals 在 C# 中(VB 使用的是 Is)。 - Konrad Rudolph
1
你不应该在两个地方编写相等逻辑。不确定微软是如何出错的。 - nawfal

17

以下是在实现IEquatable时需要做的事情以及来自各种MSDN文档页面的理由总结。


概述

  • 当需要测试值相等性(例如在集合中使用对象时)时,您应该为您的类实现IEquatable接口,重写Object.Equals和GetHashCode方法。
  • 当需要测试引用相等性时,您应该使用operator==、operator!=和Object.ReferenceEquals
  • 仅应为ValueTypes和不可变引用类型重写operator==和operator!=。

理由

IEquatable

System.IEquatable接口用于比较两个对象实例是否相等。这些对象是基于类中的逻辑进行比较的。比较结果为布尔值,指示对象是否不同。这与System.IComparable接口形成对比,后者返回一个整数,指示对象值如何不同。

IEquatable接口声明必须覆盖的两个方法。Equals方法包含执行实际比较并返回true(如果对象值相等)或false(如果它们不相等)的实现。GetHashCode方法应返回一个唯一的哈希值,可用于唯一标识包含不同值的相同对象。所使用的散列算法类型是特定于实现的。

IEquatable.Equals 方法

  • 您应该为您的对象实现IEquatable,以处理它们将存储在数组或泛型集合中的可能性。
  • 如果您实现IEquatable,则还应该覆盖Object.Equals(Object)和GetHashCode的基类实现,以使其行为与IEquatable.Equals方法一致。

覆盖 Equals() 和 Operator == 的准则(C# 编程指南)

  • x.Equals(x) 返回 true。
  • x.Equals(y) 返回与 y.Equals(x) 相同的值
  • 如果 (x.Equals(y) && y.Equals(z)) 返回 true,则 x.Equals(z) 也返回 true。
  • 连续调用 x.Equals(y) 只要 x 和 y 引用的对象未被修改,就会返回相同的值。
  • x.Equals(null) 返回 false(仅适用于非可空值类型。有关更多信息,请参见 Nullable Types (C# Programming Guide)。)
  • 新的 Equals 实现不应抛出异常。
  • 建议任何重写 Equals 的类也重写 Object.GetHashCode。
  • 建议除了实现 Equals(object) 外,任何类也为其自己的类型实现 Equals(type),以增强性能。

默认情况下,运算符 == 通过确定两个引用是否指示同一对象来测试引用相等性。 因此,引用类型不必实现运算符 == 即可获得此功能。当类型是不可变的时,即实例中包含的数据无法更改时,重载运算符 == 以比较值相等性而不是引用相等性可以很有用,因为作为不可变对象,只要它们具有相同的值,它们就可以被视为相同。 在非不可变类型中覆盖运算符 == 不是一个好主意。

  • 重载运算符 == 的实现不应抛出异常。
  • 任何重载运算符 == 的类型也应该重载运算符 !=。

== 运算符 (C# 参考)

  • 对于预定义的值类型,等号运算符(==)返回 true,如果其操作数的值相等,则返回 false。
  • 对于除字符串以外的引用类型,== 返回 true,如果它的两个操作数引用同一个对象。
  • 对于字符串类型,== 比较字符串的值。
  • 在使用 == 比较测试 null 时,请确保使用基本 object 类的运算符。如果不这样做,将导致无限递归,从而导致 stackoverflow。

Object.Equals 方法 (Object)

如果您的编程语言支持运算符重载,并且选择为给定类型重载等号运算符,则该类型必须重写Equals方法。这种实现Equals方法的方式必须返回与等号运算符相同的结果。
以下是实现值类型的指南: - 考虑重写Equals以获得比ValueType上默认实现提供的更高的性能。 - 如果您重写了Equals并且语言支持运算符重载,则必须为您的值类型重载等号运算符。
以下是实现引用类型的指南: - 如果类型的语义基于该类型表示某些值,则应考虑在引用类型上重写Equals。 - 大多数引用类型不能重载等号运算符,即使它们重写了Equals。但是,如果您正在实现一个旨在具有值语义的引用类型,例如复数类型,则必须重载等号运算符。

额外注意事项


1
Equals(Object)Equals(OwnType)使用相同的名称可能是不幸的,因为在许多情况下,由于隐式类型转换,Equals(OwnType)==运算符都不能定义等价关系。如果我设计了.NET,Object方法将被命名为EquivalentTo,并且期望覆盖使用更严格的等价标准。例如,我会指定1.0m.EquivalentTo(1.00m)应该为false,但1.0m.Equals(1.00m)1.0m == 1.00m应该为true,因为这些值在数值上是相等的,尽管它们不是等效的 - supercat

3
那篇文章只是建议不要覆盖等于运算符(对于引用类型),而不是不要覆盖 Equals。如果相等性检查的意义超出了引用检查,您应该在对象(引用或值)内重写 Equals。如果您想要一个接口,您也可以实现 IEquatable(由通用集合使用)。但是,如果您实现 IEquatable,则还应该覆盖 equals,因为 IEquatable 备注部分指出:
如果您实现 IEquatable,则还应该覆盖基类实现 Object.Equals(Object) 和 GetHashCode,以使它们的行为与 IEquatable.Equals 方法一致。如果您确实覆盖 Object.Equals(Object),则在调用类的静态 Equals(System.Object, System.Object) 方法时也会调用您覆盖的实现。这确保了所有调用 Equals 方法返回一致的结果。
关于是否应该实现 Equals 和/或等于运算符:
来自《实现 Equals 方法》。
大多数引用类型不应该重载相等运算符,即使它们重写了Equals方法。
来自实现Equals和相等运算符(==)的指南 每当你实现相等运算符(==)时都应该重写Equals方法,并让它们做同样的事情。
这仅表示您需要在实现相等运算符时重写Equals方法。它没有说当您重写Equals时也需要重载相等运算符。

2

对于那些需要进行特定比较的复杂对象,实现IComparable接口并在Compare方法中定义比较是一个不错的实现方式。

例如,我们有“车辆”对象,其中唯一的区别可能是注册号码,我们使用这个来进行比较,以确保测试中返回的预期值是我们想要的。


感谢您,Paul。虽然我认为在这种情况下它可能会过度,但已经注意到了IComparable接口,因为我只想检查相等性。 - Rob Cooper

1

微软似乎改变了他们的态度,或者至少有关于不过载等号运算符的冲突信息。根据这篇微软文章,标题为如何定义类型的值相等:

"即使类没有重载它们,也可以使用 == 和 != 运算符。然而,默认行为是执行引用相等性检查。在一个类中,如果你重载了 Equals 方法,你应该重载 == 和 != 运算符,但这并不是必需的。"

根据 Eric Lippert 在他对我提出的问题最小代码中的C#相等的回答中所说:

"你会遇到的危险是,你会得到一个默认情况下进行引用相等性比较的 == 运算符。你可能会很容易地陷入这样一种情况:一个重载了 Equals 方法的类执行值相等性比较,而 == 执行引用相等性比较,然后你意外地在值相等但不是引用相等的对象上使用引用相等性比较。这是一种容易出错且难以通过人工代码审查发现的做法。

几年前,我曾经参与开发一个静态分析算法,用于统计检测这种情况,我们发现在所有的代码库中,每百万行代码中会有大约两个实例存在缺陷。而如果只考虑那些已经覆盖了Equals方法的代码库,缺陷率显然要高得多!
此外,需要考虑成本和风险。如果你已经实现了IComparable接口,那么编写所有运算符只需要一行简单的代码,不会有错误,也不会被修改。这是你将要编写的最便宜的代码。如果让你在编写和测试数十个微小方法的固定成本和发现并修复难以察觉的使用引用相等性而不是值相等性的bug的未知成本之间进行选择,我知道我会选择哪一个。
.NET Framework永远不会使用==或!=与任何你编写的类型。但是,危险在于如果其他人这样做会发生什么。因此,如果这个类是为第三方编写的,那么我将始终提供==和!=运算符。如果这个类仅用于内部组使用,我仍然可能实现==和!=运算符。
我只会实现 <、<=、> 和 >= 运算符,如果已经实现了 IComparable 接口。只有当类型需要支持排序(例如在排序或使用 SortedSet 等有序泛型容器时)时,才应该实现 IComparable 接口。
如果团队或公司有不实现 == 和 != 运算符的政策 - 那么我当然会遵循该政策。如果存在这样的政策,则最好使用 Q/A 代码分析工具来强制执行该政策,并在使用引用类型时标记任何出现 == 和 != 运算符的情况。

1

我倾向于使用Resharper自动创建的内容。例如,它为我的某个引用类型自动生成了以下内容:

public override bool Equals(object obj)
{
    if (ReferenceEquals(null, obj)) return false;
    if (ReferenceEquals(this, obj)) return true;
    return obj.GetType() == typeof(SecurableResourcePermission) && Equals((SecurableResourcePermission)obj);
}

public bool Equals(SecurableResourcePermission obj)
{
    if (ReferenceEquals(null, obj)) return false;
    if (ReferenceEquals(this, obj)) return true;
    return obj.ResourceUid == ResourceUid && Equals(obj.ActionCode, ActionCode) && Equals(obj.AllowDeny, AllowDeny);
}

public override int GetHashCode()
{
    unchecked
    {
        int result = (int)ResourceUid;
        result = (result * 397) ^ (ActionCode != null ? ActionCode.GetHashCode() : 0);
        result = (result * 397) ^ AllowDeny.GetHashCode();
        return result;
    }
}

如果您想覆盖==并仍然进行引用检查,您仍然可以使用Object.ReferenceEquals


你如何让ReSharper自动完成那些东西? - Svish

0

以上所有答案都没有考虑到多态性,通常你希望派生引用使用派生的 Equals 方法,即使是通过基础引用进行比较。请在此处查看有关平等和多态性的问题/讨论/答案 - Equality and polymorphism


0

我认为在.NET的设计中,即使是检查对象是否相等这样简单的事情也有点棘手。

对于结构体

1)实现IEquatable<T>。它可以显著提高性能。

2)由于你现在有了自己的Equals,所以要重写GetHashCode,并且为了与各种相等性检查保持一致,还要重写object.Equals

3)不需要过分地重载==!=运算符,因为如果你无意中用==!=将一个结构体与另一个结构体相等,编译器会发出警告,但最好还是这样做,以便与Equals方法保持一致。

public struct Entity : IEquatable<Entity>
{
    public bool Equals(Entity other)
    {
        throw new NotImplementedException("Your equality check here...");
    }

    public override bool Equals(object obj)
    {
        if (obj == null || !(obj is Entity))
            return false;

        return Equals((Entity)obj);
    }

    public static bool operator ==(Entity e1, Entity e2)
    {
        return e1.Equals(e2);
    }

    public static bool operator !=(Entity e1, Entity e2)
    {
        return !(e1 == e2);
    }

    public override int GetHashCode()
    {
        throw new NotImplementedException("Your lightweight hashing algorithm, consistent with Equals method, here...");
    }
}

关于类

来自微软:

大多数引用类型不应该重载等号运算符,即使它们重写了 Equals 方法。

对我来说,==感觉像值相等,更像是Equals方法的语法糖。写a == b比写a.Equals(b)更直观。我们很少需要检查引用相等性,在处理物理对象的逻辑表示的抽象层面上,这不是我们需要检查的。我认为为==Equals赋予不同的语义实际上会让人困惑。我认为一开始应该将==赋予值相等的语义,而将Equals赋予引用相等(或更好的名称,如IsSameAs)的语义。我不想在这里严格遵循微软的准则,不仅因为它对我来说不自然,而且因为重载==不会造成太大的伤害。这与不覆盖非泛型EqualsGetHashCode不同,后者可能会反咬一口,因为框架不会在任何地方使用==,只有当我们自己使用它时才会使用。不重载==!=唯一真正的好处就是与整个框架的设计一致,而我对此没有任何控制。这确实是一件大事,所以遗憾地我会坚持这一点

关于引用语义(可变对象)

1)重写EqualsGetHashCode方法。

2)实现IEquatable<T>接口不是必须的,但如果您有一个会很好。

public class Entity : IEquatable<Entity>
{
    public bool Equals(Entity other)
    {
        if (ReferenceEquals(this, other))
            return true;

        if (ReferenceEquals(null, other))
            return false;

        //if your below implementation will involve objects of derived classes, then do a 
        //GetType == other.GetType comparison
        throw new NotImplementedException("Your equality check here...");
    }

    public override bool Equals(object obj)
    {
        return Equals(obj as Entity);
    }

    public override int GetHashCode()
    {
        throw new NotImplementedException("Your lightweight hashing algorithm, consistent with Equals method, here...");
    }
}

使用值语义(不可变对象)

这是棘手的部分。如果不小心处理,很容易搞砸。

1)重写EqualsGetHashCode

2)重载==!=以匹配Equals确保它适用于null

3)实现IEquatable<T>不是必须的,但如果有一个会很好。

public class Entity : IEquatable<Entity>
{
    public bool Equals(Entity other)
    {
        if (ReferenceEquals(this, other))
            return true;

        if (ReferenceEquals(null, other))
            return false;

        //if your below implementation will involve objects of derived classes, then do a 
        //GetType == other.GetType comparison
        throw new NotImplementedException("Your equality check here...");
    }

    public override bool Equals(object obj)
    {
        return Equals(obj as Entity);
    }

    public static bool operator ==(Entity e1, Entity e2)
    {
        if (ReferenceEquals(e1, null))
            return ReferenceEquals(e2, null);

        return e1.Equals(e2);
    }

    public static bool operator !=(Entity e1, Entity e2)
    {
        return !(e1 == e2);
    }

    public override int GetHashCode()
    {
        throw new NotImplementedException("Your lightweight hashing algorithm, consistent with Equals method, here...");
    }
}

特别注意,如果您的类可以继承,要看它应该如何运作,在这种情况下,您将不得不确定基类对象是否可以等于派生类对象。理想情况下,如果没有使用派生类对象进行相等性检查,则基类实例可以等于派生类实例,在这种情况下,没有必要在基类的通用Equals中检查Type的等同性。

总体而言,要注意不要重复代码。我可以创建一个通用的抽象基类(例如 IEqualizable<T>),以便更容易地重用,但遗憾的是,在C#中,这样做会禁止我从其他类派生。


1
覆盖引用类型的 == 运算符的一个 主要 问题(由于 C# 设计中的缺陷,这是我的个人看法)是在 C# 中实际上有两个不同的运算符,并且在编译时静态地决定使用哪个运算符。对于值类型,可以重载 == 以便在所有情况下测试值相等性 [4==4.0m4==4.0 编译并返回 true,但 4.0m==4.0 不会编译]。但是对于引用类型,这是不可能的;给定 var s1="1"; var s2=1.ToString(); Object o1 = s1;,s1==s2 和 o1==s1,但 o1!=s2。 - supercat

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