CodeGym /Kursy /C# SELF /Porównywanie obiektów w C#:

Porównywanie obiektów w C#: Equals i GetHashCode()

C# SELF
Poziom 34 , Lekcja 3
Dostępny

1. Wprowadzenie

W programowaniu cały czas coś porównujemy: liczby, stringi, obiekty. Ale co tak naprawdę oznacza "równość" obiektów w C#? To nie zawsze jest takie oczywiste, jak się wydaje. Dzisiaj ogarniemy dwa kluczowe metody, które decydują, jak obiekty są porównywane: Equals() i GetHashCode(). Zrozumienie tych metod jest mega ważne dla poprawnego działania twoich programów, szczególnie gdy używasz kolekcji takich jak Dictionary albo HashSet.

W C# są dwa główne rodzaje równości:

Równość referencji (Reference Equality):

Oznacza, że dwie zmienne typu referencyjnego wskazują na ten sam obiekt w pamięci. To sprawdza operator == dla typów referencyjnych.

MyClass obj1 = new MyClass();
MyClass obj2 = new MyClass();
MyClass obj3 = obj1;

Console.WriteLine(obj1 == obj2); // false (różne obiekty w pamięci)
Console.WriteLine(obj1 == obj3); // true (obie referencje wskazują na ten sam obiekt)

Równość wartości (Value Equality):

Oznacza, że dwa różne obiekty (albo dwa typy wartościowe) mają taką samą zawartość (wartości swoich pól/właściwości). To jest to, co zwykle chcemy uzyskać porównując obiekty. Do tego używa się metody Equals().

// Załóżmy, że mamy klasę Point
Point p1 = new Point(10, 20);
Point p2 = new Point(10, 20);
Point p3 = new Point(30, 40);

// p1 i p2 — różne obiekty, ale chcemy, żeby były "równe" pod względem wartości
Console.WriteLine(p1.Equals(p2)); // ? Zależy od implementacji Equals()
Console.WriteLine(p1.Equals(p3)); // ?

2. Metoda Equals()

Metoda Equals() jest zdefiniowana w bazowej klasie System.Object, po której dziedziczą wszystkie typy w C#. Jej główne zadanie to określić, czy dwa obiekty są równe pod względem wartości.

Domyślne zachowanie Equals()

Dla typów wartościowych (struct, int, bool itd.): Domyślna implementacja Equals() (odziedziczona po System.ValueType) robi porównanie bit po bicie wszystkich pól. Jeśli wszystkie pola są równe, obiekty są uznawane za równe. To zwykle działa tak, jak się spodziewasz.

Dla typów referencyjnych (class, string, array itd.): Domyślna implementacja Equals() (odziedziczona po System.Object) sprawdza równość referencji. Czyli obj1.Equals(obj2) domyślnie zwróci true tylko jeśli obj1 i obj2 wskazują na ten sam obiekt w pamięci.
class Person // Typ referencyjny
{
    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 }; // Inny obiekt, ale ta sama zawartość

Console.WriteLine(person1.Equals(person2)); // false (domyślnie porównuje referencje)

Jak widzisz, domyślne zachowanie dla typów referencyjnych często nie jest tym, czego chcemy! Chcemy, żeby dwie "Alice" z wiekiem 30 były uznane za równe, nawet jeśli to różne obiekty w pamięci.

Nadpisywanie Equals() dla własnych klas

Żeby zapewnić równość pod względem wartości dla własnych klas, musisz nadpisać metodę Equals().

Zasady nadpisywania Equals() (kontrakt):

  • Refleksyjność: x.Equals(x) zawsze true.
  • Symetryczność: Jeśli x.Equals(y) to true, to y.Equals(x) też musi być true.
  • Tranzytywność: Jeśli x.Equals(y) i y.Equals(z) oba true, to x.Equals(z) też musi być true.
  • Spójność: Wielokrotne wywołania x.Equals(y) powinny dawać ten sam wynik, dopóki obiekty się nie zmienią.
  • Null-kompatybilność: x.Equals(null) zawsze false.

Przykład: Nadpisanie Equals() dla klasy Person

