Sdílet prostřednictvím


Definování rovnosti hodnot pro třídu nebo strukturu (Průvodce programováním v C#)

Návod

Nejprve zvažte použití záznamů . Záznamy automaticky implementují porovnávání na základě rovnosti hodnot s minimálním množstvím kódu, což z nich činí doporučovaný přístup pro většinu typů zaměřených na data. Pokud potřebujete logiku rovnosti vlastních hodnot nebo nemůžete použít záznamy, pokračujte postupem ruční implementace níže.

Při definování třídy nebo struktury se rozhodnete, zda má smysl vytvořit vlastní definici rovnosti hodnot (nebo ekvivalence) pro typ. Rovnost hodnot obvykle implementujete, když očekáváte, že do kolekce přidáte objekty typu nebo když jejich primárním účelem je uložit sadu polí nebo vlastností. Definici rovnosti hodnot můžete založit na porovnání všech polí a vlastností v typu nebo můžete definici založit na podmnožině.

V obou případech a v obou třídách a strukturách by vaše implementace měla dodržovat pět záruk ekvivalence (pro následující pravidla předpokládejme, že xy a z nejsou null):

  1. Reflexní vlastnost: x.Equals(x) vrátí true.

  2. Symetrická vlastnost: x.Equals(y) vrátí stejnou hodnotu jako y.Equals(x).

  3. Tranzitivní vlastnost: pokud (x.Equals(y) && y.Equals(z)) vrátí true, pak x.Equals(z) vrátí true.

  4. Následné vyvolání x.Equals(y) vrátí stejnou hodnotu, pokud objekty, na které odkazuje x a y, se nezmění.

  5. Jakákoli hodnota, která není null, se nerovná hodnotě null. Pokud je x.Equals(y) null, vyvolá x výjimku. Tím se přeruší pravidla 1 nebo 2 v závislosti na argumentu Equals.

Každá struktura, kterou definujete, již má výchozí implementaci rovnosti hodnot, kterou dědí z přepsání metody System.ValueType metodou Object.Equals(Object). Tato implementace používá reflexi k prozkoumání všech polí a vlastností v typu. I když tato implementace vytváří správné výsledky, je relativně pomalé v porovnání s vlastní implementací, kterou píšete speciálně pro daný typ.

Podrobnosti implementace rovnosti hodnot se liší pro třídy a struktury. Obě třídy i struktury však vyžadují pro implementaci rovnosti stejný základní postup:

  1. Přepište metodu virtuálníObject.Equals(Object). To poskytuje polymorfní chování shodnosti, což umožňuje správné porovnání objektů, když se jedná o object odkazy. Zajišťuje správné chování v kolekcích a při použití polymorfismu. Ve většině případů by vaše implementace bool Equals( object obj ) měla pouze zavolat metodu specifickou pro typ Equals, která je implementací rozhraní System.IEquatable<T>. (Viz krok 2.)

  2. System.IEquatable<T> Implementujte rozhraní zadáním metody specifické pro typEquals. Toto poskytuje typově bezpečné porovnávání rovnosti bez boxování, což vede k lepšímu výkonu. Zabraňuje také zbytečnému přetypování a umožňuje kontrolu typů v době kompilace. Tady se provádí skutečné porovnání ekvivalence. Můžete se například rozhodnout definovat rovnost porovnáním pouze jednoho nebo dvou polí ve vašem typu. Nevyvolávejte výjimky z Equals. Třídy, které souvisejí dědičností:

    • Tato metoda by měla zkoumat pouze pole deklarovaná ve třídě. Mělo by volat base.Equals pro zkoumání polí, která jsou v základní třídě. (Nevolejte base.Equals, pokud typ dědí přímo z Object, protože implementace Object ve Object.Equals(Object) provádí kontrolu rovnosti odkazů.)

    • Dvě proměnné by měly být považovány za stejné, pouze pokud jsou porovnávané typy proměnných za běhu stejné. Také se ujistěte, že implementace IEquatable metody Equals pro typ za běhu proměnné je použita, pokud se typy za běhu a při kompilaci proměnné liší. Jednou strategií pro zajištění správného porovnání typů během běhu programu je implementace IEquatable pouze ve sealed třídách. Další informace najdete v příkladu třídy dále v tomto článku.

  3. Volitelné, ale doporučené: Přetížit operátory == a !=. To poskytuje konzistentní a intuitivní syntaxi pro porovnávání rovnosti, která odpovídá očekávání uživatelů z vestavěných typů. Zajišťuje, že obj1 == obj2 a obj1.Equals(obj2) chová se stejným způsobem.

  4. Přepsat Object.GetHashCode tak, aby dva objekty, které mají rovnost hodnot, vytvořily stejný hash kód. To je vyžadováno pro správné chování v kolekcích založených na hodnotě hash, jako Dictionary<TKey,TValue> a HashSet<T>. Objekty, které jsou stejné, musí mít stejné hashovací kódy nebo tyto kolekce nebudou fungovat správně.

  5. Volitelné: Chcete-li podporovat definice pro "větší než" nebo "menší než", implementujte IComparable<T> rozhraní pro váš typ a také přetěžujte operátory <= a >=. To umožňuje operace řazení a poskytuje úplný vztah řazení pro váš typ, užitečný při přidávání objektů do seřazených kolekcí nebo při řazení polí nebo seznamů.

Příklad záznamu

Následující příklad ukazuje, jak záznamy automaticky implementují rovnost hodnot s minimálním kódem. První záznam je jednoduchý typ záznamu TwoDPoint , který automaticky implementuje rovnost hodnot. Druhý záznam ThreeDPoint ukazuje, že záznamy lze odvodit z jiných záznamů a zachovat správné chování rovnosti hodnot:

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 }
*/

