1. Introduzione
Quando programmiamo, confrontiamo sempre qualcosa: numeri, stringhe, oggetti. Ma cosa significa davvero "uguaglianza" di oggetti in C#? Non è sempre così ovvio come sembra. Oggi vediamo due metodi chiave che determinano come vengono confrontati gli oggetti: Equals() e GetHashCode(). Capire questi metodi è fondamentale per far funzionare bene i tuoi programmi, soprattutto quando usi collezioni come Dictionary o HashSet.
In C# ci sono due tipi principali di uguaglianza:
Uguaglianza di riferimento (Reference Equality):Significa che due variabili di tipo reference puntano allo stesso oggetto in memoria. Questo viene controllato con l'operatore == per i tipi reference.
MyClass obj1 = new MyClass();
MyClass obj2 = new MyClass();
MyClass obj3 = obj1;
Console.WriteLine(obj1 == obj2); // false (oggetti diversi in memoria)
Console.WriteLine(obj1 == obj3); // true (entrambi i riferimenti puntano allo stesso oggetto)
Uguaglianza di valore (Value Equality):
Significa che due oggetti diversi (o due tipi value) hanno lo stesso contenuto (valori dei loro campi/proprietà). Questo è quello che di solito vogliamo quando confrontiamo oggetti. Per questo si usa il metodo Equals().
// Supponiamo di avere una classe Point
Point p1 = new Point(10, 20);
Point p2 = new Point(10, 20);
Point p3 = new Point(30, 40);
// p1 e p2 sono oggetti diversi, ma vogliamo che siano "uguali" per valore
Console.WriteLine(p1.Equals(p2)); // ? Dipende dall'implementazione di Equals()
Console.WriteLine(p1.Equals(p3)); // ?
2. Il metodo Equals()
Il metodo Equals() è definito nella classe base System.Object, da cui ereditano tutti i tipi in C#. Il suo scopo principale è determinare se due oggetti sono uguali per valore.
Comportamento di default di Equals()
Per i tipi value (struct, int, bool ecc.): L'implementazione di Equals() di default (ereditata da System.ValueType) fa un confronto bit a bit di tutti i campi. Se tutti i campi sono uguali, gli oggetti sono considerati uguali. Di solito funziona come ci si aspetta.
Per i tipi reference (class, string, array ecc.): L'implementazione di Equals() di default (ereditata da System.Object) controlla l'uguaglianza di riferimento. Quindi obj1.Equals(obj2) di default restituisce true solo se obj1 e obj2 puntano allo stesso oggetto in memoria.class Person // Tipo reference
{
public string Name { get; set; }
public int Age { get; set; }
}
Person person1 = new Person { Name = "Alice", Age = 30 };
Person person2 = new Person { Name = "Alice", Age = 30 }; // Altro oggetto, stesso contenuto
Console.WriteLine(person1.Equals(person2)); // false (di default confronta i riferimenti)
Come vedi, il comportamento di default per i tipi reference spesso non è quello che vogliamo! Vorremmo che due "Alice" di 30 anni fossero considerate uguali, anche se sono oggetti diversi in memoria.
Override di Equals() per le tue classi
Per avere l'uguaglianza per valore nelle tue classi, devi fare l'override del metodo Equals().
Regole per l'override di Equals() (contratto):
- Riflessività: x.Equals(x) è sempre true.
- Simmetria: Se x.Equals(y) è true, allora anche y.Equals(x) deve essere true.
- Transitività: Se x.Equals(y) e y.Equals(z) sono entrambi true, allora anche x.Equals(z) deve essere true.
- Coerenza: Più chiamate a x.Equals(y) devono dare lo stesso risultato, finché gli oggetti non vengono modificati.
- Compatibilità con null: x.Equals(null) è sempre false.
Esempio: Override di Equals() per la classe Person
class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
// Override di Equals()
public override bool Equals(object? obj)
{
// 1. Controllo null
if (obj == null) return false;
// 2. Controllo tipo
if (obj.GetType() != this.GetType()) return false;
// 3. Cast
Person other = (Person)obj;
// 4. Confronto dei campi per valore
return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) &&
Age == other.Age;
}
}
// Uso:
Person person1 = new Person("Alice", 30);
Person person2 = new Person("Alice", 30);
Person person3 = new Person("Bob", 25);
Console.WriteLine(person1.Equals(person2)); // true (ora confronta per valore!)
Console.WriteLine(person1.Equals(person3)); // false
Console.WriteLine(person1.Equals(null)); // false
3. Il metodo GetHashCode()
Il metodo GetHashCode() è anch'esso definito in System.Object. Restituisce un valore intero (hash code) che identifica velocemente e in modo unico (per quanto possibile) un oggetto.
A cosa serve GetHashCode()?
Gli hash code vengono usati per ottimizzare il lavoro con le collezioni basate su hash table. Queste collezioni includono:
- Dictionary<TKey, TValue> (l'hash code viene usato per cercare velocemente la chiave)
- HashSet<T> (l'hash code viene usato per controllare l'unicità degli elementi)
- Hashtable
Quando aggiungi un oggetto a un HashSet o lo usi come chiave in un Dictionary, la collezione calcola prima l'hash code dell'oggetto. Questo le permette di "saltare" subito al "bucket" o gruppo di elementi che hanno lo stesso hash code, invece di scorrere tutti gli elementi. Poi, dentro quel "bucket", viene usato il metodo Equals() per il confronto preciso.
Regole per l'override di GetHashCode() (contratto):
Se fai l'override di Equals(), DEVI fare anche l'override di GetHashCode()! Questa è una delle regole più importanti in C#.
- Coerenza: Se Equals() restituisce true per due oggetti, allora GetHashCode() per questi oggetti deve restituire lo stesso valore. (Il contrario non è vero: oggetti diversi possono avere lo stesso hash code — si chiama "collisione".)
- Costanza: GetHashCode() deve restituire sempre lo stesso valore per lo stesso oggetto, finché i campi usati nel confronto non cambiano.
- Velocità: GetHashCode() deve essere veloce e non richiedere calcoli pesanti.
Perché è così importante? Se fai l'override di Equals() ma non di GetHashCode(), le tue collezioni (soprattutto quelle hash) funzioneranno male:
- Dictionary non troverà la tua chiave.
- HashSet aggiungerà duplicati, pensando che siano unici.
Questo succede perché di default GetHashCode() restituisce un hash basato sul riferimento dell'oggetto (per i tipi reference). Se Equals() ora confronta per valore, oggetti con lo stesso valore ma riferimenti diversi avranno hash code diversi, e la collezione non li "vedrà" come uguali.
Override di GetHashCode() per la classe Person
Buona pratica: genera l'hash code usando gli stessi campi che usi in Equals(). .NET fornisce il metodo statico HashCode.Combine(), molto comodo per questo.
class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
public override bool Equals(object? obj)
{
if (obj == null || obj.GetType() != this.GetType()) return false;
Person other = (Person)obj;
return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) &&
Age == other.Age;
}
// Override di GetHashCode()
public override int GetHashCode()
{
// Usiamo HashCode.Combine per unire gli hash dei campi.
return HashCode.Combine(Name.ToLowerInvariant(), Age);
}
}
// Uso in una collezione:
public class Program
{
public static void Main(string[] args)
{
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = new Person("Bob", 25);
HashSet
uniquePeople = new HashSet
(); uniquePeople.Add(p1); uniquePeople.Add(p2); // p2 è considerato uguale a p1 per valore, non verrà aggiunto Console.WriteLine($"Numero di persone uniche: {uniquePeople.Count}"); // Output: 1 uniquePeople.Add(p3); Console.WriteLine($"Numero di persone uniche: {uniquePeople.Count}"); // Output: 2 } }
Punto importante: In GetHashCode() per i campi stringa che confronti senza distinzione tra maiuscole e minuscole (StringComparison.OrdinalIgnoreCase in Equals), devi calcolare l'hash code in modo che non dipenda dal case (ad esempio, convertendo in minuscolo prima di calcolare l'hash, come Name.ToLowerInvariant()). Altrimenti Equals() restituirà true (Alice == alice), ma GetHashCode() restituirà valori diversi, rompendo il contratto.
4. Overload dell'operatore == e !=
Per le classi (tipi reference) l'operatore == di default controlla l'uguaglianza di riferimento. Puoi fare l'overload per controllare l'uguaglianza di valore, come fa Equals().
Regole per l'overload di ==:
- Se fai l'overload di ==, devi fare anche l'overload di !=.
- Si consiglia che l'operatore == abbia lo stesso comportamento di Equals().
- Devi anche fare l'override di GetHashCode() e Equals() quando fai l'overload di ==.
class Person
{
public string Name { get; set; }
public int Age { get; set; }
// Costruttore, Equals, GetHashCode come prima
// Overload dell'operatore ==
public static bool operator ==(Person? left, Person? right)
{
if (ReferenceEquals(left, null)) // Controllo se left è null
{
return ReferenceEquals(right, null); // Se entrambi sono null, sono uguali
}
return left.Equals(right); // Altrimenti usiamo il nostro Equals()
}
// Overload dell'operatore != (obbligatorio se fai l'overload di ==)
public static bool operator !=(Person? left, Person? right)
{
return !(left == right);
}
}
// Uso:
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = null;
Person p4 = null;
Console.WriteLine(p1 == p2); // true (ora usa l'operatore == overloadato)
Console.WriteLine(p1 == p3); // false
Console.WriteLine(p3 == p4); // true
5. record — uguaglianza automatica
Da C# 9 esiste il tipo record. È un tipo reference, ma implementa automaticamente l'uguaglianza per valore (e fa l'override di Equals(), GetHashCode(), ToString() e gli operatori ==/!=) su tutti i suoi campi/proprietà. Questo rende record perfetto per oggetti dati immutabili.
public record PersonRecord(string Name, int Age);
// Uso:
PersonRecord r1 = new PersonRecord("Bob", 25);
PersonRecord r2 = new PersonRecord("Bob", 25);
PersonRecord r3 = new PersonRecord("Charlie", 40);
Console.WriteLine(r1 == r2); // true (confronto automatico per valore!)
Console.WriteLine(r1.Equals(r2)); // true
Console.WriteLine(r1.GetHashCode() == r2.GetHashCode()); // true
Console.WriteLine(r1 == r3); // false
record semplifica molto la vita quando vuoi il comportamento di un tipo value per un tipo reference.
6. Consigli
Se fai l'override di Equals(), fai sempre anche l'override di GetHashCode()! Se non lo fai, le hash collection si comporteranno in modo imprevedibile.
Equals() e GetHashCode() devono usare gli stessi campi. I campi che rendono gli oggetti "uguali" per valore devono essere usati anche per calcolare l'hash.
Attento ai tipi mutabili. Se i campi usati in Equals() e GetHashCode() possono cambiare dopo la creazione dell'oggetto, l'hash code può cambiare. Questo è molto pericoloso per le hash collection, perché l'oggetto può "perdersi" dopo la modifica (l'hash code cambia e la collezione non lo trova più nel suo "bucket"). Per le hash collection è meglio usare tipi immutabili come chiavi o elementi.
Per oggetti dati immutabili considera l'uso di record. Questo semplifica molto l'implementazione dell'uguaglianza per valore.
Fai overload di == e != solo per tipi reference, quando ha senso. Per i tipi value == già confronta per valore. Se fai overload, assicurati che il comportamento sia uguale a Equals().
GO TO FULL VERSION