Udostępnij za pomocą


Jak zdefiniować równość wartości dla klasy lub struktury (Przewodnik programowania w języku C#)

Wskazówka

Najpierw rozważ użycie rekordów . Rekordy automatycznie implementują równość wartości przy minimalnym kodzie, co czyni je zalecanym podejściem dla większości typów skoncentrowanych na danych. Jeśli potrzebujesz niestandardowej logiki równości wartości lub nie możesz używać rekordów, wykonaj poniższe kroki implementacji ręcznej.

Podczas definiowania klasy lub struktury decydujesz, czy warto utworzyć niestandardową definicję równości wartości (lub równoważności) dla typu. Zazwyczaj należy zaimplementować równość wartości, gdy oczekuje się dodania obiektów typu do kolekcji lub gdy ich głównym celem jest przechowywanie zestawu pól lub właściwości. Definicję równości wartości można opierać na porównywaniu wszystkich pól i właściwości w typie lub można opierać definicję w podzestawie.

W obu przypadkach, a w obu klasach i strukturach implementacja powinna przestrzegać pięciu gwarancji równoważności (w przypadku następujących reguł przyjęto założenie, że xi yz nie ma wartości null):

  1. Właściwość refleksyjna: x.Equals(x) zwraca wartość true.

  2. Właściwość symetryczna: x.Equals(y) zwraca tę samą wartość co y.Equals(x).

  3. Właściwość przechodnia: jeśli (x.Equals(y) && y.Equals(z)) zwraca true, to x.Equals(z) zwraca true.

  4. Kolejne wywołania x.Equals(y) zwracają tę samą wartość, o ile obiekty, do których odwołuje się x i y, nie są modyfikowane.

  5. Każda wartość inna niż null nie jest równa null. x.Equals(y) Zgłasza jednak wyjątek, gdy x ma wartość null. To przerywa reguły 1 lub 2, w zależności od argumentu dla Equals.

Każda struktura, którą zdefiniujesz, ma już domyślną implementację równości wartości, którą dziedziczy z nadpisania metody System.ValueType. Ta implementacja używa odbicia w celu zbadania wszystkich pól i właściwości w typie. Mimo że ta implementacja generuje poprawne wyniki, jest stosunkowo wolna w porównaniu z implementacją niestandardową, pisaną specjalnie dla danego typu.

Szczegóły implementacji równości wartości różnią się w przypadku klas i struktur. Jednak obie klasy i struktury wymagają tych samych podstawowych kroków implementowania równości:

  1. Zastąp metodę wirtualną Object.Equals(Object). Zapewnia to zachowanie równości polimorficznej, co pozwala na poprawne porównywanie obiektów w przypadku traktowania ich jako object odwołań. Zapewnia prawidłowe zachowanie w kolekcjach i podczas korzystania z polimorfizmu. W większości przypadków implementacja bool Equals( object obj ) powinna po prostu wywołać metodę specyficzną dla typu Equals, która jest implementacją interfejsu System.IEquatable<T>. (Zobacz krok 2.)

  2. Zaimplementuj interfejs System.IEquatable<T> poprzez podanie specyficznej dla typu Equals metody. Zapewnia to bezpieczne sprawdzanie równości typu bez pola wyboru, co zapewnia lepszą wydajność. Pozwala również uniknąć niepotrzebnego rzutowania i umożliwia sprawdzanie typów w czasie kompilacji. W tym miejscu jest wykonywane rzeczywiste porównanie równoważności. Na przykład możesz zdecydować się na zdefiniowanie równości, porównując tylko jedno lub dwa pola w twoim typie. Nie zgłaszaj wyjątków z elementu Equals. W przypadku klas, które są powiązane przez dziedziczenie:

    • Ta metoda powinna sprawdzać tylko pola zadeklarowane w klasie. Powinno wywołać base.Equals, aby zbadać pola, które znajdują się w klasie bazowej. (Nie należy wywoływać base.Equals, jeśli typ dziedziczy bezpośrednio z Object, ponieważ implementacja Object w Object.Equals(Object) wykonuje sprawdzanie równości odwołań).

    • Dwie zmienne należy uznać za równe tylko wtedy, gdy porównywane typy zmiennych w czasie działania programu są takie same. Upewnij się również, że IEquatable implementacja Equals metody dla typu czasu wykonywania jest używana, jeśli typy czasu wykonywania i kompilowania zmiennej są inne. Jedną ze strategii upewnienia się, że typy czasu wykonywania są zawsze porównywane poprawnie, jest zaimplementowanie IEquatable tylko w sealed klasach. Aby uzyskać więcej informacji, zobacz przykład klasy w dalszej części tego artykułu.

  3. Opcjonalne, ale zalecane: przeciążenie operatorów == i !=. Zapewnia to spójną i intuicyjną składnię porównań równości, która odpowiada oczekiwaniom użytkowników z wbudowanych typów. Zapewnia to i obj1 == obj2obj1.Equals(obj2) zachowuje się tak samo.

  4. Zastąpij Object.GetHashCode tak, aby dwa obiekty, które mają równość wartości, tworzyły ten sam kod skrótu. Jest to wymagane do poprawnego zachowania w kolekcjach opartych na skrótach, takich jak Dictionary<TKey,TValue> i HashSet<T>. Obiekty, które są równe, muszą mieć równe kody skrótu lub te kolekcje nie będą działać poprawnie.

  5. Opcjonalnie: Aby obsługiwać definicje "większe niż" lub "mniejsze niż", zaimplementuj IComparable<T> interfejs dla danego typu, a także przeciąż <operatory = i >= . Umożliwia to operacje sortowania i zapewnia pełną relację porządkowania dla danego typu, przydatną podczas dodawania obiektów do posortowanych kolekcji lub sortowania tablic lub list.

Przykład rekordu

W poniższym przykładzie pokazano, jak rekordy automatycznie implementują równość wartości przy minimalnym kodzie. Pierwszy rekord to prosty typ rekordu TwoDPoint , który automatycznie implementuje równość wartości. Drugi rekord ThreeDPoint pokazuje, że rekordy mogą pochodzić z innych rekordów i nadal zachować prawidłowe zachowanie równości wartości:

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

Rekordy zapewniają kilka zalet równości wartości:

  • Implementacja automatyczna: rekordy automatycznie implementują System.IEquatable<T>Object.Equalsi zastępują operatory , Object.GetHashCodei ==/!= .
  • Poprawne zachowanie dziedziczenia: Rekordy implementują IEquatable<T> przy użyciu metod wirtualnych, które sprawdzają typ środowiska uruchomieniowego obu operandów, zapewniając prawidłowe zachowanie w hierarchiach dziedziczenia i scenariuszach polimorficznych.
  • Niezmienność domyślnie: Rekordy zachęcają do niezmiennego projektowania, który dobrze sprawdza się z semantyka równości wartości.
  • Zwięzła składnia: Parametry pozycyjne zapewniają kompaktowy sposób definiowania typów danych.
  • Lepsza wydajność: implementacja równości wygenerowana przez kompilator jest zoptymalizowana i nie używa odbicia, takiego jak domyślna implementacja struktury.

Użyj rekordów, gdy głównym celem jest przechowywanie danych i potrzeba semantyki równości wartości.

Rekordy z członkami używającymi równości odwołań

Gdy rekordy zawierają elementy członkowskie używające równości odwołań, zachowanie automatycznej równości wartości rekordów nie działa zgodnie z oczekiwaniami. Dotyczy to kolekcji, takich jak System.Collections.Generic.List<T>, tablice i inne typy odwołań, które nie implementują równości opartej na wartości (z godnym uwagi wyjątkiem System.String, który implementuje równość wartości).

Ważne

Rekordy zapewniają doskonałą równość wartości dla podstawowych typów danych, ale nie są automatycznie rozwiązywane równości wartości dla członków korzystających z równości odwołań. Jeśli rekord zawiera System.Collections.Generic.List<T>typy odwołań , System.Arraylub inne, które nie implementują równości wartości, dwa wystąpienia rekordów o identycznej zawartości w tych elementach członkowskich nadal nie będą równe, ponieważ członkowie używają równości odwołań.

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!

Dzieje się tak, ponieważ rekordy używają metody każdego elementu członkowskiego, a typy kolekcji zwykle używają Object.Equals równości odwołań, a nie porównywania ich zawartości.

Poniżej przedstawiono problem:

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

Oto jak działa to podczas uruchamiania kodu:

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

Rozwiązania dotyczące rekordów z elementami członkowskimi równości odwołań

  • Implementacja niestandardowaSystem.IEquatable<T>: zastąp równość wygenerowaną przez kompilator ręcznie zakodowaną wersją, która zapewnia porównanie oparte na zawartości dla elementów członkowskich równości odwołań. W przypadku kolekcji zaimplementuj porównanie elementów według elementów przy użyciu Enumerable.SequenceEqual lub podobnych metod.

  • Użyj typów wartości, jeśli to możliwe: rozważ, czy dane mogą być reprezentowane z typami wartości lub niezmiennymi strukturami, które naturalnie obsługują równość wartości, takie jak System.Numerics.Vector<T> lub Plane.

  • Użyj typów z równością opartą na wartościach: w przypadku kolekcji rozważ użycie typów, które implementują równość opartą na wartościach lub implementują niestandardowe typy kolekcji, które zastępują Object.Equals porównanie oparte na zawartości, takie jak System.Collections.Immutable.ImmutableArray<T> lub System.Collections.Immutable.ImmutableList<T>.

  • Projektowanie z uwzględnieniem równości odwołań: zaakceptuj, że niektórzy członkowie będą używać równości odwołań i odpowiednio zaprojektować logikę aplikacji, zapewniając ponowne użycie tych samych wystąpień, gdy równość jest ważna.

Oto przykład implementacji równości niestandardowej dla rekordów z kolekcjami:

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

Ta implementacja niestandardowa działa poprawnie:

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

Ten sam problem dotyczy tablic i innych typów kolekcji:

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

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

Tablice używają również równości odwołań, generując te same nieoczekiwane wyniki:

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

Nawet kolekcje readonly wykazują to zachowanie równości referencyjnej:

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

Kluczową analizą jest to, że rekordy rozwiązują problem równości strukturalnej , ale nie zmieniają semantycznego zachowania równości typów, które zawierają.

Przykład klasy

W poniższym przykładzie pokazano, jak zaimplementować równość wartości w klasie (typ referencyjny). Ta metoda ręczna jest wymagana, gdy nie można używać rekordów ani nie potrzebujesz niestandardowej logiki równości:

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

W przypadku klas (typów referencyjnych) domyślna implementacja obu Object.Equals(Object) metod wykonuje porównanie równości odwołań, a nie sprawdzanie równości wartości. Gdy implementator zastępuje metodę wirtualną, celem jest nadanie jej semantyki równości wartości.

Operatory == i != mogą być używane z klasami, nawet jeśli klasa ich nie przeciąża. Jednak domyślne zachowanie polega na sprawdzaniu równości referencji. W klasie, jeśli przeciążasz metodę Equals, powinieneś również przeciążyć operatory == i !=, chociaż nie jest to wymagane.

Ważne

Powyższy przykładowy kod może nie obsługiwać każdego scenariusza dziedziczenia w oczekiwany sposób. Spójrzmy na poniższy kod:

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

Ten kod raportuje, że p1 jest równe p2, pomimo różnicy w wartościach z. Różnica jest ignorowana, ponieważ kompilator wybiera implementację TwoDPointIEquatable na podstawie typu czasu kompilacji. Jest to podstawowy problem z równością polimorficzną w hierarchiach dziedziczenia.

Równość polimorficzna

Podczas implementowania równości wartości w hierarchiach dziedziczenia z klasami standardowe podejście pokazane w przykładzie klasy może prowadzić do nieprawidłowego zachowania, gdy obiekty są używane polimorficznie. Problem występuje, ponieważ System.IEquatable<T> implementacje są wybierane na podstawie typu czasu kompilacji, a nie typu środowiska uruchomieniowego.

Problem ze standardowymi implementacjami

Rozważmy ten problematyczny scenariusz:

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!

Porównanie zwraca True wartość, ponieważ kompilator wybiera TwoDPoint.Equals(TwoDPoint) na podstawie zadeklarowanego typu, ignorując różnice współrzędnych Z .

Kluczem do poprawienia równości polimorficznej jest zapewnienie, że wszystkie porównania równości używają metody wirtualnej Object.Equals , która może sprawdzać typy środowiska uruchomieniowego i prawidłowo obsługiwać dziedziczenie. Można to osiągnąć za pomocą jawnej implementacji interfejsu dla System.IEquatable<T> tego delegata do metody wirtualnej:

Klasa bazowa demonstruje kluczowe wzorce:

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

Klasa pochodna poprawnie rozszerza logikę równości:

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

Oto jak ta implementacja obsługuje problematyczne scenariusze polimorficzne:

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

Implementacja prawidłowo obsługuje również porównania typów bezpośrednich:

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

Implementacja równości działa również prawidłowo z kolekcjami:

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

Powyższy kod demonstruje kluczowe elementy do implementowania równości opartej na wartości:

  • Przesłonięcia wirtualneEquals(object?): główna logika równości odbywa się w metodzie wirtualnejObject.Equals, która jest wywoływana niezależnie od typu czasu kompilacji.
  • Sprawdzanie typu środowiska uruchomieniowego: użycie this.GetType() != p.GetType() zapewnia, że obiekty różnych typów nigdy nie są traktowane jako równe.
  • Implementacja jawnego interfejsu: implementacja System.IEquatable<T> deleguje do metody wirtualnej, uniemożliwiając problemy z wyborem typu czasu kompilacji.
  • Chroniona protected virtual Equals(TwoDPoint? p)metoda pomocnika wirtualnego: metoda umożliwia klasom pochodnym zastępowanie logiki równości przy zachowaniu bezpieczeństwa typów.

Użyj tego wzorca, gdy:

  • Istnieją hierarchie dziedziczenia, w których równość wartości jest ważna
  • Obiekty mogą być używane polimorficznie (zadeklarowane jako typ podstawowy, tworzone jako typ pochodny)
  • Potrzebne są typy odwołań z semantyka równości wartości

Preferowaną metodą jest użycie record typów w celu zaimplementowania równości opartej na wartości. Takie podejście wymaga bardziej złożonej implementacji niż standardowe podejście i wymaga dokładnego testowania scenariuszy polimorficznych w celu zapewnienia poprawności.

Przykład struktury

W poniższym przykładzie pokazano, jak zaimplementować równość wartości w ramach struktury (typu wartości). Chociaż struktury mają równość wartości domyślnych, implementacja niestandardowa może poprawić wydajność:

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

W przypadku struktur domyślna implementacja Object.Equals(Object) (która jest zastępowaną wersją w systemie System.ValueType) wykonuje sprawdzanie równości wartości przy użyciu odbicia w celu porównania wartości każdego pola w typie. Mimo że ta implementacja generuje poprawne wyniki, jest stosunkowo wolna w porównaniu z implementacją niestandardową, pisaną specjalnie dla danego typu.

Gdy zastąpisz metodę wirtualną Equals w strukturę, celem jest zapewnienie bardziej wydajnego sposobu sprawdzania równości wartości i opcjonalnego oparcia porównania na niektórych podzestawach pól lub właściwości struktury.

Operatory == i != nie mogą działać na strukturę, chyba że struktura jawnie je przeciąża.

Zobacz też