Condividi tramite


Come definire l'uguaglianza di valori per una classe o un struct (Guida per programmatori C#)

Suggerimento

È consigliabile usare prima i record . I record implementano automaticamente l'uguaglianza dei valori con codice minimo, rendendoli l'approccio consigliato per la maggior parte dei tipi incentrati sui dati. Se è necessaria una logica di uguaglianza dei valori personalizzata o non è possibile usare i record, continuare con la procedura di implementazione manuale riportata di seguito.

Quando si definisce una classe o uno struct, si decide se è opportuno creare una definizione personalizzata di uguaglianza di valore, o equivalenza, per il tipo. In genere, l'uguaglianza di valori viene implementata quando si prevede di aggiungere oggetti del tipo a una raccolta, o quando lo scopo principale consiste nell'archiviare un set di campi o di proprietà. È possibile basare la definizione di uguaglianza di valori su un confronto di tutti i campi e di tutte le proprietà nel tipo, oppure su un sottoinsieme.

In entrambi i casi e in entrambe le classi e gli struct, l'implementazione deve seguire le cinque garanzie di equivalenza (per le regole seguenti, si supponga che x, y e z non siano Null):

  1. Proprietà riflessiva: x.Equals(x) restituisce true.

  2. Proprietà simmetrica: x.Equals(y) restituisce lo stesso valore di y.Equals(x).

  3. Proprietà transitiva: se (x.Equals(y) && y.Equals(z)) restituisce true, x.Equals(z) restituisce true.

  4. Le successive chiamate di x.Equals(y) restituiscono lo stesso valore purché gli oggetti a cui x e y fanno riferimento non vengano modificati.

  5. Qualsiasi valore non Null non è uguale a Null. Tuttavia, x.Equals(y) genera un'eccezione quando x è Null. Che viola le regole 1 o 2, a seconda dell'argomento fornito a Equals.

Gli struct definiti hanno già un'implementazione predefinita di uguaglianza di valore che eredita dall'override System.ValueType del metodo Object.Equals(Object). Questa implementazione utilizza la reflection per esaminare tutti i campi e le proprietà del tipo. Sebbene questa implementazione produca risultati corretti, è relativamente lenta rispetto a un'implementazione personalizzata che viene scritta specificamente per il tipo.