Záznamy poskytují několik výhod rovnosti hodnot:

  • Automatická implementace: Záznamy automaticky implementují System.IEquatable<T> a přepisují Object.Equals, Object.GetHashCode a operátory ==/!=.
  • Správné chování dědičnosti: Záznamy implementují IEquatable<T> pomocí virtuálních metod, které kontrolují typ modulu runtime obou operandů a zajišťují správné chování v hierarchiích dědičnosti a polymorfních scénářích.
  • Neměnnost ve výchozím nastavení: Záznamy podporují neměnný návrh, který dobře funguje s sémantikou rovnosti hodnot.
  • Stručná syntaxe: Poziční parametry poskytují kompaktní způsob, jak definovat datové typy.
  • Lepší výkon: Implementace rovnosti generované kompilátorem je optimalizovaná a nepoužívá reflexi, jako je výchozí implementace struktury.

Záznamy použijte v případě, že vaším primárním cílem je ukládat data a potřebujete sémantiku rovnosti hodnot.

Záznamy se členy, kteří používají rovnost odkazů

Pokud záznamy obsahují členy, které používají rovnost odkazů, chování automatické rovnosti hodnot záznamů nefunguje podle očekávání. To platí pro kolekce, jako jsou System.Collections.Generic.List<T>, pole a další odkazové typy, které neimplementují rovnost založenou System.Stringna hodnotách (s výjimkou , která implementuje rovnost hodnot).

Důležité

Záznamy sice poskytují vynikající rovnost hodnot pro základní datové typy, ale automaticky nevyřešují rovnost hodnot pro členy, kteří používají rovnost odkazů. Pokud záznam obsahuje odkaz System.Arraynebo System.Collections.Generic.List<T>jiné odkazové typy, které neimplementují rovnost hodnot, nebudou dvě instance záznamů s identickým obsahem v těchto členech stále stejné, protože členové používají rovnost odkazů.

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!

Důvodem je, že záznamy používají metodu Object.Equals každého člena a typy kolekcí obvykle používají rovnost odkazů místo porovnání jejich obsahu.

Následující příklad ukazuje problém:

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

Tady je postup, jak se chová při spuštění kódu:

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();

Řešení pro záznamy se členy referenční rovnosti

  • Vlastní System.IEquatable<T> implementace: Nahraďte rovnost vygenerovanou kompilátorem ručně zakódovanou verzí, která poskytuje porovnání založené na obsahu pro členy rovnosti odkazů. Pro kolekce implementujte porovnání element-by-element pomocí Enumerable.SequenceEqual nebo podobné metody.

  • Pokud je to možné, použijte hodnotové typy: Zvažte, jestli vaše data mohou být reprezentována typy hodnot nebo neměnnými strukturami, které přirozeně podporují rovnost hodnot, například System.Numerics.Vector<T> nebo Plane.

  • Používejte typy s rovností založenou na hodnotách: U kolekcí zvažte použití typů, které implementují rovnost založenou na hodnotách nebo implementují vlastní typy kolekcí, které přepíší Object.Equals , aby poskytovaly porovnání založené na obsahu, například System.Collections.Immutable.ImmutableArray<T> nebo System.Collections.Immutable.ImmutableList<T>.

  • Návrhsch

Tady je příklad implementace vlastní rovnosti záznamů s kolekcemi:

// 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();
    }
}

Tato vlastní implementace funguje správně:

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();

Stejný problém má vliv na pole a další typy kolekcí:

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

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

Pole také používají rovnost odkazů, což vede ke stejným neočekávaným výsledkům:

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();

Dokonce i kolekce jen pro čtení vykazují toto referenční chování rovnosti:

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();

Klíčovým přehledem je, že záznamy řeší problém strukturální rovnosti, ale nemění sémantické chování rovnosti typů, které obsahují.

Příklad třídy

