• C# i .NET
  • Jak porównywać obiekty w C#? Equals, == i record

Jak porównywać obiekty w C#? Equals, == i record

Bruno Krawczyk 26 maja 2026
Grafika przedstawia pytanie "What are record types in C#/.NET?". Stylizowany symbol C# przypomina płytę winylową.

Spis treści

Dwie instancje mogą wyglądać identycznie, a mimo to C# uzna je za różne obiekty. W tym artykule pokazuję, jak świadomie porównywać obiekty za pomocą ==, Equals, ReferenceEquals i EqualityComparer, kiedy użyć rekordu, a kiedy napisać własną implementację równości.

Najważniejsze zasady porównywania obiektów w C#

  • == dla zwykłej klasy domyślnie sprawdza referencję, nie zawartość obiektu.
  • Equals może porównywać wartości, ale tylko wtedy, gdy typ definiuje odpowiednią semantykę.
  • ReferenceEquals sprawdza wyłącznie, czy dwie zmienne wskazują dokładnie ten sam obiekt.
  • record automatycznie zapewnia porównywanie wartości właściwość po właściwości.
  • Po nadpisaniu Equals trzeba zawsze poprawnie zaimplementować GetHashCode.

Najpierw ustal, co oznacza równość

Największe nieporozumienia wynikają z mieszania dwóch pojęć. Równość referencyjna oznacza, że dwie zmienne wskazują ten sam obiekt w pamięci. Równość wartości mówi natomiast, że dwa niezależne obiekty zawierają te same dane.

W przypadku zwykłej klasy C# domyślnie stosuje porównanie referencji. Dlatego poniższy kod wypisze False, mimo że oba obiekty mają identyczne właściwości.

public class User
{
    public int Id { get; init; }
    public string Name { get; init; } = "";
}

var first = new User { Id = 1, Name = "Anna" };
var second = new User { Id = 1, Name = "Anna" };

Console.WriteLine(first == second);       // False
Console.WriteLine(first.Equals(second));  // False

Zmienne przechowują dwa różne obiekty. Jeśli jednak przypiszemy pierwszą referencję do trzeciej zmiennej, oba wskazania będą prowadzić do tego samego miejsca.

var third = first;

Console.WriteLine(first == third);              // True
Console.WriteLine(ReferenceEquals(first, third)); // True

Inaczej zachowują się typy wartościowe. Dla liczb, krotek i wielu struktur porównanie zwykle dotyczy danych. String jest wyjątkiem wśród klas, ponieważ jego operator == porównuje treść tekstu, a nie referencję.

==, Equals i ReferenceEquals mają różne zadania

Nie traktuję tych metod jako zamienników. Każda odpowiada na inne pytanie, dlatego wybór powinien wynikać z intencji kodu.

Mechanizm Co sprawdza Kiedy go użyć
== Operator zdefiniowany przez typ, często referencję albo wartość Do czytelnego porównywania obiektów w kodzie aplikacji
Equals Logiczną równość obiektu Gdy typ udostępnia własną definicję równości
object.Equals Równość dwóch wartości, także z obsługą null Gdy oba argumenty mogą być puste
ReferenceEquals Tożsamość obiektu w pamięci Do diagnostyki i świadomego sprawdzania tej samej instancji
EqualityComparer.Default Reguły równości właściwe dla typu T W kolekcjach generycznych, słownikach i algorytmach

Metoda Equals jest wirtualna, więc może zostać nadpisana przez klasę. Samo jej wywołanie nie gwarantuje jednak porównania właściwości. Jeśli typ korzysta z implementacji odziedziczonej po object, nadal otrzymamy porównanie referencyjne.

Gdy porównywane wartości mogą być równe null, wygodną opcją jest object.Equals(a, b). Metoda poprawnie obsługuje sytuacje, w których oba argumenty są puste albo tylko jeden z nich ma wartość.

Jak zaimplementować równość wartości w klasie

Jeżeli obiekt reprezentuje wartość, na przykład identyfikator, adres albo klucz biznesowy, zwykłe porównanie referencji zazwyczaj nie wystarczy. W takim przypadku implementuję IEquatable, nadpisuję Equals i GetHashCode, a operatorów używam tylko wtedy, gdy poprawiają czytelność kodu.

public sealed class ProductKey : IEquatable
{
    public int Id { get; }
    public string Region { get; }

    public ProductKey(int id, string region)
    {
        Id = id;
        Region = region;
    }