I dettagli di implementazione per l'uguaglianza di valori sono diversi per le classi e gli struct. Tuttavia, sia le classi che gli struct richiedono gli stessi passaggi di base per l'implementazione dell'uguaglianza:

  1. Sovrascrivere il metodo virtualObject.Equals(Object). In questo modo viene fornito un comportamento di uguaglianza polimorfica, consentendo di confrontare correttamente gli oggetti quando vengono considerati come object riferimenti. Garantisce un comportamento corretto nelle raccolte e quando si usa il polimorfismo. Nella maggior parte dei casi l'implementazione di bool Equals( object obj ) deve solo chiamare il metodo Equals specifico per il tipo, che è l'implementazione dell'interfaccia System.IEquatable<T>. (Vedere il passaggio 2.)

  2. Implementare l'interfaccia System.IEquatable<T> definendo un metodo Equals specifico per il tipo. Ciò garantisce un controllo di uguaglianza indipendente dai tipi senza boxing, con prestazioni migliori. Evita anche il cast non necessario e abilita il controllo dei tipi in fase di compilazione. È in questo passaggio che viene eseguito il confronto di equivalenza effettivo. Ad esempio, è possibile definire l'uguaglianza confrontando solo uno o due campi nel tipo. Non lanciare eccezioni da Equals. Per le classi correlate dall'ereditarietà:

    • questo metodo deve esaminare solo i campi che vengono dichiarati nella classe. Deve chiamare base.Equals per esaminare i campi presenti nella classe di base. Non chiamare base.Equals se il tipo eredita direttamente da Object, perché l'implementazione Object di Object.Equals(Object) esegue un controllo di uguaglianza dei riferimenti.

    • Due variabili devono essere considerate uguali solo se i tipi in fase di esecuzione delle variabili confrontate sono uguali. Assicurarsi inoltre che l'implementazione IEquatable del metodo Equals per il tipo in fase di esecuzione venga usata se i tipi in fase di esecuzione e in fase di compilazione di una variabile sono diversi. Una strategia per assicurarsi che i tipi in fase di esecuzione siano sempre confrontati correttamente consiste nell'implementare IEquatable solo nelle classi sealed. Per ulteriori informazioni, vedere l’esempio di classe più avanti in questo articolo.

  3. Facoltativo ma consigliato: sovraccaricare gli operatori == e !=. In questo modo è disponibile una sintassi coerente e intuitiva per confronti di uguaglianza, che corrispondono alle aspettative degli utenti dai tipi predefiniti. Garantisce che obj1 == obj2 e obj1.Equals(obj2) si comportino allo stesso modo.

  4. Eseguire l'override di Object.GetHashCode in modo che due oggetti con uguaglianza di valori producano lo stesso codice hash. Questa operazione è necessaria per il comportamento corretto nelle raccolte basate su hash, ad esempio Dictionary<TKey,TValue> e HashSet<T>. Gli oggetti uguali devono avere codici hash uguali oppure queste raccolte non funzioneranno correttamente.

  5. Facoltativo: per supportare le definizioni di "maggiore di" o "minore di", implementare l'interfaccia IComparable<T> per il tipo e effettuare anche l'overload ai operatori <= e >=. Ciò consente operazioni di ordinamento e fornisce una relazione di ordinamento completa per il tipo, utile quando si aggiungono oggetti a raccolte ordinate o quando si ordinano matrici o elenchi.

Esempio di record

Nell'esempio seguente viene illustrato come i record implementano automaticamente l'uguaglianza dei valori con codice minimo. Il primo record TwoDPoint è un tipo di record semplice che implementa automaticamente l'uguaglianza dei valori. Il secondo record ThreeDPoint dimostra che i record possono essere derivati da altri record e mantenere comunque un comportamento di uguaglianza dei valori appropriato:

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

I record offrono diversi vantaggi per l'uguaglianza dei valori:

  • Implementazione automatica: i record implementano ed eseguono System.IEquatable<T> automaticamente l'override Object.EqualsObject.GetHashCode==/degli operatori , !=e .
  • Corretto comportamento di ereditarietà: i record implementano IEquatable<T> usando metodi virtuali che controllano il tipo di runtime di entrambi gli operandi, garantendo un comportamento corretto nelle gerarchie di ereditarietà e negli scenari polimorfici.
  • Immutabilità per impostazione predefinita: i record incoraggiano la progettazione non modificabile, che funziona bene con la semantica di uguaglianza dei valori.
  • Sintassi concisa: i parametri posizionali offrono un modo compatto per definire i tipi di dati.
  • Prestazioni migliori: l'implementazione dell'uguaglianza generata dal compilatore è ottimizzata e non usa reflection come l'implementazione dello struct predefinito.

Usare i record quando l'obiettivo principale consiste nell'archiviare i dati ed è necessaria la semantica di uguaglianza dei valori.

Record con membri che usano l'uguaglianza dei riferimenti

Quando i record contengono membri che usano l'uguaglianza dei riferimenti, il comportamento automatico di uguaglianza dei valori dei record non funziona come previsto. Questo vale per le raccolte come System.Collections.Generic.List<T>, matrici e altri tipi di riferimento che non implementano l'uguaglianza basata su valori ( con l'eccezione notevole di , che implementa l'uguaglianza dei System.Stringvalori).

Importante

Sebbene i record forniscano un'eccellente uguaglianza di valori per i tipi di dati di base, non risolvono automaticamente l'uguaglianza dei valori per i membri che usano l'uguaglianza dei riferimenti. Se un record contiene un System.Collections.Generic.List<T>tipo di riferimento , System.Arrayo altri tipi di riferimento che non implementano l'uguaglianza dei valori, due istanze di record con contenuto identico in tali membri non saranno ancora uguali perché i membri usano l'uguaglianza dei riferimenti.

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!

