如何为类或结构定义值相等性(C# 编程指南)

小窍门

可以考虑优先使用 记录 记录使用最少的代码自动实现值相等性,使其成为大多数以数据为中心的类型的建议方法。 如果需要自定义值相等逻辑或无法使用记录,请继续执行下面的手动实现步骤。

定义类或结构时,需确定为类型创建值相等性(或等效性)的自定义定义是否有意义。 通常,预期将类型的对象添加到集合时,或者这些对象主要用于存储一组字段或属性时,需实现值相等性。 可以基于类型中所有字段和属性的比较结果来定义值相等性,也可以基于子集进行定义。

在任何一种情况下,类和结构中的实现均应遵循 5 个等效性保证条件(对于以下规则,假设 xyz 都不为 null):

  1. 自反属性:x.Equals(x) 将返回 true

  2. 对称属性:x.Equals(y) 返回与 y.Equals(x) 相同的值。

  3. 可传递属性:如果 (x.Equals(y) && y.Equals(z)) 返回 true,则 x.Equals(z) 将返回 true

  4. 只要未修改 x 和 y 引用的对象,x.Equals(y) 的连续调用就将返回相同的值。

  5. 任何非 null 值均不等于 null。 然而,当 x.Equals(y) 为 null 时,x 将引发异常。 这会违反规则 1 或 2,具体取决于 Equals 的参数。

定义的任何结构都已具有其从 System.ValueType 方法的 Object.Equals(Object) 替代中继承的值相等性的默认实现。 此实现使用反射来检查类型中的所有字段和属性。 尽管此实现可生成正确的结果,但与专门为类型编写的自定义实现相比,它的速度相对较慢。

类和结构的值相等性的实现详细信息有所不同。 但是,类和结构都需要相同的基础步骤来实现相等性:

  1. 替代虚拟Object.Equals(Object) 方法。 这提供了多态化的相等性行为,允许对象在被视为object引用时可以正确地进行比较。 它确保集合中以及使用多态性时的行为正确。 大多数情况下,bool Equals( object obj ) 实现应只调入作为 Equals 接口的实现的类型特定 System.IEquatable<T> 方法。 (请参阅步骤 2。)

  2. 通过提供类型特定的 System.IEquatable<T> 方法实现 Equals 接口。 这提供了类型安全的相等性检查,无需进行装箱,从而提升性能。 它还可避免不必要的强制转换并启用编译时类型检查。 实际的等效性比较将在此接口中执行。 例如,可能决定通过仅比较类型中的一两个字段来定义相等性。 不会从 Equals 引发异常。 对于与继承相关的类:

    • 此方法应仅检查类中声明的字段。 它应调用 base.Equals 来检查基类中的字段。 (如果类型直接从 base.Equals 中继承,则不会调用 Object,因为 ObjectObject.Equals(Object) 实现会执行引用相等性检查。)

    • 仅当要比较的变量的运行时类型相同时,才应将两个变量视为相等。 此外,如果变量的运行时和编译时类型不同,请确保使用运行时类型的 IEquatable 方法的 Equals 实现。 确保始终正确比较运行时类型的一种策略是仅在 IEquatable 类中实现 sealed。 有关详细信息,请参阅本文后续部分的类示例

  3. 可选,但建议这样做:重载 ==!= 运算符。 这为相等比较提供了一致且直观的语法,符合内置类型的用户期望。 它确保 obj1 == obj2obj1.Equals(obj2) 的行为表现一致。

  4. 替代 Object.GetHashCode,以便具有值相等性的两个对象生成相同的哈希代码。 为了在基于哈希的集合中实现正确行为,这是必需的,例如 Dictionary<TKey,TValue>HashSet<T>。 相等的对象必须具有相同的哈希代码,否则这些集合将无法正常工作。

  5. 可选:若要支持“大于”或“小于”定义,请为类型实现 IComparable<T> 接口,并同时重载 <> 运算符。 这将启用排序作并为类型提供完整的排序关系,在将对象添加到排序集合或对数组或列表进行排序时非常有用。

记录示例

以下示例演示记录如何通过最少的代码自动实现值相等性。 第一条记录 TwoDPoint 是一个简单的记录类型,可自动实现值相等性。 第二条记录 ThreeDPoint 演示记录可以派生自其他记录,但仍保持适当的值相等行为:

namespace ValueEqualityRecord;

public record TwoDPoint(int X, int Y);

public record ThreeDPoint(int X, int Y, int Z) : TwoDPoint(X, Y);

class Program
{
    static void Main(string[] args)
    {
        // Create some points
        TwoDPoint pointA = new TwoDPoint(3, 4);
        TwoDPoint pointB = new TwoDPoint(3, 4);
        TwoDPoint pointC = new TwoDPoint(5, 6);
        
        ThreeDPoint point3D_A = new ThreeDPoint(3, 4, 5);
        ThreeDPoint point3D_B = new ThreeDPoint(3, 4, 5);
        ThreeDPoint point3D_C = new ThreeDPoint(3, 4, 7);

        Console.WriteLine("=== Value Equality with Records ===");
        
        // Value equality works automatically
        Console.WriteLine($"pointA.Equals(pointB) = {pointA.Equals(pointB)}"); // True
        Console.WriteLine($"pointA == pointB = {pointA == pointB}"); // True
        Console.WriteLine($"pointA.Equals(pointC) = {pointA.Equals(pointC)}"); // False
        Console.WriteLine($"pointA == pointC = {pointA == pointC}"); // False

        Console.WriteLine("\n=== Hash Codes ===");
        
        // Equal objects have equal hash codes automatically
        Console.WriteLine($"pointA.GetHashCode() = {pointA.GetHashCode()}");
        Console.WriteLine($"pointB.GetHashCode() = {pointB.GetHashCode()}");
        Console.WriteLine($"pointC.GetHashCode() = {pointC.GetHashCode()}");
        
        Console.WriteLine("\n=== Inheritance with Records ===");
        
        // Inheritance works correctly with value equality
        Console.WriteLine($"point3D_A.Equals(point3D_B) = {point3D_A.Equals(point3D_B)}"); // True
        Console.WriteLine($"point3D_A == point3D_B = {point3D_A == point3D_B}"); // True
        Console.WriteLine($"point3D_A.Equals(point3D_C) = {point3D_A.Equals(point3D_C)}"); // False
        
        // Different types are not equal (unlike problematic class example)
        Console.WriteLine($"pointA.Equals(point3D_A) = {pointA.Equals(point3D_A)}"); // False
        
        Console.WriteLine("\n=== Collections ===");
        
        // Works seamlessly with collections
        var pointSet = new HashSet<TwoDPoint> { pointA, pointB, pointC };
        Console.WriteLine($"Set contains {pointSet.Count} unique points"); // 2 unique points
        
        var pointDict = new Dictionary<TwoDPoint, string>
        {
            { pointA, "First point" },
            { pointC, "Different point" }
        };
        
        // Demonstrate that equivalent points work as the same key
        var duplicatePoint = new TwoDPoint(3, 4);
        Console.WriteLine($"Dictionary contains key for {duplicatePoint}: {pointDict.ContainsKey(duplicatePoint)}"); // True
        Console.WriteLine($"Dictionary contains {pointDict.Count} entries"); // 2 entries
        
        Console.WriteLine("\n=== String Representation ===");
        
        // Automatic ToString implementation
        Console.WriteLine($"pointA.ToString() = {pointA}");
        Console.WriteLine($"point3D_A.ToString() = {point3D_A}");

        Console.WriteLine("Press any key to exit.");
        Console.ReadKey();
    }
}

/* Expected Output:
=== Value Equality with Records ===
pointA.Equals(pointB) = True
pointA == pointB = True
pointA.Equals(pointC) = False
pointA == pointC = False

=== Hash Codes ===
pointA.GetHashCode() = -1400834708
pointB.GetHashCode() = -1400834708
pointC.GetHashCode() = -148136000

=== Inheritance with Records ===
point3D_A.Equals(point3D_B) = True
point3D_A == point3D_B = True
point3D_A.Equals(point3D_C) = False
pointA.Equals(point3D_A) = False

=== Collections ===
Set contains 2 unique points
Dictionary contains key for TwoDPoint { X = 3, Y = 4 }: True
Dictionary contains 2 entries

=== String Representation ===
pointA.ToString() = TwoDPoint { X = 3, Y = 4 }
point3D_A.ToString() = ThreeDPoint { X = 3, Y = 4, Z = 5 }
*/

记录为值相等性提供了几个优点:

  • 自动实现:记录自动实现 System.IEquatable<T> 和替代 Object.EqualsObject.GetHashCode以及 ==/!= 运算符。
  • 正确的继承行为:记录使用检查两个作数的运行时类型的虚拟方法实现 IEquatable<T> ,确保继承层次结构和多态方案中的行为正确。
  • 默认情况下不可变性:记录鼓励不可变设计,这非常适合值相等语义。
  • 简明的语法:位置参数提供了一种用于定义数据类型的紧凑方法。
  • 性能更佳:编译器生成的相等实现经过优化,不使用反射(如默认结构实现)。

在主要目标是存储数据并且需要值相等语义时使用记录。

具有使用引用相等性的成员的记录

当记录包含使用引用相等性的成员时,记录的自动值相等行为无法按预期工作。 这适用于不实现基于值的相等性的集合 System.Collections.Generic.List<T>、数组和其他引用类型(但有明显的例外 System.String,即实现值相等)。

重要

虽然记录为基本数据类型提供出色的值相等性,但它们不会为使用引用相等性的成员自动求解值相等性。 如果记录包含不实现值相等的其他引用类型,则这些成员中具有相同内容的两个System.Collections.Generic.List<T>System.Array记录实例仍将不相等,因为成员使用引用相等性。

public record PersonWithHobbies(string Name, List<string> Hobbies);

var person1 = new PersonWithHobbies("Alice", new List<string> { "Reading", "Swimming" });
var person2 = new PersonWithHobbies("Alice", new List<string> { "Reading", "Swimming" });

Console.WriteLine(person1.Equals(person2)); // False - different List instances!

这是因为记录使用 Object.Equals 每个成员的方法,集合类型通常使用引用相等性,而不是比较其内容。

下面显示了问题:

// Records with reference-equality members don't work as expected
public record PersonWithHobbies(string Name, List<string> Hobbies);

下面是运行代码时的行为方式:

Console.WriteLine("=== Records with Collections - The Problem ===");

// Problem: Records with mutable collections use reference equality for the collection
var person1 = new PersonWithHobbies("Alice", [ "Reading", "Swimming" ]);
var person2 = new PersonWithHobbies("Alice", [ "Reading", "Swimming" ]);

Console.WriteLine($"person1: {person1}");
Console.WriteLine($"person2: {person2}");
Console.WriteLine($"person1.Equals(person2): {person1.Equals(person2)}"); // False! Different List instances
Console.WriteLine($"Lists have same content: {person1.Hobbies.SequenceEqual(person2.Hobbies)}"); // True
Console.WriteLine();

具有引用相等性成员的记录的解决方案

  • 自定义 System.IEquatable<T> 实现:将编译器生成的相等性替换为手动编码的版本,该版本为引用相等成员提供基于内容的比较。 对于集合,请使用 Enumerable.SequenceEqual 或类似方法实现按元素比较。

  • 尽可能使用值类型:考虑数据是否可以用自然支持值相等性的值类型或不可变结构表示,例如 System.Numerics.Vector<T>Plane

  • 使用具有基于值的相等性的类型:对于集合,请考虑使用实现基于值的相等性的类型,或实现重写 Object.Equals 的自定义集合类型,以提供基于内容的比较,例如 System.Collections.Immutable.ImmutableArray<T>System.Collections.Immutable.ImmutableList<T>

  • 考虑引用相等的设计:接受某些成员将使用引用相等性并相应地设计应用程序逻辑,确保在相等很重要时重复使用同一实例。

下面是使用集合实现记录的自定义相等性的示例:

// A potential solution using IEquatable<T> with custom equality
public record PersonWithHobbiesFixed(string Name, List<string> Hobbies) : IEquatable<PersonWithHobbiesFixed>
{
    public virtual bool Equals(PersonWithHobbiesFixed? other)
    {
        if (ReferenceEquals(null, other)) return false;
        if (ReferenceEquals(this, other)) return true;
        
        // Use SequenceEqual for List comparison
        return Name == other.Name && Hobbies.SequenceEqual(other.Hobbies);
    }

    public override int GetHashCode()
    {
        // Create hash based on content, not reference
        var hashCode = new HashCode();
        hashCode.Add(Name);
        foreach (var hobby in Hobbies)
        {
            hashCode.Add(hobby);
        }
        return hashCode.ToHashCode();
    }
}

此自定义实现正常工作:

Console.WriteLine("=== Solution 1: Custom IEquatable Implementation ===");

var personFixed1 = new PersonWithHobbiesFixed("Bob", [ "Cooking", "Hiking" ]);
var personFixed2 = new PersonWithHobbiesFixed("Bob", [ "Cooking", "Hiking" ]);

Console.WriteLine($"personFixed1: {personFixed1}");
Console.WriteLine($"personFixed2: {personFixed2}");
Console.WriteLine($"personFixed1.Equals(personFixed2): {personFixed1.Equals(personFixed2)}"); // True! Custom equality
Console.WriteLine();

相同的问题会影响数组和其他集合类型:

// These also use reference equality - the issue persists
public record PersonWithHobbiesArray(string Name, string[] Hobbies);

public record PersonWithHobbiesImmutable(string Name, IReadOnlyList<string> Hobbies);

数组还使用引用相等性,产生相同的意外结果:

Console.WriteLine("=== Arrays Also Use Reference Equality ===");

var personArray1 = new PersonWithHobbiesArray("Charlie", ["Gaming", "Music" ]);
var personArray2 = new PersonWithHobbiesArray("Charlie", ["Gaming", "Music" ]);

Console.WriteLine($"personArray1: {personArray1}");
Console.WriteLine($"personArray2: {personArray2}");
Console.WriteLine($"personArray1.Equals(personArray2): {personArray1.Equals(personArray2)}"); // False! Arrays use reference equality too
Console.WriteLine($"Arrays have same content: {personArray1.Hobbies.SequenceEqual(personArray2.Hobbies)}"); // True
Console.WriteLine();

即使是只读集合也表现出此引用相等行为:

Console.WriteLine("=== Same Issue with IReadOnlyList ===");

var personImmutable1 = new PersonWithHobbiesImmutable("Diana", [ "Art", "Travel" ]);
var personImmutable2 = new PersonWithHobbiesImmutable("Diana", [ "Art", "Travel" ]);

Console.WriteLine($"personImmutable1: {personImmutable1}");
Console.WriteLine($"personImmutable2: {personImmutable2}");
Console.WriteLine($"personImmutable1.Equals(personImmutable2): {personImmutable1.Equals(personImmutable2)}"); // False! Reference equality
Console.WriteLine($"Content is the same: {personImmutable1.Hobbies.SequenceEqual(personImmutable2.Hobbies)}"); // True
Console.WriteLine();

关键见解是记录解决了 结构 相等问题,但不会更改它们所包含的类型的 语义 相等行为。

类示例

下面的示例演示如何在类(引用类型)中实现值相等性。 当无法使用记录或需要自定义相等逻辑时,需要此手动方法:

namespace ValueEqualityClass;

class TwoDPoint : IEquatable<TwoDPoint>
{
    public int X { get; private set; }
    public int Y { get; private set; }

    public TwoDPoint(int x, int y)
    {
        if (x is (< 1 or > 2000) || y is (< 1 or > 2000))
        {
            throw new ArgumentException("Point must be in range 1 - 2000");
        }
        this.X = x;
        this.Y = y;
    }

    public override bool Equals(object obj) => this.Equals(obj as TwoDPoint);

    public bool Equals(TwoDPoint p)
    {
        if (p is null)
        {
            return false;
        }

        // Optimization for a common success case.
        if (Object.ReferenceEquals(this, p))
        {
            return true;
        }

        // If run-time types are not exactly the same, return false.
        if (this.GetType() != p.GetType())
        {
            return false;
        }

        // Return true if the fields match.
        // Note that the base class is not invoked because it is
        // System.Object, which defines Equals as reference equality.
        return (X == p.X) && (Y == p.Y);
    }

    public override int GetHashCode() => (X, Y).GetHashCode();

    public static bool operator ==(TwoDPoint lhs, TwoDPoint rhs)
    {
        if (lhs is null)
        {
            if (rhs is null)
            {
                return true;
            }

            // Only the left side is null.
            return false;
        }
        // Equals handles case of null on right side.
        return lhs.Equals(rhs);
    }

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

// For the sake of simplicity, assume a ThreeDPoint IS a TwoDPoint.
class ThreeDPoint : TwoDPoint, IEquatable<ThreeDPoint>
{
    public int Z { get; private set; }

    public ThreeDPoint(int x, int y, int z)
        : base(x, y)
    {
        if ((z < 1) || (z > 2000))
        {
            throw new ArgumentException("Point must be in range 1 - 2000");
        }
        this.Z = z;
    }

    public override bool Equals(object obj) => this.Equals(obj as ThreeDPoint);

    public bool Equals(ThreeDPoint p)
    {
        if (p is null)
        {
            return false;
        }

        // Optimization for a common success case.
        if (Object.ReferenceEquals(this, p))
        {
            return true;
        }

        // Check properties that this class declares.
        if (Z == p.Z)
        {
            // Let base class check its own fields
            // and do the run-time type comparison.
            return base.Equals((TwoDPoint)p);
        }
        else
        {
            return false;
        }
    }

    public override int GetHashCode() => (X, Y, Z).GetHashCode();

    public static bool operator ==(ThreeDPoint lhs, ThreeDPoint rhs)
    {
        if (lhs is null)
        {
            if (rhs is null)
            {
                // null == null = true.
                return true;
            }

            // Only the left side is null.
            return false;
        }
        // Equals handles the case of null on right side.
        return lhs.Equals(rhs);
    }

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

class Program
{
    static void Main(string[] args)
    {
        ThreeDPoint pointA = new ThreeDPoint(3, 4, 5);
        ThreeDPoint pointB = new ThreeDPoint(3, 4, 5);
        ThreeDPoint pointC = null;
        int i = 5;

        Console.WriteLine($"pointA.Equals(pointB) = {pointA.Equals(pointB)}");
        Console.WriteLine($"pointA == pointB = {pointA == pointB}");
        Console.WriteLine($"null comparison = {pointA.Equals(pointC)}");
        Console.WriteLine($"Compare to some other type = {pointA.Equals(i)}");

        TwoDPoint pointD = null;
        TwoDPoint pointE = null;

        Console.WriteLine($"Two null TwoDPoints are equal: {pointD == pointE}");

        pointE = new TwoDPoint(3, 4);
        Console.WriteLine($"(pointE == pointA) = {pointE == pointA}");
        Console.WriteLine($"(pointA == pointE) = {pointA == pointE}");
        Console.WriteLine($"(pointA != pointE) = {pointA != pointE}");

        System.Collections.ArrayList list = new System.Collections.ArrayList();
        list.Add(new ThreeDPoint(3, 4, 5));
        Console.WriteLine($"pointE.Equals(list[0]): {pointE.Equals(list[0])}");

        // Keep the console window open in debug mode.
        Console.WriteLine("Press any key to exit.");
        Console.ReadKey();
    }
}

/* Output:
    pointA.Equals(pointB) = True
    pointA == pointB = True
    null comparison = False
    Compare to some other type = False
    Two null TwoDPoints are equal: True
    (pointE == pointA) = False
    (pointA == pointE) = False
    (pointA != pointE) = True
    pointE.Equals(list[0]): False
*/

在类(引用类型)上,两种 Object.Equals(Object) 方法的默认实现均执行引用相等性比较,而不是值相等性检查。 实施者替代虚方法时,目的是为其指定值相等性语义。

即使类不重载 ==!= 运算符,也可将这些运算符与类一起使用。 但是,默认行为是执行引用相等性检查。 在类中,如果重载 Equals 方法,则应重载 ==!= 运算符,但这并不是必需的。

重要

前面的示例代码可能无法按照预期的方式处理每个继承方案。 考虑下列代码:

TwoDPoint p1 = new ThreeDPoint(1, 2, 3);
TwoDPoint p2 = new ThreeDPoint(1, 2, 4);
Console.WriteLine(p1.Equals(p2)); // output: True

根据此代码报告,尽管 p1 值有所不同,但 p2 等于 z。 由于编译器会根据编译时类型选取 TwoDPointIEquatable 实现,因而会忽略该差异。 这是继承层次结构中多态相等的基本问题。

多态相等性

使用类在继承层次结构中实现值相等性时,类示例中所示的标准方法可能会导致在多态使用对象时出现错误行为。 之所以出现问题,是因为 System.IEquatable<T> 根据编译时类型而不是运行时类型选择了实现。

标准实现的问题

请考虑以下有问题的方案:

TwoDPoint p1 = new ThreeDPoint(1, 2, 3);  // Declared as TwoDPoint
TwoDPoint p2 = new ThreeDPoint(1, 2, 4);  // Declared as TwoDPoint
Console.WriteLine(p1.Equals(p2)); // True - but should be False!

比较返回 True 是因为编译器根据声明的类型选择 TwoDPoint.Equals(TwoDPoint) ,因此忽略 Z 坐标差异。

更正多态相等性的关键是确保所有相等比较都使用虚拟 Object.Equals 方法,该方法可以检查运行时类型并正确处理继承。 这可以通过对虚拟方法的委托使用显式接口实现 System.IEquatable<T> 来实现:

基类演示了键模式:

// Safe polymorphic equality implementation using explicit interface implementation
class TwoDPoint : IEquatable<TwoDPoint>
{
    public int X { get; private set; }
    public int Y { get; private set; }

    public TwoDPoint(int x, int y)
    {
        if (x is (< 1 or > 2000) || y is (< 1 or > 2000))
        {
            throw new ArgumentException("Point must be in range 1 - 2000");
        }
        this.X = x;
        this.Y = y;
    }

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

    // Explicit interface implementation prevents compile-time type issues
    bool IEquatable<TwoDPoint>.Equals(TwoDPoint? p) => Equals((object?)p);

    protected virtual bool Equals(TwoDPoint? p)
    {
        if (p is null)
        {
            return false;
        }

        // Optimization for a common success case.
        if (Object.ReferenceEquals(this, p))
        {
            return true;
        }

        // If run-time types are not exactly the same, return false.
        if (this.GetType() != p.GetType())
        {
            return false;
        }

        // Return true if the fields match.
        // Note that the base class is not invoked because it is
        // System.Object, which defines Equals as reference equality.
        return (X == p.X) && (Y == p.Y);
    }

    public override int GetHashCode() => (X, Y).GetHashCode();

    public static bool operator ==(TwoDPoint? lhs, TwoDPoint? rhs)
    {
        if (lhs is null)
        {
            if (rhs is null)
            {
                return true;
            }

            // Only the left side is null.
            return false;
        }
        // Equals handles case of null on right side.
        return lhs.Equals(rhs);
    }

    public static bool operator !=(TwoDPoint? lhs, TwoDPoint? rhs) => !(lhs == rhs);
}

派生类正确地扩展了相等逻辑:

// For the sake of simplicity, assume a ThreeDPoint IS a TwoDPoint.
class ThreeDPoint : TwoDPoint, IEquatable<ThreeDPoint>
{
    public int Z { get; private set; }

    public ThreeDPoint(int x, int y, int z)
        : base(x, y)
    {
        if ((z < 1) || (z > 2000))
        {
            throw new ArgumentException("Point must be in range 1 - 2000");
        }
        this.Z = z;
    }

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

    // Explicit interface implementation prevents compile-time type issues
    bool IEquatable<ThreeDPoint>.Equals(ThreeDPoint? p) => Equals((object?)p);

    protected override bool Equals(TwoDPoint? p)
    {
        if (p is null)
        {
            return false;
        }

        // Optimization for a common success case.
        if (Object.ReferenceEquals(this, p))
        {
            return true;
        }

        // Runtime type check happens in the base method
        if (p is ThreeDPoint threeD)
        {
            // Check properties that this class declares.
            if (Z != threeD.Z)
            {
                return false;
            }

            return base.Equals(p);
        }

        return false;
    }

    public override int GetHashCode() => (X, Y, Z).GetHashCode();

    public static bool operator ==(ThreeDPoint? lhs, ThreeDPoint? rhs)
    {
        if (lhs is null)
        {
            if (rhs is null)
            {
                // null == null = true.
                return true;
            }

            // Only the left side is null.
            return false;
        }
        // Equals handles the case of null on right side.
        return lhs.Equals(rhs);
    }

    public static bool operator !=(ThreeDPoint? lhs, ThreeDPoint? rhs) => !(lhs == rhs);
}

下面是此实现如何处理有问题的多态方案:

Console.WriteLine("=== Safe Polymorphic Equality ===");

// Test polymorphic scenarios that were problematic before
TwoDPoint p1 = new ThreeDPoint(1, 2, 3);
TwoDPoint p2 = new ThreeDPoint(1, 2, 4);
TwoDPoint p3 = new ThreeDPoint(1, 2, 3);
TwoDPoint p4 = new TwoDPoint(1, 2);

Console.WriteLine("Testing polymorphic equality (declared as TwoDPoint):");
Console.WriteLine($"p1 = ThreeDPoint(1, 2, 3) as TwoDPoint");
Console.WriteLine($"p2 = ThreeDPoint(1, 2, 4) as TwoDPoint");
Console.WriteLine($"p3 = ThreeDPoint(1, 2, 3) as TwoDPoint");
Console.WriteLine($"p4 = TwoDPoint(1, 2)");
Console.WriteLine();

Console.WriteLine($"p1.Equals(p2) = {p1.Equals(p2)}"); // False - different Z values
Console.WriteLine($"p1.Equals(p3) = {p1.Equals(p3)}"); // True - same values
Console.WriteLine($"p1.Equals(p4) = {p1.Equals(p4)}"); // False - different types
Console.WriteLine($"p4.Equals(p1) = {p4.Equals(p1)}"); // False - different types
Console.WriteLine();

实现还正确处理直接类型比较:

// Test direct type comparisons
var point3D_A = new ThreeDPoint(3, 4, 5);
var point3D_B = new ThreeDPoint(3, 4, 5);
var point3D_C = new ThreeDPoint(3, 4, 7);
var point2D_A = new TwoDPoint(3, 4);

Console.WriteLine("Testing direct type comparisons:");
Console.WriteLine($"point3D_A.Equals(point3D_B) = {point3D_A.Equals(point3D_B)}"); // True
Console.WriteLine($"point3D_A.Equals(point3D_C) = {point3D_A.Equals(point3D_C)}"); // False
Console.WriteLine($"point3D_A.Equals(point2D_A) = {point3D_A.Equals(point2D_A)}"); // False
Console.WriteLine($"point2D_A.Equals(point3D_A) = {point2D_A.Equals(point3D_A)}"); // False
Console.WriteLine();

相等实现也适用于集合:

// Test with collections
Console.WriteLine("Testing with collections:");
var hashSet = new HashSet<TwoDPoint> { p1, p2, p3, p4 };
Console.WriteLine($"HashSet contains {hashSet.Count} unique points"); // Should be 3: one ThreeDPoint(1,2,3), one ThreeDPoint(1,2,4), one TwoDPoint(1,2)

var dictionary = new Dictionary<TwoDPoint, string>
{
    { p1, "First 3D point" },
    { p2, "Second 3D point" },
    { p4, "2D point" }
};

Console.WriteLine($"Dictionary contains {dictionary.Count} entries");
Console.WriteLine($"Dictionary lookup for equivalent point: {dictionary.ContainsKey(new ThreeDPoint(1, 2, 3))}"); // True

前面的代码演示实现基于值的相等性的关键元素:

  • 虚拟 Equals(object?) 重写:主要相等逻辑发生在虚拟 Object.Equals 方法中,无论编译时类型如何,都会调用该逻辑。
  • 运行时类型检查:使用 this.GetType() != p.GetType() 可确保不同类型的对象永远不会被视为相等。
  • 显式接口实现System.IEquatable<T> 实现委托给虚拟方法,防止编译时类型选择问题。
  • 受保护的虚拟帮助程序方法:此方法 protected virtual Equals(TwoDPoint? p) 允许派生类在维护类型安全性的同时替代相等逻辑。

在以下情况下使用此模式:

  • 你有一个继承层次结构,其中值相等非常重要
  • 对象可以多态使用(声明为基类型,实例化为派生类型)
  • 需要具有值相等语义的引用类型

首选方法是使用 record 类型来实现基于值的相等性。 此方法需要比标准方法更复杂的实现,并且需要彻底测试多态方案以确保正确性。

结构示例

以下示例演示如何在结构(值类型)中实现值相等性。 虽然结构具有默认值相等性,但自定义实现可以提高性能:

namespace ValueEqualityStruct
{
    struct TwoDPoint : IEquatable<TwoDPoint>
    {
        public int X { get; private set; }
        public int Y { get; private set; }

        public TwoDPoint(int x, int y)
            : this()
        {
            if (x is (< 1 or > 2000) || y is (< 1 or > 2000))
            {
                throw new ArgumentException("Point must be in range 1 - 2000");
            }
            X = x;
            Y = y;
        }

        public override bool Equals(object? obj) => obj is TwoDPoint other && this.Equals(other);

        public bool Equals(TwoDPoint p) => X == p.X && Y == p.Y;

        public override int GetHashCode() => (X, Y).GetHashCode();

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

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

    class Program
    {
        static void Main(string[] args)
        {
            TwoDPoint pointA = new TwoDPoint(3, 4);
            TwoDPoint pointB = new TwoDPoint(3, 4);
            int i = 5;

            // True:
            Console.WriteLine($"pointA.Equals(pointB) = {pointA.Equals(pointB)}");
            // True:
            Console.WriteLine($"pointA == pointB = {pointA == pointB}");
            // True:
            Console.WriteLine($"object.Equals(pointA, pointB) = {object.Equals(pointA, pointB)}");
            // False:
            Console.WriteLine($"pointA.Equals(null) = {pointA.Equals(null)}");
            // False:
            Console.WriteLine($"(pointA == null) = {pointA == null}");
            // True:
            Console.WriteLine($"(pointA != null) = {pointA != null}");
            // False:
            Console.WriteLine($"pointA.Equals(i) = {pointA.Equals(i)}");
            // CS0019:
            // Console.WriteLine($"pointA == i = {pointA == i}");

            // Compare unboxed to boxed.
            System.Collections.ArrayList list = new System.Collections.ArrayList();
            list.Add(new TwoDPoint(3, 4));
            // True:
            Console.WriteLine($"pointA.Equals(list[0]): {pointA.Equals(list[0])}");

            // Compare nullable to nullable and to non-nullable.
            TwoDPoint? pointC = null;
            TwoDPoint? pointD = null;
            // False:
            Console.WriteLine($"pointA == (pointC = null) = {pointA == pointC}");
            // True:
            Console.WriteLine($"pointC == pointD = {pointC == pointD}");

            TwoDPoint temp = new TwoDPoint(3, 4);
            pointC = temp;
            // True:
            Console.WriteLine($"pointA == (pointC = 3,4) = {pointA == pointC}");

            pointD = temp;
            // True:
            Console.WriteLine($"pointD == (pointC = 3,4) = {pointD == pointC}");

            Console.WriteLine("Press any key to exit.");
            Console.ReadKey();
        }
    }

    /* Output:
        pointA.Equals(pointB) = True
        pointA == pointB = True
        Object.Equals(pointA, pointB) = True
        pointA.Equals(null) = False
        (pointA == null) = False
        (pointA != null) = True
        pointA.Equals(i) = False
        pointE.Equals(list[0]): True
        pointA == (pointC = null) = False
        pointC == pointD = True
        pointA == (pointC = 3,4) = True
        pointD == (pointC = 3,4) = True
    */
}

对于结构,Object.Equals(Object)System.ValueType 中的替代版本)的默认实现通过使用反射来比较类型中每个字段的值,从而执行值相等性检查。 尽管此实现可生成正确的结果,但与专门为类型编写的自定义实现相比,它的速度相对较慢。

在结构体中重写虚拟 Equals 方法时,目的是提供一种更高效手段来执行值相等性检查,并选择性地基于结构体字段或属性的某些子集进行比较。

除非结构显式重载了 ==!= 运算符,否则这些运算符无法对结构进行运算。

请参阅