🔥 Darmowa Roadmapa .NET — kompletny plan nauki do pierwszej pracy. Pobierz i dołącz do Listy VIP →

Equals i GetHashCode w C# — fotel sprzedany dwa razy

Equals i GetHashCode w C# — dwa bilety na to samo miejsce w kinie, rząd 7, miejsce 12

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 — SPRZEDANE

Kiedy 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): True

Pierwsza 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 Equals sprawdza, 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 zwraca bool, czyli tak albo nie. Dostaje obiekt, z którym ma się porównać. Ten obiekt może być czymkolwiek, dlatego jego typ to object.
  • obj is Seat other — sprawdzamy, czy ten drugi obiekt w ogóle jest miejscem. Jeśli tak, nazywamy go other, czyli „ten drugi”.
  • Row == other.Row i Number == 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ĘTE

Bartek 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:

Zgadnij, zanim klikniesz

04-HashSet.cs

var sold = new HashSet<Seat>(); SellTicket(new Seat(7, 12), "Ania"); SellTicket(new Seat(7, 12), "Bartek");

Klasa Seat ma nadpisany Equals (ten sam rząd i numer = to samo miejsce), ale nie ma GetHashCode. Co dostanie Bartek?

Tak by było z listą. Z List dokładnie ten sam Seat daje „ZAJĘTE”. HashSet pracuje inaczej.
Zgadłeś — fotel znowu idzie dwa razy. Equals jest w klasie, ale HashSet nie wywołał go ani razu.
Kompilator tylko ostrzega. CS0659 to ostrzeżenie, nie błąd — program się kompiluje i uruchamia.
Konsola (uruchomione na .NET 10):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 — SPRZEDANE

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 — SPRZEDANE

Znowu 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, to GetHashCode musi 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ĘTE

Teraz 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łasne GetHashCode. 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.

Kasa biletowa

Ania i Bartek kupują rząd 7, miejsce 12

1. Klasa Seat

class Seat { public int Row { get; } public int Number { get; } public Seat(int row, int number) { Row = row; Number = number; } }
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; } }
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); } }

2. Kolekcja sprzedanych miejsc

var sold = new List<Seat>();

3. Wynik (uruchomione na .NET 10)

Ania: rząd 7, miejsce 12 — SPRZEDANEBartek: rząd 7, miejsce 12 — SPRZEDANE

Ściągawka na code review

Klasa Seatlista (List)HashSetKompilator
bez Equalssprzedaje dwa razysprzedaje dwa razybez ostrzeżeń
tylko Equals✅ ZAJĘTE❌ sprzedaje dwa razyCS0659
Equals + GetHashCode✅ ZAJĘTE✅ ZAJĘTEbez 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.

  1. Zanim uruchomisz program, zapisz, co dostanie Bartek.
  2. Popraw klasę Seat tak, żeby Bartek dostał bilet.
  3. 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 metody ToString, Equals i GetHashCode. 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łasne Equals.
  • **HashSet i Dictionary najpierw pytają GetHashCode** o numer przegródki. Bez własnego GetHashCode twoje Equals może nie zostać nawet wywołane.
  • Ostrzeżenie CS0659 to nie ozdobnik: samo Equals dział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

Mariusz Jurczenko
Mariusz Jurczenko
Senior .NET Developer · 10+ lat doświadczenia komercyjnego

Programista .NET z doświadczeniem komercyjnym w firmach takich jak NFZ, Kamsoft, Diagnostyka, Hermes Reply Polska czy Etisoft Smart Solutions. Twórca kursów, z których skorzystało już ponad 11 000 osób w Strefie Kursów i ponad 1 000 kursantów na dev-hobby.pl.

Specjalizacja: Clean Code, Clean Architecture i uczenie programowania tak, żeby dało się je naprawdę zrozumieć — nie wykuć.

🚀 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.

Dodaj komentarz

czytanie to początek

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ę →