Ciò è dovuto al fatto che i record usano il Object.Equals metodo di ogni membro e i tipi di raccolta usano in genere l'uguaglianza dei riferimenti anziché confrontare il relativo contenuto.

Di seguito viene illustrato il problema:

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

Ecco come si comporta quando si esegue il codice:

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

Soluzioni per i record con membri di uguaglianza dei riferimenti

  • Implementazione personalizzataSystem.IEquatable<T>: sostituire l'uguaglianza generata dal compilatore con una versione codificata a mano che fornisce un confronto basato sul contenuto per i membri di uguaglianza dei riferimenti. Per le raccolte, implementare il confronto tra elementi per elemento usando Enumerable.SequenceEqual o metodi simili.

  • Usare i tipi valore dove possibile: valutare se i dati possono essere rappresentati con tipi di valore o strutture non modificabili che supportano naturalmente l'uguaglianza dei valori, ad esempio System.Numerics.Vector<T> o Plane.

  • Usare i tipi con uguaglianza basata su valori: per le raccolte è consigliabile usare tipi che implementano l'uguaglianza basata su valori o implementare tipi di raccolta personalizzati che eseguono l'override Object.Equals per fornire un confronto basato su contenuto, ad esempio System.Collections.Immutable.ImmutableArray<T> o System.Collections.Immutable.ImmutableList<T>.

  • Progettare tenendo presente l'uguaglianza dei riferimenti: accettare che alcuni membri useranno l'uguaglianza dei riferimenti e progettare di conseguenza la logica dell'applicazione, assicurandosi di riutilizzare le stesse istanze quando l'uguaglianza è importante.

Ecco un esempio di implementazione dell'uguaglianza personalizzata per i record con raccolte:

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

Questa implementazione personalizzata funziona correttamente:

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

Lo stesso problema riguarda le matrici e altri tipi di raccolta:

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

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

Le matrici usano anche l'uguaglianza dei riferimenti, generando gli stessi risultati imprevisti:

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

Anche le raccolte readonly mostrano questo comportamento di uguaglianza dei riferimenti:

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

Le informazioni dettagliate principali sono che i record risolvono il problema di uguaglianza strutturale , ma non modificano il comportamento di uguaglianza semantica dei tipi che contengono.

Esempio di classe

Nell'esempio seguente viene illustrato come implementare l'uguaglianza di valori in una classe (tipo riferimento). Questo approccio manuale è necessario quando non è possibile usare record o richiedere logica di uguaglianza personalizzata:

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

Nelle classi (tipi riferimento) l'implementazione predefinita di entrambi i metodi Object.Equals(Object) esegue un confronto di uguaglianza dei riferimenti, non un controllo di uguaglianza dei valori. Quando un responsabile dell'implementazione esegue l'override del metodo virtuale, lo scopo è assegnare a tale metodo una semantica di uguaglianza di valore.

Gli operatori == e != possono essere usati con le classi anche se la classe non ne esegue l'overload. Tuttavia, il comportamento predefinito è eseguire una verifica dell'uguaglianza dei riferimenti. Se in una classe si esegue l'overload del metodo Equals, è consigliabile eseguire l'overload degli operatori == e !=, ma non è obbligatorio.

Importante

Il codice di esempio precedente potrebbe non gestire tutti gli scenari di ereditarietà nel modo previsto. Osservare il codice seguente:

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

Questo codice segnala che p1 è uguale a p2 nonostante la differenza nei valori z. La differenza viene ignorata perché il compilatore seleziona l'implementazione TwoDPoint di IEquatable in base al tipo in fase di compilazione. Si tratta di un problema fondamentale con l'uguaglianza polimorfica nelle gerarchie di ereditarietà.