    public bool Equals(ProductKey? other)
    {
        return other is not null
            && Id == other.Id
            && string.Equals(
                Region,
                other.Region,
                StringComparison.OrdinalIgnoreCase);
    }

    public override bool Equals(object? obj)
    {
        return Equals(obj as ProductKey);
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(
            Id,
            StringComparer.OrdinalIgnoreCase.GetHashCode(Region));
    }

    public static bool operator ==(
        ProductKey? left,
        ProductKey? right)
    {
        if (ReferenceEquals(left, right))
            return true;

        if (left is null || right is null)
            return false;

        return left.Equals(right);
    }

    public static bool operator !=(
        ProductKey? left,
        ProductKey? right)
    {
        return !(left == right);
    }
}

W tym przykładzie dwa klucze są równe, gdy mają ten sam identyfikator i region bez rozróżniania wielkości liter. To ważna decyzja biznesowa, a nie detal techniczny. Dla kodów, identyfikatorów i nazw technicznych zwykle wybieram StringComparison.Ordinal albo OrdinalIgnoreCase, zamiast porównań zależnych od bieżącej kultury systemu.

Nie można pominąć GetHashCode. Kolekcje takie jak HashSet i Dictionary używają kodu skrótu do znajdowania elementów. Jeżeli dwa obiekty są równe, muszą zwracać ten sam hash. Odwrotna zależność nie musi zachodzić, ponieważ różne obiekty mogą mieć kolizję.

Unikam też zmieniania pól używanych do równości po dodaniu obiektu do słownika. Jeśli klucz zmieni się już po zapisaniu, kolekcja może nie odnaleźć elementu, mimo że nadal znajduje się on w środku.

Kiedy record jest lepszy od ręcznej implementacji

Jeśli typ przede wszystkim przechowuje dane, najczęściej wybieram record. Kompilator generuje dla niego porównywanie wartości, GetHashCode, operatory == i != oraz implementację IEquatable.

public record CustomerId(int Value);

var first = new CustomerId(42);
var second = new CustomerId(42);

Console.WriteLine(first == second);             // True
Console.WriteLine(first.Equals(second));        // True
Console.WriteLine(ReferenceEquals(first, second)); // False

To dobre rozwiązanie dla obiektów typu DTO, parametrów poleceń, wyników zapytań i kluczy wartościowych. Nie używam rekordów automatycznie dla encji bazodanowych, ponieważ narzędzia takie jak Entity Framework Core często potrzebują tożsamości konkretnej instancji, a nie tylko zgodności jej właściwości.

Rekord nie wykonuje jednak głębokiego porównania każdej zagnieżdżonej struktury. Jeśli zawiera tablicę albo zwykłą listę, te kolekcje będą porównywane zgodnie z własnymi zasadami, zwykle przez referencję.

public record Playlist(string Name, List Tracks);

var first = new Playlist(
    "Chill",
    new List { "Song A", "Song B" });

var second = new Playlist(
    "Chill",
    new List { "Song A", "Song B" });

Console.WriteLine(first == second); // False

Obie listy mają tę samą zawartość, ale są dwiema różnymi instancjami. Gdy równość ma uwzględniać elementy kolekcji, trzeba użyć SequenceEqual, własnego porównywacza albo zaprojektować typ tak, aby przechowywał wartość z odpowiednią semantyką.

Porównywanie elementów w kolekcjach

W kolekcjach nie zawsze chcemy korzystać z domyślnej równości. EqualityComparer.Default najpierw uwzględnia implementację IEquatable, a w przeciwnym razie korzysta z Equals i GetHashCode typu.

var names = new HashSet(
    StringComparer.OrdinalIgnoreCase);

names.Add("Kasia");
names.Add("kasia");

Console.WriteLine(names.Count); // 1

Podanie StringComparer.OrdinalIgnoreCase jasno określa regułę działania zbioru. To lepsze niż liczenie na przypadkowe zachowanie domyślnego porównania, zwłaszcza gdy dane pochodzą z plików, API albo bazy danych.

Dla sekwencji ważne jest także to, czy liczy się kolejność elementów. SequenceEqual uzna dwie listy za równe tylko wtedy, gdy elementy występują w tej samej kolejności.

var first = new[] { 1, 2, 3 };
var second = new[] { 1, 2, 3 };
var third = new[] { 3, 2, 1 };

Console.WriteLine(first.SequenceEqual(second)); // True
Console.WriteLine(first.SequenceEqual(third));  // False

