Equals i GetHashCode w C# — fotel sprzedany dwa razy

Ania kupuje bilet do kina: rząd 7, miejsce 12. Minutę później Bartek kupuje bilet na dokładnie to samo miejsce — i program kasy mu go sprzedaje. A przecież program sprawdza, czy miejsce jest wolne. To sprawdzenie naprawdę się wykonuje. I mimo to przepuszcza Bartka.
Ten wpis to wersja tekstowa odcinka Program .NET Developer #2 na YouTube. Kod jest ten sam co w filmie, a każdy wynik uruchomiłem na .NET 10. Jeśli piszesz własne klasy i wkładasz ich obiekty do list albo zbiorów, ten błąd może już być w twoim kodzie. Zakładam, że znasz podstawy C# (zmienne, if, metody) — wszystko, co dotyczy programowania obiektowego, wyjaśniam po drodze.
Kasa, która sprzedaje to samo miejsce dwa razy
Zacznijmy od kodu kasy:
var sold = new List<Seat>();
SellTicket(new Seat(7, 12), "Ania");
SellTicket(new Seat(7, 12), "Bartek");
void SellTicket(Seat seat, string buyer)
{
if (sold.Contains(seat))
{
Console.WriteLine($"{buyer}: rząd {seat.Row}, miejsce {seat.Number} — ZAJĘTE");
return;
}
sold.Add(seat);
Console.WriteLine($"{buyer}: rząd {seat.Row}, miejsce {seat.Number} — SPRZEDANE");
}
class Seat
{
public int Row { get; }
public int Number { get; }
public Seat(int row, int number)
{
Row = row;
Number = number;
}
}Przeczytajmy go od dołu. Seat po angielsku znaczy „miejsce” albo „fotel”. Klasa Seat to przepis na obiekt: każde miejsce w kinie ma numer rzędu (Row) i numer miejsca (Number). Oba numery podajemy przy tworzeniu miejsca, w konstruktorze.
Na górze jest lista sold, czyli lista sprzedanych miejsc. Na początku jest pusta. Metoda SellTicket („sprzedaj bilet”) dostaje miejsce i imię kupującego. Najpierw pyta listę: czy masz już to miejsce? Do tego służy metoda Contains, po polsku „zawiera”. Jeśli lista odpowie „tak” — wypisujemy ZAJĘTE. Jeśli „nie” — dopisujemy miejsce do listy i wypisujemy SPRZEDANE.
Ania i Bartek kupują rząd 7, miejsce 12. Wynik:
Ania: rząd 7, miejsce 12 — SPRZEDANE
Bartek: rząd 7, miejsce 12 — SPRZEDANEKiedy przyszedł Bartek, lista odpowiedziała: „nie mam tego miejsca”. A przecież miejsce Ani dopisaliśmy do niej chwilę wcześniej. Program się nie wywrócił, nie było żadnego błędu ani ostrzeżenia. Po prostu sprzedał jedno miejsce dwóm osobom.
Zasada: to, że sprawdzenie jest w kodzie, nie znaczy jeszcze, że działa. Trzeba wiedzieć, jak porównuje obiekty.
Jak C# porównuje dwa obiekty
Sprawdźmy, co C# myśli o dwóch miejscach z tymi samymi numerami:
var a = new Seat(7, 12);
var b = new Seat(7, 12);
Console.WriteLine(a);
Console.WriteLine($"a.Equals(b): {a.Equals(b)}");
Console.WriteLine($"a.Equals(a): {a.Equals(a)}");
class Seat
{
public int Row { get; }
public int Number { get; }
public Seat(int row, int number)
{
Row = row;
Number = number;
}
}Tworzę dwa obiekty, a i b. Oba mają rząd 7 i miejsce 12. Wypisuję a na ekran, a potem pytam metodą Equals (po polsku „równa się”): czy a równa się b? I czy a równa się samemu sobie?
Seat
a.Equals(b): False
a.Equals(a): TruePierwsza linijka jest dziwna: chciałem wypisać miejsce, a na ekranie jest tylko słowo Seat, czyli nazwa klasy. Druga: a nie równa się b. Trzecia: a równa się a.
Wszystkie trzy wyniki mają jedno wyjaśnienie. W C# każda klasa automatycznie dziedziczy po klasie object. Dziedziczy, czyli dostaje od niej gotowe metody, których sama nie napisała. Więcej o tym mechanizmie znajdziesz we wpisie o dziedziczeniu w C#.
Nie napisałem metody, która zamienia miejsce na tekst, więc C# użył gotowej wersji z klasy object — a ona wypisuje po prostu nazwę klasy. Tak samo jest z Equals: nie napisałem własnej, więc działa gotowa wersja. A ona nie porównuje rzędu i numeru. Sprawdza tylko jedno: czy to jest dokładnie ten sam obiekt w pamięci komputera.
Ty patrzysz na bilety i widzisz to samo miejsce. Gotowe Equals widzi coś innego: każde słowo new tworzy w pamięci nowy, osobny obiekt. Dwa razy new to dwa osobne obiekty, więc dla gotowego Equals to dwa różne miejsca. Dlaczego zmienna wskazuje na obiekt w pamięci, zamiast go przechowywać, wyjaśnia wpis o typach wartościowych i referencyjnych.
Najważniejsze: metoda Contains na liście porównuje miejsca właśnie za pomocą Equals. Dlatego lista odpowiedziała Bartkowi „nie mam tego miejsca”.
Zasada: gotowe
Equalssprawdza, czy to ten sam obiekt, a nie czy obiekty mają te same dane.
Poprawka: własne Equals
Skoro gotowe Equals porównuje nie to, co trzeba, piszemy w klasie Seat własne. Własną wersję metody, którą klasa dostała od klasy object, oznaczamy słowem override — po polsku „nadpisz”: zamiast gotowej wersji używaj mojej.
var sold = new List<Seat>();
SellTicket(new Seat(7, 12), "Ania");
SellTicket(new Seat(7, 12), "Bartek");
void SellTicket(Seat seat, string buyer)
{
if (sold.Contains(seat))
{
Console.WriteLine($"{buyer}: rząd {seat.Row}, miejsce {seat.Number} — ZAJĘTE");
return;
}
sold.Add(seat);
Console.WriteLine($"{buyer}: rząd {seat.Row}, miejsce {seat.Number} — SPRZEDANE");
}
class Seat
{
public int Row { get; }
public int Number { get; }
public Seat(int row, int number)
{
Row = row;
Number = number;
}
public override bool Equals(object? obj)
{
return obj is Seat other
&& Row == other.Row
&& Number == other.Number;
}
}Przejdźmy przez nową metodę linijka po linijce:
public override bool Equals(object? obj)— metoda zwracabool, czyli tak albo nie. Dostaje obiekt, z którym ma się porównać. Ten obiekt może być czymkolwiek, dlatego jego typ toobject.obj is Seat other— sprawdzamy, czy ten drugi obiekt w ogóle jest miejscem. Jeśli tak, nazywamy goother, czyli „ten drugi”.Row == other.RowiNumber == other.Number— sprawdzamy, czy rząd i numer są takie same. Znak&&znaczy „i”: wszystkie trzy warunki muszą być spełnione.
Wynik:
warning CS0659: 'Element „Seat” przesłania metodę Object.Equals(object o), lecz nie przesłania metody Object.GetHashCode()
Ania: rząd 7, miejsce 12 — SPRZEDANE
Bartek: rząd 7, miejsce 12 — ZAJĘTEBartek dostał ZAJĘTE — kasa działa. Ale nad wynikiem jest pierwsza linijka: ostrzeżenie kompilatora. Kompilator to program, który zamienia nasz kod na program do uruchomienia. Ostrzeżenie to nie błąd — program się uruchamia. Kompilator mówi tylko: „uważaj, coś tu może być nie tak”. To ostrzeżenie ma numer CS0659 i mówi, że klasa Seat ma własne Equals, ale nie ma własnej metody GetHashCode. Program działa, więc łatwo je zignorować. Zapamiętaj ten numer.
Pułapka #1: zmiana, która miała przyspieszyć program (CS0659)
Wyobraź sobie, że kino rośnie: kilka sal, tysiące miejsc. Lista sprawdza miejsca po kolei, jedno po drugim, więc przy tysiącach miejsc trwa to coraz dłużej. Ktoś w zespole robi rozsądną rzecz i zamienia listę na [HashSet](https://dev-hobby.pl/kolekcje/hashset/). HashSet to też zbiór obiektów, ale sprawdza, czy coś już w nim jest, dużo szybciej niż lista. Zmienia się tylko jedna linijka — klasa Seat zostaje taka sama, razem z naszym Equals:
var sold = new HashSet<Seat>();
SellTicket(new Seat(7, 12), "Ania");
SellTicket(new Seat(7, 12), "Bartek");Zanim przewiniesz dalej, zgadnij, co dostanie Bartek:
04-HashSet.cs
Klasa Seat ma nadpisany Equals (ten sam rząd i numer = to samo miejsce), ale nie ma GetHashCode. Co dostanie Bartek?
List dokładnie ten sam Seat daje „ZAJĘTE”. HashSet pracuje inaczej.Equals jest w klasie, ale HashSet nie wywołał go ani razu.HashSet to szafka z przegródkami, a GetHashCode() to numer przegródki. Odziedziczony po object daje dwóm obiektom różne numery, więc bilet Bartka szuka w pustej przegródce.
warning CS0659: 'Element „Seat” przesłania metodę Object.Equals(object o), lecz nie przesłania metody Object.GetHashCode()
Ania: rząd 7, miejsce 12 — SPRZEDANE
Bartek: rząd 7, miejsce 12 — SPRZEDANEZnowu dwa razy SPRZEDANE. Nasze Equals dalej jest w klasie, nikt go nie usunął. Sprawdziłem, co się dzieje w środku: dopisałem do Equals jedną linijkę, która wypisuje na ekran słowo „wywołane”. Z listą to słowo pojawia się przy Bartku. Z HashSet — ani razu. Czyli HashSet w ogóle nie zapytał naszego Equals.
Żeby zrozumieć dlaczego, trzeba wiedzieć, jak HashSet szuka tak szybko. Lista sprawdza wszystko po kolei. HashSet działa jak szafka z setkami przegródek. Zanim cokolwiek porówna, pyta obiekt: „podaj numer swojej przegródki”. Ten numer daje metoda GetHashCode(), a liczbę, którą ona zwraca, nazywamy hashem. Potem HashSet zagląda tylko do tej jednej przegródki — dlatego nie musi przeglądać całej szafki.
Nie napisaliśmy własnego GetHashCode, więc znów działa gotowa wersja z klasy object. A ona zwykle daje każdemu nowemu obiektowi inny numer, nawet jeśli rząd i miejsce są takie same. Miejsce Ani trafiło do jednej przegródki, a miejsce Bartka dostało inny numer, więc HashSet zagląda do innej, pustej przegródki. Nie ma tam z czym porównać miejsca Bartka, dlatego Equals w ogóle nie zostaje wywołane. Tak samo działają klucze w [Dictionary](https://dev-hobby.pl/kolekcje/dictionary-csharp/).
Zasada: jeśli dwa obiekty są równe według
Equals, toGetHashCodemusi zwrócić dla nich ten sam numer. O tym właśnie mówi ostrzeżenie CS0659.
Equals i GetHashCode razem
Piszemy więc własne GetHashCode:
var sold = new HashSet<Seat>();
SellTicket(new Seat(7, 12), "Ania");
SellTicket(new Seat(7, 12), "Bartek");
void SellTicket(Seat seat, string buyer)
{
if (sold.Contains(seat))
{
Console.WriteLine($"{buyer}: rząd {seat.Row}, miejsce {seat.Number} — ZAJĘTE");
return;
}
sold.Add(seat);
Console.WriteLine($"{buyer}: rząd {seat.Row}, miejsce {seat.Number} — SPRZEDANE");
}
class Seat
{
public int Row { get; }
public int Number { get; }
public Seat(int row, int number)
{
Row = row;
Number = number;
}
public override bool Equals(object? obj)
{
return obj is Seat other
&& Row == other.Row
&& Number == other.Number;
}
public override int GetHashCode()
{
return HashCode.Combine(Row, Number);
}
}Znowu override, bo zastępujemy gotową wersję z klasy object. Metoda zwraca liczbę całkowitą (int) — numer przegródki. HashCode.Combine robi jedną liczbę z kilku wartości. Dajemy mu rząd i numer miejsca, czyli **te same dwie wartości, które porównuje nasze Equals**. Ten sam rząd i ten sam numer dadzą zawsze ten sam numer przegródki.
Ania: rząd 7, miejsce 12 — SPRZEDANE
Bartek: rząd 7, miejsce 12 — ZAJĘTETeraz miejsce Ani i miejsce Bartka trafiają do tej samej przegródki. HashSet porównuje je tam naszym Equals i odpowiada: to miejsce jest już sprzedane. Ostrzeżenie CS0659 zniknęło, a ta sama klasa działa i z listą, i z HashSet.
Zasada: piszesz własne
Equals— napisz też własneGetHashCode. I w obu używaj tych samych danych.
Pułapka #2: == dalej porównuje obiekty
Własne Equals nie zmienia działania znaku ==. Dla klasy new Seat(7, 12) == new Seat(7, 12) w wersji finalnej daje False (sprawdziłem uruchomieniem) — znak == dalej sprawdza, czy to ten sam obiekt. Jeśli chcesz, żeby == też porównywał dane, trzeba przeciążyć operator. A gdy klasa służy tylko do przechowywania danych, C# potrafi napisać to wszystko za ciebie — o tym jest wpis class, struct czy record.
Sprawdź sam: sześć kombinacji
Wybierz wersję klasy Seat i rodzaj kolekcji, a zobaczysz, co dostanie Bartek. Wszystkie sześć wyników uruchomiłem na .NET 10.
Ania i Bartek kupują rząd 7, miejsce 12
1. Klasa Seat
2. Kolekcja sprzedanych miejsc
3. Wynik (uruchomione na .NET 10)
Ściągawka na code review
Klasa Seat | lista (List) | HashSet | Kompilator |
|---|---|---|---|
bez Equals | sprzedaje dwa razy | sprzedaje dwa razy | bez ostrzeżeń |
tylko Equals | ✅ ZAJĘTE | ❌ sprzedaje dwa razy | CS0659 |
Equals + GetHashCode | ✅ ZAJĘTE | ✅ ZAJĘTE | bez ostrzeżeń |
Przeglądając kod, szukaj trzech rzeczy: własnej klasy, której obiekty trafiają do HashSet albo są kluczami w Dictionary; ostrzeżenia CS0659 w wynikach budowania; i GetHashCode, które korzysta z innych danych niż Equals.
Zadanie: kino ma dwie sale
Klasa Seat dostała numer sali (Hall), ale Equals i GetHashCode zostały takie jak w finale — sprawdzają tylko rząd i numer miejsca:
var sold = new HashSet<Seat>();
SellTicket(new Seat(1, 7, 12), "Ania");
SellTicket(new Seat(2, 7, 12), "Bartek");
void SellTicket(Seat seat, string buyer)
{
if (sold.Contains(seat))
{
Console.WriteLine($"{buyer}: sala {seat.Hall}, rząd {seat.Row}, miejsce {seat.Number} — ZAJĘTE");
return;
}
sold.Add(seat);
Console.WriteLine($"{buyer}: sala {seat.Hall}, rząd {seat.Row}, miejsce {seat.Number} — SPRZEDANE");
}
class Seat
{
public int Hall { get; }
public int Row { get; }
public int Number { get; }
public Seat(int hall, int row, int number)
{
Hall = hall;
Row = row;
Number = number;
}
public override bool Equals(object? obj)
{
return obj is Seat other
&& Row == other.Row
&& Number == other.Number;
}
public override int GetHashCode()
{
return HashCode.Combine(Row, Number);
}
}Ania kupuje salę 1, rząd 7, miejsce 12. Bartek — salę 2, ten sam rząd i to samo miejsce.
- Zanim uruchomisz program, zapisz, co dostanie Bartek.
- Popraw klasę
Seattak, żeby Bartek dostał bilet. - Celowo zepsuj
GetHashCode: niech zawsze zwraca zero (return 0;). Zanim uruchomisz, zapisz: czy sprzedaż dalej będzie działać?
Swoje odpowiedzi napisz w komentarzu pod filmem — a pytanie z pułapki #2 sprawdź najpierw w głowie, dopiero potem w kodzie.
Podsumowanie
- **Każda klasa dziedziczy po
object** gotowe metodyToString,EqualsiGetHashCode. Gotowe wersje sprawdzają, czy to ten sam obiekt, a nie czy ma te same dane. - **Lista (
List.Contains) porównuje za pomocąEquals** — dla listy wystarczy własneEquals. - **
HashSetiDictionarynajpierw pytająGetHashCode** o numer przegródki. Bez własnegoGetHashCodetwojeEqualsmoże nie zostać nawet wywołane. - Ostrzeżenie CS0659 to nie ozdobnik: samo
Equalsdziała tylko do chwili, gdy ktoś zmieni rodzaj kolekcji.
🎥 Wersja wideo: ten sam przykład krok po kroku — Program .NET Developer #2 — Kino sprzedało ten sam fotel dwa razy
🎓 Skąd ten temat: kurs C# Podstawy Programowania Obiektowego — lekcje „Klasa Object” i „Nadpisywanie funkcjonalności klasy podstawowej”. Kurs to poziom 2 Programu .NET Developer.
📘 Darmowa roadmapa .NET Developera: dev-hobby.pl/lista-vip
Zobacz
- HashSet w C# — unikalne elementy i szybkość — co jeszcze potrafi kolekcja z pułapki #1
- Dziedziczenie w C# — base, virtual, override — skąd się bierze
override - Przeciążanie operatorów w C# — jak sprawić, żeby
==porównywał dane - Value types vs reference types w C# — dlaczego dwa
newto dwa obiekty
🚀 Co dalej?
Zobacz to w praktyce na wideo i pobierz darmową roadmapę, żeby ułożyć naukę w spójną ścieżkę do pierwszej pracy.
- 🗺️ Pobierz darmową roadmapę Junior .NET Developer — 12 poziomów od podstaw C# do pierwszej pracy: dev-hobby.pl
- 🎬 Subskrybuj kanał YouTube — nowe filmy co tydzień.
Zamień wiedzę w umiejętności
Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.
Pobieram roadmapę →