Uguaglianza polimorfica

Quando si implementa l'uguaglianza di valori nelle gerarchie di ereditarietà con le classi, l'approccio standard illustrato nell'esempio di classe può causare un comportamento errato quando gli oggetti vengono usati in modo polimorfico. Il problema si verifica perché System.IEquatable<T> le implementazioni vengono scelte in base al tipo in fase di compilazione, non al tipo di runtime.

Problema con le implementazioni standard

Si consideri questo scenario problematico:

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!

Il confronto restituisce True perché il compilatore seleziona TwoDPoint.Equals(TwoDPoint) in base al tipo dichiarato, ignorando le differenze di Z coordinate.

La chiave per correggere l'uguaglianza polimorfica è garantire che tutti i confronti di uguaglianza usino il metodo virtuale Object.Equals , che può controllare i tipi di runtime e gestire correttamente l'ereditarietà. A tale scopo, è possibile usare l'implementazione esplicita dell'interfaccia per System.IEquatable<T> i delegati al metodo virtuale:

La classe base illustra i modelli chiave:

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

La classe derivata estende correttamente la logica di uguaglianza:

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

Ecco come questa implementazione gestisce gli scenari polimorfici problematici:

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

L'implementazione gestisce correttamente anche i confronti dei tipi diretti:

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

L'implementazione di uguaglianza funziona anche correttamente con le raccolte:

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

Il codice precedente illustra gli elementi chiave per implementare l'uguaglianza basata su valori:

  • Override virtualeEquals(object?): la logica di uguaglianza principale si verifica nel metodo virtualeObject.Equals, che viene chiamato indipendentemente dal tipo di fase di compilazione.
  • Controllo dei tipi di runtime: l'uso this.GetType() != p.GetType() di garantisce che gli oggetti di tipi diversi non vengano mai considerati uguali.
  • Implementazione esplicita dell'interfaccia: System.IEquatable<T> delega l'implementazione al metodo virtuale, impedendo problemi di selezione dei tipi in fase di compilazione.
  • Metodo helper virtuale protetto: il protected virtual Equals(TwoDPoint? p) metodo consente alle classi derivate di eseguire l'override della logica di uguaglianza mantenendo al tempo stesso la sicurezza dei tipi.

Usare questo modello quando:

  • Si dispone di gerarchie di ereditarietà in cui l'uguaglianza dei valori è importante
  • Gli oggetti possono essere usati in modo polimorfico (dichiarato come tipo di base, creata un'istanza come tipo derivato)
  • Sono necessari tipi di riferimento con semantica di uguaglianza dei valori

L'approccio preferito consiste nell'usare record i tipi per implementare l'uguaglianza basata su valori. Questo approccio richiede un'implementazione più complessa rispetto all'approccio standard e richiede test approfonditi di scenari polimorfici per garantire la correttezza.

Esempio di struttura

Nell'esempio seguente viene illustrato come implementare l'uguaglianza dei valori in uno struct (tipo valore). Sebbene gli struct abbiano l'uguaglianza dei valori predefiniti, un'implementazione personalizzata può migliorare le prestazioni:

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

Per gli struct, l'implementazione predefinita di Object.Equals(Object), che è la versione sottoposta a override in System.ValueType, esegue un controllo di uguaglianza dei valori usando la reflection per confrontare i valori di ogni campo nel tipo. Sebbene questa implementazione produca risultati corretti, è relativamente lenta rispetto a un'implementazione personalizzata che viene scritta specificamente per il tipo.

Quando si esegue l'override del metodo virtuale Equals in uno struct, lo scopo consiste nel fornire un mezzo più efficiente per eseguire il controllo di uguaglianza dei valori e, facoltativamente, per basare il confronto su alcuni subset dei campi o delle proprietà dello struct.

Gli operatori == e != non possono funzionare con uno struct a meno che lo struct non ne esegua esplicitamente l'overload.

Vedi anche