Následující příklad ukazuje, jak implementovat rovnost hodnot ve třídě (typ odkazu). Tento ruční přístup je potřeba v případě, že nemůžete použít záznamy nebo potřebujete vlastní logiku rovnosti:

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
*/

U tříd (odkazových typů) výchozí implementace obou Object.Equals(Object) metod provádí porovnání rovnosti odkazů, nikoli kontroly rovnosti hodnot. Když implementátor přepíše virtuální metodu, účelem je dát jí sémantiku rovnosti hodnot.

Operátory == a != lze použít u tříd, i když je třída nepřetíží. Výchozí chování je však provést kontrolu rovnosti odkazů. Pokud ve třídě přetížíte metodu Equals, měli byste přetížit operátory == a !=, ale není to nutné.

Důležité

Předchozí ukázkový kód nemusí zpracovávat každý scénář dědičnosti očekávaným způsobem. Uvažujte následující kód:

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

Tento kód hlásí, že p1 se rovná p2 navzdory rozdílu v z hodnotách. Rozdíl je ignorován, protože kompilátor vybere implementaci TwoDPoint z IEquatable na základě kompilačního času. Jedná se o základní problém s polymorfním rovností v hierarchiích dědičnosti.

Polymorfní rovnost

Při implementaci rovnosti hodnot v hierarchiích dědičnosti s třídami může standardní přístup zobrazený v příkladu třídy vést k nesprávnému chování, když se objekty používají polymorfně. K tomuto problému dochází, protože System.IEquatable<T> implementace jsou vybrány na základě typu kompilace a času, nikoli typu runtime.

Problém se standardními implementacemi

Zvažte tento problematický scénář:

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!

Porovnání se vrátí True , protože kompilátor vybere TwoDPoint.Equals(TwoDPoint) na základě deklarovaného typu a ignoruje Z rozdíly souřadnic.

Klíčem k opravě polymorfní rovnosti je zajistit, aby všechna porovnání rovnosti používala virtuální Object.Equals metodu, která může kontrolovat typy modulu runtime a správně zpracovávat dědičnost. Toho lze dosáhnout pomocí explicitní implementace rozhraní pro System.IEquatable<T> tuto delegování na virtuální metodu:

Základní třída demonstruje klíčové vzory:

// 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);
}

Odvozená třída správně rozšiřuje logiku rovnosti:

// 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);
}

Tady je postup, jak tato implementace zpracovává problematické polymorfní scénáře:

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();

Implementace také správně zpracovává porovnání přímých typů:

// 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();

Implementace rovnosti funguje také správně s kolekcemi:

// 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

Předchozí kód ukazuje klíčové prvky pro implementaci rovnosti na základě hodnot:

  • Virtuální Equals(object?) přepsání: Hlavní logika rovnosti se provádí ve virtuální Object.Equals metodě, která se volá bez ohledu na typ kompilace.
  • Kontrola typů modulu runtime: Použití this.GetType() != p.GetType() zajišťuje, že objekty různých typů se nikdy nepovažují za stejné.
  • Explicitní implementace rozhraní: Implementace System.IEquatable<T> deleguje na virtuální metodu, což brání problémům s výběrem typu kompilace.
  • Chráněná virtuální pomocná metoda: Metoda protected virtual Equals(TwoDPoint? p) umožňuje odvozené třídy přepsat logiku rovnosti při zachování bezpečnosti typů.

Tento model použijte v těchto případech:

  • Máte hierarchie dědičnosti, kde je důležitá rovnost hodnot.
  • Objekty mohou být použity polymorfně (deklarovány jako základní typ, vytvoření instance jako odvozený typ).
  • Potřebujete odkazové typy sémantikou rovnosti hodnot.

Upřednostňovaným přístupem je použití record typů k implementaci rovnosti na základě hodnot. Tento přístup vyžaduje složitější implementaci než standardní přístup a k zajištění správnosti vyžaduje důkladné testování polymorfních scénářů.

Příklad struktury

Následující příklad ukazuje, jak implementovat rovnost hodnot ve struktuře (typ hodnoty). I když struktury mají výchozí rovnost hodnot, vlastní implementace může zlepšit výkon:

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
    */
}

U struktur provádí standardní implementace Object.Equals(Object) (což je přepsaná verze v System.ValueType) kontrolu rovnosti hodnot pomocí reflexe k porovnání hodnot každého pole v typu. I když tato implementace vytváří správné výsledky, je relativně pomalé v porovnání s vlastní implementací, kterou píšete speciálně pro daný typ.

Při přetížení virtuální Equals metody ve struktuře je účelem poskytnout efektivnější metodu kontroly rovnosti hodnot a případně založit porovnání na určité podmnožině polí nebo vlastností struktury.

Operátory == a != nemohou pracovat se strukturou, pokud je struktura explicitně nepřetěžuje.

Viz také