class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }

    // Nadpisanie Equals()
    public override bool Equals(object? obj)
    {
        // 1. Sprawdzenie na null
        if (obj == null) return false;

        // 2. Sprawdzenie typu
        if (obj.GetType() != this.GetType()) return false; 

        // 3. Rzutowanie typu
        Person other = (Person)obj;

        // 4. Porównanie pól po wartości
        return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) && 
               Age == other.Age;
    }
}

// Użycie:
Person person1 = new Person("Alice", 30);
Person person2 = new Person("Alice", 30);
Person person3 = new Person("Bob", 25);

Console.WriteLine(person1.Equals(person2)); // true (teraz porównuje po wartości!)
Console.WriteLine(person1.Equals(person3)); // false
Console.WriteLine(person1.Equals(null));    // false

3. Metoda GetHashCode()

Metoda GetHashCode() też jest zdefiniowana w System.Object. Zwraca wartość całkowitą (hash code), która szybko i unikalnie (na ile się da) identyfikuje obiekt.

Po co jest GetHashCode()?

Hash code'y są używane do optymalizacji pracy z kolekcjami opartymi na hash-tabelach. Do takich kolekcji należą:

  • Dictionary<TKey, TValue> (hash code jest używany do szybkiego wyszukiwania klucza)
  • HashSet<T> (hash code jest używany do sprawdzania unikalności elementów)
  • Hashtable

Kiedy dodajesz obiekt do HashSet albo używasz go jako klucza w Dictionary, kolekcja najpierw liczy hash code obiektu. Dzięki temu od razu "skacze" do konkretnego "koszyka" lub grupy elementów, które mają ten sam hash code, zamiast przeglądać wszystkie elementy. Potem, w tym "koszyku", używa się już metody Equals() do dokładnego porównania.

Zasady nadpisywania GetHashCode() (kontrakt):

Jeśli nadpisujesz Equals(), MUSISZ też nadpisać GetHashCode()! To jedna z najważniejszych zasad C#.

  • Spójność: Jeśli Equals() zwraca true dla dwóch obiektów, to GetHashCode() dla tych obiektów musi zwracać tę samą wartość. (Odwrotność nie jest wymagana: różne obiekty mogą mieć ten sam hash code — to się nazywa "kolizja".)
  • Stałość: GetHashCode() powinien zwracać tę samą wartość dla tego samego obiektu, dopóki jego pola używane w porównaniu się nie zmienią.
  • Szybkość: GetHashCode() powinien być szybki i nie wymagać dużych obliczeń.

Dlaczego to takie ważne? Jeśli nadpiszesz Equals(), ale nie GetHashCode(), twoje kolekcje (szczególnie hash-kolekcje) będą działać źle:

  • Dictionary nie znajdzie twojego klucza.
  • HashSet będzie dodawał duplikaty, bo będzie myślał, że są unikalne.

To się dzieje, bo domyślnie GetHashCode() zwraca hash oparty na referencji obiektu (dla typów referencyjnych). Jeśli Equals() teraz porównuje po wartości, to obiekty z tą samą wartością, ale różnymi referencjami, będą miały różne hash code'y i kolekcja nie "zobaczy" ich jako takie same.

Nadpisywanie GetHashCode() dla klasy Person

Dobrą praktyką jest generowanie hash code na podstawie tych samych pól, które są używane w Equals(). .NET daje statyczną metodę HashCode.Combine(), która jest do tego bardzo wygodna.

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

    // Nadpisanie GetHashCode()
    public override int GetHashCode()
    {
        // Używamy HashCode.Combine do połączenia hashy pól.
        return HashCode.Combine(Name.ToLowerInvariant(), Age); 
    }
}

// Użycie w kolekcji:
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 jest uznany za równego p1 pod względem wartości, nie zostanie dodany Console.WriteLine($"Liczba unikalnych osób: {uniquePeople.Count}"); // Wyjście: 1 uniquePeople.Add(p3); Console.WriteLine($"Liczba unikalnych osób: {uniquePeople.Count}"); // Wyjście: 2 } } 
   
  