Jeśli kolejność nie ma znaczenia, lepszym narzędziem może być HashSet i porównanie zbiorów. W praktyce najpierw definiuję znaczenie równości danych, dopiero później wybieram metodę. Samo użycie SequenceEqual nie naprawi błędnej definicji domeny.

Błędy, które najczęściej psują porównywanie

  • Nadpisanie Equals bez GetHashCode powoduje problemy w słownikach i zbiorach.
  • Przeciążenie tylko operatora == prowadzi do rozbieżności między operatorem a metodą Equals.
  • Porównywanie klas przez == często sprawdza referencję, nawet gdy właściwości wyglądają identycznie.
  • Zmienne dane używane jako klucz mogą sprawić, że obiekt zniknie logicznie z kolekcji haszującej.
  • Tablice i listy w rekordach zwykle nie są porównywane element po elemencie.
  • Porównanie tekstu zależne od kultury może dawać inne wyniki na różnych komputerach.

W testach jednostkowych sprawdzam nie tylko pozytywny przypadek, ale też null, różne typy, różne wartości i użycie w HashSet. Dobra implementacja powinna być zwrotna, symetryczna i przechodnia, czyli nie może zależeć od kolejności porównania ani zmieniać wyniku bez zmiany danych.

Jeśli klasa ma skomplikowaną hierarchię dziedziczenia, ręczne definiowanie równości wymaga jeszcze większej ostrożności. W prostych obiektach wartościowych preferuję rekord albo klasę zapieczętowaną, ponieważ ogranicza to liczbę trudnych przypadków.

Najprostsza reguła wyboru dla codziennego kodu

Gdy porównuję encję, pytam o tożsamość obiektu i często używam referencji. Gdy porównuję dane, identyfikator lub parametr operacji, oczekuję równości wartości i wybieram rekord albo poprawnie zaimplementowane IEquatable.

Najważniejsza praktyczna zasada brzmi prosto: nie pytaj tylko, czy obiekty wyglądają tak samo. Ustal, co w danym modelu oznacza „ten sam”, a potem konsekwentnie dopasuj do tego ==, Equals, hash oraz zachowanie kolekcji.

FAQ - Najczęstsze pytania

Dla zwykłej klasy operator == domyślnie sprawdza referencję, a nie zawartość obiektu. Dwie instancje o identycznych właściwościach są więc różne, natomiast przypisanie jednej referencji do drugiej da wynik True.

Equals sprawdza logiczną równość zgodnie z implementacją typu, a ReferenceEquals wyłącznie tożsamość tej samej instancji. object.Equals(a, b) jest przydatne, gdy oba argumenty mogą mieć wartość null, ponieważ poprawnie obsługuje przypadki pustych referencji.

Record sprawdza się w typach przechowujących dane, takich jak DTO, parametry poleceń i identyfikatory, ponieważ automatycznie zapewnia równość wartości, GetHashCode, operatory == i != oraz IEquatable<T>. Własna implementacja jest potrzebna, gdy reguły równości wymagają szczegółowej logiki biznesowej albo niestandardowego porównania.

HashSet<T> i Dictionary<TKey, TValue> używają kodu skrótu do znajdowania elementów. Jeśli dwa obiekty są równe, muszą zwracać ten sam hash, dlatego po nadpisaniu Equals trzeba nadpisać także GetHashCode. Nie należy również zmieniać pól używanych do równości po dodaniu obiektu do kolekcji haszującej.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi

equals
rekordy
hashset
iequatable
gethashcode
Autor Bruno Krawczyk
Bruno Krawczyk
Mam na imię Bruno i od 8 lat zgłębiam tajniki programowania w ekosystemie .NET, chmury Azure oraz sztucznej inteligencji. Moja przygoda z technologią zaczęła się od ciekawości, jak złożone systemy mogą ułatwiać codzienne życie i rozwiązywać realne problemy. Dziś moją misją jest dzielenie się tą wiedzą, starając się przybliżyć nawet najbardziej skomplikowane zagadnienia w sposób zrozumiały i przystępny dla każdego. W moich artykułach na kursdotnet.pl skupiam się na praktycznych aspektach, analizuję najnowsze trendy i weryfikuję informacje, aby dostarczyć Wam treści, które są nie tylko dokładne i aktualne, ale przede wszystkim użyteczne w Waszej własnej ścieżce rozwoju technologicznego.

Udostępnij artykuł

Napisz komentarz