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
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)); // FalseZmienne 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)); // TrueInaczej 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 |
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ę IEquatableEquals 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)); // FalseTo 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. EqualityComparerIEquatable, 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)); // FalseJeś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.