Ważna sprawa: W GetHashCode() dla pól stringowych, które porównujesz bez uwzględniania wielkości liter (StringComparison.OrdinalIgnoreCase w Equals), musisz uzyskać hash code w taki sposób, żeby też nie zależał od wielkości liter (np. zamienić na małe litery przed liczeniem hash, jak Name.ToLowerInvariant()). Inaczej Equals() zwróci true (Alice == alice), a GetHashCode() zwróci różne wartości, łamiąc kontrakt.

4. Przeciążanie operatora == i !=

Dla klas (typów referencyjnych) operator == domyślnie sprawdza równość referencji. Możesz go przeciążyć, żeby sprawdzał równość wartości, podobnie jak Equals().

Zasady przeciążania ==:

  • Jeśli przeciążasz ==, musisz też przeciążyć !=.
  • Zaleca się, żeby przeciążony == miał takie samo zachowanie jak Equals().
  • Trzeba też nadpisać GetHashCode() i Equals() przy przeciążaniu ==.
class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    // Konstruktor, Equals, GetHashCode jak wcześniej

    // Przeciążenie operatora ==
    public static bool operator ==(Person? left, Person? right)
    {
        if (ReferenceEquals(left, null)) // Sprawdzamy, czy left to null
        {
            return ReferenceEquals(right, null); // Jeśli oba null, to równe
        }
        return left.Equals(right); // W innym wypadku używamy naszego nadpisanego Equals()
    }

    // Przeciążenie operatora != (obowiązkowe przy przeciążaniu ==)
    public static bool operator !=(Person? left, Person? right)
    {
        return !(left == right);
    }
}

// Użycie:
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = null;
Person p4 = null;

Console.WriteLine(p1 == p2); // true (teraz używa przeciążonego ==)
Console.WriteLine(p1 == p3); // false
Console.WriteLine(p3 == p4); // true

5. record — automatyczna równość

Od C# 9 pojawił się typ record. To typ referencyjny, ale automatycznie realizuje równość po wartości (i nadpisuje Equals(), GetHashCode(), ToString() i operatory ==/!=) na podstawie wszystkich swoich pól/właściwości. To sprawia, że record jest idealny dla niezmiennych obiektów-danych.

public record PersonRecord(string Name, int Age);

// Użycie:
PersonRecord r1 = new PersonRecord("Bob", 25);
PersonRecord r2 = new PersonRecord("Bob", 25);
PersonRecord r3 = new PersonRecord("Charlie", 40);

Console.WriteLine(r1 == r2); // true (automatyczne porównanie po wartości!)
Console.WriteLine(r1.Equals(r2)); // true
Console.WriteLine(r1.GetHashCode() == r2.GetHashCode()); // true
Console.WriteLine(r1 == r3); // false

record mega ułatwia życie, gdy potrzebujesz zachowania typu wartościowego dla typu referencyjnego.

6. Rekomendacje

Jeśli nadpisujesz Equals(), zawsze nadpisuj też GetHashCode()! Złamanie tej zasady prowadzi do nieprzewidywalnego zachowania w hash-kolekcjach.

Equals() i GetHashCode() powinny używać tych samych pól. Te pola, które sprawiają, że obiekty są "równe" pod względem wartości, muszą być używane do liczenia hash.

Uważaj na typy zmienne (mutable). Jeśli pola używane w Equals() i GetHashCode() mogą się zmieniać po utworzeniu obiektu, hash code obiektu może się zmienić. To bardzo złe dla hash-kolekcji, bo obiekt może się "zgubić" po zmianie (jego hash code się zmieni i kolekcja nie znajdzie go w swoim "koszyku"). Dla hash-kolekcji lepiej używać niezmiennych (immutable) typów jako kluczy lub elementów.

Dla niezmiennych obiektów-danych rozważ użycie record. To bardzo upraszcza implementację równości po wartości.

Przeciążaj == i != tylko dla typów referencyjnych, gdy to ma sens. Dla typów wartościowych == już porównuje po wartości. Jeśli przeciążasz, upewnij się, że zachowanie jest zgodne z Equals().

2
Zadanie
C# SELF, poziom 34, lekcja 3
Niedostępne
Użycie metody `Equals` do porównywania obiektów
Użycie metody `Equals` do porównywania obiektów
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION