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.

C# IComparable IComparer ToString 55 min
CEL LEKCJI

Czego się dziś nauczysz

  • Wyjaśnisz, dlaczego Sort na 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 potrzeby Equals i GetHashCode

Przygotowanie: lekcje 01–50. Przewidywany czas: 45–90 minut z zadaniami. Przykłady wymagają .NET 8 lub nowszego, z włączonymi ImplicitUsings i Nullable.

TEORIA

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?

Program.cs
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.

TEORIA

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.

definicja typu
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 CompareToZnaczenie
liczba ujemnaten obiekt jest przed porównywanym
zeroobiekty są równorzędne w tym porządku
liczba dodatniaten 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.

Program.cs
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.

TEORIA

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

definicje typów
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
    }
}
użycie
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.

TEORIA

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

Program.cs
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.

wewnątrz klasy Uczen
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

Przykład: ranking turnieju

Program.cs
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}");
        }
    }
}
wynik w konsoli
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 z CompareTo zapisanego w klasie.
  • Przy równych punktach Ola i Ewa zachowały kolejność wpisania — ale List.Sort nie 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.
  • ToString z formatowaniem {Imie,-8} daje równe kolumny bez dodatkowej pracy w pętli wypisującej.
ELEMENTY WBUDOWANE

Zestawienie elementów

ElementZwracaDziałanie i ograniczenia
IComparable<T>interfejsPorządek naturalny wbudowany w typ. Wymaga metody CompareTo.
x.CompareTo(y)intUjemna — x przed y; zero — równorzędne; dodatnia — x za y.
IComparer<T>interfejsPorzą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)intPorównanie napisów. CurrentCulture uwzględnia polskie znaki, Ordinal porównuje numery znaków.
override string ToString()stringTekstowa postać obiektu. Używana automatycznie przez Console.WriteLine i interpolację.
override bool Equals(object?)boolWłasna definicja równości. Wymaga nadpisania GetHashCode.
HashCode.Combine(a, b)intBuduje skrót z kilku pól — gotowa i poprawna implementacja GetHashCode.
CZĘSTE BŁĘDY

Na co uważać

ZapisProblem
return Punkty - inny.Punkty;Przy skrajnych wartościach int odejmowanie się przekręca i znak wyniku jest odwrotny. Używaj CompareTo.
lista.Sort() bez IComparableInvalidOperationException w czasie wykonania — kompilator tego nie wykryje.
Nadpisany Equals bez GetHashCodeHashSet i Dictionary przestają odnajdywać elementy. Kompilator ostrzega (CS0659), nie przerywa.
Poleganie na kolejności elementów równychList.Sort i Array.Sort nie są stabilne. Dodaj drugie kryterium rozstrzygające remis.
Porównanie Ordinal dla list dla użytkownikaPolskie znaki wylądują na końcu alfabetu. Wybierz CurrentCulture.
CompareTo zwracające true/falseMetoda musi zwracać int — trzy stany, nie dwa.
ZADANIA

Zadania

ZAD 1Naturalny porządek★☆☆

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.

ZAD 2ToString z sensem★☆☆

Nadpisz ToString w klasie Ksiazka tak, żeby Console.WriteLine(ksiazka) dawało czytelny wiersz z wyrównanymi kolumnami.

ZAD 3Trzy komparatory★★☆

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.

ZAD 4Ranking z miejscami★★☆

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

ZAD 5Równość według treści★★☆

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

ZAD 6Sortowanie sterowane przez użytkownika★★★

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.

PODSUMOWANIE

Co trzeba zapamiętać

  • CompareTo zwraca 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.Sort i Array.Sort nie są stabilne — remisy rozstrzygaj jawnie.
  • Nadpisując Equals, zawsze nadpisz GetHashCode; HashCode.Combine robi to poprawnie.

Dokumentacja: Microsoft Learn — temat tej lekcji.