Sortowanie obiektów — IComparable, IComparer i ToString
Liczby i napisy sortują się same. Uczniowie, książki i zgłoszenia nie — bo nikt nie powiedział programowi, co znaczy „większy”. Ten brakujący element napiszesz sam.
Czego się dziś nauczysz
- Wyjaśnisz, dlaczego
Sortna liście obiektów kończy się wyjątkiem - Zaimplementujesz
IComparable<T>i porządek naturalny typu - Napiszesz osobne komparatory
IComparer<T>dla różnych kryteriów - Posortujesz po kilku polach naraz i rozstrzygniesz remisy
- Nadpiszesz
ToString, a w razie potrzebyEqualsiGetHashCode
Przygotowanie: lekcje 01–50. Przewidywany czas: 45–90 minut z zadaniami. Przykłady wymagają .NET 8 lub nowszego, z włączonymi ImplicitUsings i Nullable.
Array.Sort nie wie, co jest większe
Sortowanie liczb i napisów działa, bo dla nich istnieje oczywisty porządek. A dla obiektów?
class Uczen
{
public string Imie { get; set; } = "";
public int Punkty { get; set; }
}
List<Uczen> lista = new List<Uczen> { /* ... */ };
lista.Sort(); // WYJATEK: InvalidOperationException
Środowisko nie zgadnie, czy uczniów porządkujemy po imieniu, po punktach rosnąco, czy po punktach malejąco. Musimy to powiedzieć — na jeden z dwóch sposobów.
Porządek naturalny: IComparable
Gdy typ ma jeden oczywisty porządek, wbudowujemy go w klasę. Służy do tego interfejs IComparable<T> z jedną metodą CompareTo.
class Uczen : IComparable<Uczen>
{
public string Imie { get; set; } = "";
public int Punkty { get; set; }
public int CompareTo(Uczen? inny)
{
if (inny == null) return 1; // null jest "mniejszy" niz cokolwiek
return Punkty.CompareTo(inny.Punkty); // porzadek naturalny: po punktach rosnaco
}
public override string ToString() => $"{Imie} ({Punkty} pkt)";
}
Wynik CompareTo | Znaczenie |
|---|---|
| liczba ujemna | ten obiekt jest przed porównywanym |
| zero | obiekty są równorzędne w tym porządku |
| liczba dodatnia | ten obiekt jest za porównywanym |
Nie licz różnicy ręcznie: return Punkty - inny.Punkty; wygląda sprytnie, ale przy skrajnych wartościach int może się przekręcić i dać zły znak. Zawsze wywołuj CompareTo na porównywanym polu.
lista.Sort(); // teraz dziala - rosnaco po punktach
Uczen[] tablica = lista.ToArray();
Array.Sort(tablica); // to samo dla tablicy
ToString nadpisujemy, bo bez tego wypisanie obiektu daje samą nazwę klasy: Uczen. Metoda ToString istnieje w każdym typie — dziedziczymy ją po klasie object i możemy nadpisać słowem override, tak jak metodę wirtualną z lekcji 46.
Porządki alternatywne: IComparer
Jeden porządek naturalny nie wystarcza, gdy ta sama lista ma być raz posortowana po imieniu, a raz po punktach malejąco. Wtedy porównanie wynosimy poza klasę — do osobnego typu implementującego IComparer<T>.
class PoImieniu : IComparer<Uczen>
{
public int Compare(Uczen? a, Uczen? b)
{
if (a == null || b == null) return 0;
return string.Compare(a.Imie, b.Imie, StringComparison.CurrentCulture);
}
}
class PoPunktachMalejaco : IComparer<Uczen>
{
public int Compare(Uczen? a, Uczen? b)
{
if (a == null || b == null) return 0;
return b.Punkty.CompareTo(a.Punkty); // ZAMIENIONA kolejnosc = malejaco
}
}
lista.Sort(new PoImieniu());
lista.Sort(new PoPunktachMalejaco());
Array.Sort(tablica, new PoImieniu());
Zamiana argumentów miejscami odwraca porządek — to najprostszy sposób na sortowanie malejące i warto go zapamiętać.
Porównywanie napisów
string.Compare(a, b, StringComparison.CurrentCulture) uwzględnia polskie znaki i ustawienia regionalne: ą trafi obok a, a nie na koniec alfabetu. Porównanie Ordinal jest szybsze i przewidywalne, ale porządkuje po numerach znaków — dla list wyświetlanych człowiekowi wybieramy wersję kulturową.
Krótszy zapis czeka w lekcji 54
Zamiast osobnej klasy porównującej można podać wyrażenie lambda: lista.Sort((a, b) => b.Punkty.CompareTo(a.Punkty)), a LINQ pozwala napisać lista.OrderByDescending(u => u.Punkty). Najpierw jednak warto zobaczyć, co się pod tym kryje.
Równość: Equals i GetHashCode
Sortowanie odpowiada na pytanie „które wcześniej”. Osobnym pytaniem jest „czy to ten sam”. Domyślnie == i Equals dla klasy porównują tożsamość — czy obie nazwy wskazują ten sam obiekt (lekcja 43).
Uczen a = new Uczen { Imie = "Ala", Punkty = 18 };
Uczen b = new Uczen { Imie = "Ala", Punkty = 18 };
Console.WriteLine(a == b); // False - dwa rozne obiekty
Console.WriteLine(a.Equals(b)); // False
Jeśli w twoim modelu dwaj uczniowie o tym samym imieniu i wyniku to ten sam uczeń, nadpisz Equals — a razem z nim GetHashCode, bo z niego korzystają Dictionary i HashSet z lekcji 32.
public override bool Equals(object? obj)
{
if (obj is not Uczen inny) return false;
return Imie == inny.Imie && Punkty == inny.Punkty;
}
public override int GetHashCode() => HashCode.Combine(Imie, Punkty);
Reguła nie do złamania
Obiekty równe według Equals muszą mieć ten sam wynik GetHashCode. Nadpisanie tylko jednej z tych metod sprawia, że HashSet i Dictionary zaczynają gubić elementy — i to bez żadnego komunikatu o błędzie.
Przykład: ranking turnieju
using System.Collections.Generic;
class Uczen : IComparable<Uczen>
{
public string Imie { get; }
public int Punkty { get; }
public Uczen(string imie, int punkty)
{
Imie = imie;
Punkty = punkty;
}
public int CompareTo(Uczen? inny)
{
if (inny == null) return 1;
return Punkty.CompareTo(inny.Punkty);
}
public override string ToString() => $"{Imie,-8}{Punkty,4} pkt";
}
class PoPunktachMalejacoPotemImie : IComparer<Uczen>
{
public int Compare(Uczen? a, Uczen? b)
{
if (a == null || b == null) return 0;
int wynik = b.Punkty.CompareTo(a.Punkty); // najpierw punkty malejaco
if (wynik != 0) return wynik;
return string.Compare(a.Imie, b.Imie, StringComparison.CurrentCulture);
}
}
class Program
{
static void Main()
{
List<Uczen> lista = new List<Uczen>
{
new Uczen("Ola", 24),
new Uczen("Ala", 18),
new Uczen("Ewa", 24),
new Uczen("Jan", 7)
};
lista.Sort(); // porzadek naturalny: punkty rosnaco
Console.WriteLine("Rosnaco po punktach:");
foreach (Uczen u in lista) Console.WriteLine(" " + u);
lista.Sort(new PoPunktachMalejacoPotemImie());
Console.WriteLine();
Console.WriteLine("Ranking:");
int miejsce = 1;
foreach (Uczen u in lista)
{
Console.WriteLine($" {miejsce++}. {u}");
}
}
}
Rosnaco po punktach:
Jan 7 pkt
Ala 18 pkt
Ola 24 pkt
Ewa 24 pkt
Ranking:
1. Ewa 24 pkt
2. Ola 24 pkt
3. Ala 18 pkt
4. Jan 7 pkt
Co dzieje się po kolei
- Pierwsze
Sort()bez argumentu korzysta zCompareTozapisanego w klasie. - Przy równych punktach Ola i Ewa zachowały kolejność wpisania — ale
List.Sortnie jest stabilny, więc nie wolno na tym polegać. Dlatego drugi porządek rozstrzyga remis imieniem. - Komparator porównuje najpierw punkty; dopiero gdy wynik to zero, sięga po drugie kryterium. To standardowy wzorzec sortowania po kilku polach.
ToStringz formatowaniem{Imie,-8}daje równe kolumny bez dodatkowej pracy w pętli wypisującej.
Zestawienie elementów
| Element | Zwraca | Działanie i ograniczenia |
|---|---|---|
IComparable<T> | interfejs | Porządek naturalny wbudowany w typ. Wymaga metody CompareTo. |
x.CompareTo(y) | int | Ujemna — x przed y; zero — równorzędne; dodatnia — x za y. |
IComparer<T> | interfejs | Porządek zdefiniowany poza typem. Wymaga metody Compare(a, b). |
lista.Sort() | — | Sortuje w miejscu według IComparable. Bez niego InvalidOperationException. Nie jest stabilne. |
lista.Sort(komparator) | — | Sortuje według podanego IComparer<T>. |
Array.Sort(t, komparator) | — | To samo dla tablicy. |
string.Compare(a, b, opcja) | int | Porównanie napisów. CurrentCulture uwzględnia polskie znaki, Ordinal porównuje numery znaków. |
override string ToString() | string | Tekstowa postać obiektu. Używana automatycznie przez Console.WriteLine i interpolację. |
override bool Equals(object?) | bool | Własna definicja równości. Wymaga nadpisania GetHashCode. |
HashCode.Combine(a, b) | int | Buduje skrót z kilku pól — gotowa i poprawna implementacja GetHashCode. |
Na co uważać
| Zapis | Problem |
|---|---|
return Punkty - inny.Punkty; | Przy skrajnych wartościach int odejmowanie się przekręca i znak wyniku jest odwrotny. Używaj CompareTo. |
lista.Sort() bez IComparable | InvalidOperationException w czasie wykonania — kompilator tego nie wykryje. |
Nadpisany Equals bez GetHashCode | HashSet i Dictionary przestają odnajdywać elementy. Kompilator ostrzega (CS0659), nie przerywa. |
| Poleganie na kolejności elementów równych | List.Sort i Array.Sort nie są stabilne. Dodaj drugie kryterium rozstrzygające remis. |
Porównanie Ordinal dla list dla użytkownika | Polskie znaki wylądują na końcu alfabetu. Wybierz CurrentCulture. |
CompareTo zwracające true/false | Metoda musi zwracać int — trzy stany, nie dwa. |
Zadania
Dodaj do klasy Ksiazka (tytuł, rok, liczba stron) interfejs IComparable porządkujący po roku wydania. Posortuj listę pięciu książek i wypisz wynik.
Nadpisz ToString w klasie Ksiazka tak, żeby Console.WriteLine(ksiazka) dawało czytelny wiersz z wyrównanymi kolumnami.
Napisz trzy klasy IComparer<Ksiazka>: po tytule, po liczbie stron malejąco i po roku, a przy równym roku po tytule. Pokaż wszystkie trzy porządki dla tej samej listy.
Posortuj uczniów malejąco po punktach i wypisz ranking, w którym osoby o tej samej liczbie punktów dostają to samo miejsce (1, 2, 2, 4).
Nadpisz w klasie Ksiazka metody Equals i GetHashCode tak, żeby dwie książki o tym samym tytule i roku były równe. Sprawdź działanie, wrzucając duplikaty do HashSet<Ksiazka>.
Program wyświetla menu wyboru kryterium (tytuł, rok, strony) i kierunku (rosnąco, malejąco), po czym sortuje listę zgodnie z wyborem. Zadbaj o to, żeby dodanie czwartego kryterium wymagało dopisania jednej klasy, a nie przerabiania menu w kilku miejscach.
Co trzeba zapamiętać
CompareTozwraca liczbę ujemną, zero albo dodatnią — nigdy wartości logicznej.IComparable<T>to jeden porządek naturalny wbudowany w typ;IComparer<T>to dowolna liczba porządków obok typu.- Zamiana argumentów miejscami w porównaniu odwraca kierunek sortowania.
- Przy porównywaniu po kilku polach sprawdzaj kolejne kryterium dopiero, gdy poprzednie dało zero.
List.SortiArray.Sortnie są stabilne — remisy rozstrzygaj jawnie.- Nadpisując
Equals, zawsze nadpiszGetHashCode;HashCode.Combinerobi to poprawnie.
Dokumentacja: Microsoft Learn — temat tej lekcji.