CodeGym /Kursy /C# SELF /Interfejs IComparable<T>

Interfejs IComparable<T>

C# SELF
Poziom 30, Lekcja 0
Dostępny

1. Wprowadzenie

Wyobraź sobie, że prosisz kumpla, żeby poukładał książki na półkach. Jeśli to książki z numerkami na grzbietach (1, 2, 3…), twój kumpel ogarnie to bez problemu: "No dobra, 1 idzie przed 2, 2 przed 3". To jest naturalny porządek liczb (po kolei). Tak samo, jeśli to książki, których tytuły zaczynają się od liter "A", "B", "C", poukłada je alfabetycznie. Też "naturalny" porządek dla stringów (alfabetycznie).

Ale co, jeśli to książki, na których grzbietach są tylko nazwiska autorów? I mówisz: "Ułóż w kolejności". Twój kumpel zapyta: "W jakiej kolejności? Po nazwisku? Po roku wydania? Po liczbie stron?" No i tu zaczyna się problem. Dla twoich unikalnych bytów nie ma oczywistego naturalnego porządku.

Tak samo jest w programowaniu. Gdy próbujemy posortować listę liczb całkowitych List<int>, C# doskonale wie, jak to zrobić. Dla List<string> też ogarnia, używając porządku leksykograficznego (alfabetycznie). Ale jeśli mamy List<Student>, gdzie każdy Student to obiekt z imieniem, nazwiskiem, wiekiem, ID i jeszcze masą innych rzeczy, to C# się gubi. Nie wie, po czym porównywać dwóch studentów. Po imieniu? Po ID? Po średniej ocen? To właśnie ten problem, na który trafiliśmy w wykładzie "Sortowanie kolekcji", kiedy List<T>.Sort() wywalił błąd przy próbie sortowania kolekcji własnych typów.

Żeby rozwiązać tę zagwozdkę, musimy dać C# jasną instrukcję, według jakiej zasady porównywać nasze obiekty. Do tego służy interfejs IComparable<T>.

2. Interfejs IComparable<T>

No więc, żeby nasze obiekty "wiedziały", gdzie ich miejsce względem innych takich samych obiektów, używamy interfejsu IComparable<T>. To taki kontrakt. Gdy twoja klasa albo struct implementuje ten interfejs, mówisz kompilatorowi: "Siema! Moje obiekty można porównać ze sobą i tu masz instrukcję, jak to robić."

Jak to działa?

Interfejs IComparable<T> definiuje tylko jedną metodę:

public interface IComparable<in T>
{
    int CompareTo(T other);
}

Ta metoda przyjmuje obiekt typu T (tego samego typu co bieżący obiekt) i powinna zwracać:

  • liczbę ujemną (< 0), jeśli bieżący obiekt jest "mniejszy" od porównywanego;
  • zero (0), jeśli są "równe" (z punktu widzenia sortowania);
  • liczbę dodatnią (> 0), jeśli bieżący obiekt jest "większy".

W sumie to trochę jak sędziowie na zawodach, np. w boksie: jeśli byłeś lepszy, wygrywasz rundę i dostajesz więcej punktów; jeśli słabszy — mniej; jeśli walka była równa, wynik taki sam. Tylko tutaj zamiast sędziów wszystko jest prostsze: liczba ze znakiem.

Dlaczego właśnie tak?

Metody sortujące (np. List<T>.Sort()) wywołują CompareTo dla elementów listy, żeby ogarnąć, kogo ustawić wcześniej, a kogo później. Jeśli twoja klasa implementuje ten interfejs — można ją sortować!

3. Praktyka

Załóżmy, że mamy taką klasę użytkownika (User):

public class User
{
    public string Name { get; set; }
    public int Age { get; set; }
}

Spróbujmy posortować listę użytkowników:

List<User> users = new List<User>
{
    new User { Name = "Siergiej", Age = 31 },
    new User { Name = "Maria", Age = 22 },
    new User { Name = "Anton", Age = 27 }
};

users.Sort(); // BUM! InvalidOperationException

Pojawia się błąd: "At least one object must implement IComparable" (Przynajmniej jeden obiekt musi implementować IComparable).

Naprawiamy: implementujemy IComparable<User>

Dodajemy interfejs do naszej klasy. Niech na początek sortowanie będzie po wieku — od najmłodszych do najstarszych:

public class User : IComparable<User>
{
    public string Name { get; set; }
    public int Age { get; set; }

    public int CompareTo(User other)
    {
        // Zabezpieczenie: jeśli other == null, nasz użytkownik jest "większy"
        if (other == null) return 1;
        return this.Age.CompareTo(other.Age); // sortujemy po wieku
    }
}

Teraz robimy listę, wywołujemy users.Sort(); i wypisujemy wynik na konsoli:

List<User> users = new List<User>
{
    new User { Name = "Siergiej", Age = 31 },
    new User { Name = "Maria", Age = 22 },
    new User { Name = "Anton", Age = 27 }
};

// Sortowanie po wieku (używa CompareTo)
users.Sort();

// Wypisanie posortowanych użytkowników
foreach (User user in users)
{
    Console.WriteLine($"{user.Name}, {user.Age}");
}

Lista posortuje się po wieku:

Maria, 22
Anton, 27
Siergiej, 31

Wizualizacja: przed i po

Imię Wiek
Siergiej 31
Maria 22
Anton 27

Po sortowaniu:

Imię Wiek
Maria 22
Anton 27
Siergiej 31

4. Ważne szczegóły i częste błędy

Zabezpieczenie przed null

W środku CompareTo mega ważne jest, żeby sprawdzić, czy other nie jest null. Jeśli to olejesz, możesz dostać NullReferenceException — a one są tak podstępne, jak bugi w kodzie tuż przed releasem. Zazwyczaj, jeśli porównywany obiekt to null, przyjmuje się, że bieżący jest "większy":

public int CompareTo(User other)
{
    if (other == null) return 1;
    // ...
}

Trzymaj się przechodniości

Jeśli A < B i B < C, to A powinno być < C. Jeśli nie będziesz tego pilnować, sortowanie zacznie się zachowywać nieprzewidywalnie (czyli śmiesznie, ale źle!).

Jeśli sortowanie ma być po kilku polach

Załóżmy, że najpierw sortujemy po wieku, ale jeśli dwóch użytkowników ma ten sam wiek, sortujemy ich po imieniu alfabetycznie. Robi się to tak:

public int CompareTo(User other)
{
    if (other == null) return 1;

    int ageCompare = this.Age.CompareTo(other.Age);
    if (ageCompare != 0) return ageCompare;

    // Jeśli wiek taki sam — porównujemy po imieniu
    return this.Name.CompareTo(other.Name);
}

5. Sortowanie: teraz też z twoimi obiektami

Wszystko, co umie List<int> — teraz umie twoja klasa

Teraz możesz używać każdej metody, która wymaga porównania: Sort, BinarySearch, nawet wstawianie do posortowanych kolekcji (np. do SortedSet<T>).

users.Sort();
// users jest teraz posortowany po wieku (i po imieniu przy tym samym wieku)

Przykład w kontekście aplikacji kursowej

Załóżmy, że wcześniej już budowałeś apkę do zarządzania użytkownikami. Teraz możemy dodać im "naturalny" porządek sortowania prosto do istniejącego kodu. Tak to będzie wyglądać:

// Nasza klasa User już implementuje IComparable<User>

List<User> users = new List<User>
{
    new User { Name = "Iwan", Age = 45 },
    new User { Name = "Galina", Age = 27 },
    new User { Name = "Jurij", Age = 27 }
};

// Sortujemy po wieku, potem po imieniu
users.Sort();
foreach (var u in users)
{
    Console.WriteLine($"{u.Name} - {u.Age}");
}

Wynik:

Galina - 27
Jurij - 27
Iwan - 45

Galina i Jurij są w tym samym wieku, sortowanie po imieniu przy równości wieku.

6. Jak działa CompareTo: twarda matematyka

Jeszcze raz skupmy się na standardzie zwracanych wartości:

  • Liczba ujemna (np. -1): bieżący obiekt idzie przed porównywanym.
  • Zero: są równe pod względem sortowania.
  • Liczba dodatnia (np. 1): bieżący obiekt idzie po porównywanym.

Typy wbudowane (np. Age.CompareTo(other.Age)) już mają ten standard, zawsze zwracając -1, 0 albo 1.

Tabelka zwrotów dla metody CompareTo

Zwracana wartość Co to znaczy? Przykład
< 0 Mniejszy (idzie wcześniej)
20.CompareTo(21): -1
0 Równe
23.CompareTo(23): 0
> 0 Większy (idzie później)
42.CompareTo(15): 1

7. Sortowanie wielopolowe: łączymy pola

Czasem trzeba zrobić bardziej złożone sortowanie: np. po nazwisku, imieniu i wieku. Stosując już znany patent, można porównywać po kolei:

public class Student : IComparable<Student>
{
    public string LastName { get; set; }
    public string FirstName { get; set; }
    public int Grade { get; set; }

    public int CompareTo(Student other)
    {
        if (other == null) return 1;
        int lastNameCompare = this.LastName.CompareTo(other.LastName);
        if (lastNameCompare != 0) return lastNameCompare;
        int firstNameCompare = this.FirstName.CompareTo(other.FirstName);
        if (firstNameCompare != 0) return firstNameCompare;
        return this.Grade.CompareTo(other.Grade);
    }
}

8. Kiedy NIE warto implementować IComparable<T>

Sytuacja z życia (programistycznego!): jeśli obiekt nie ma "naturalnego" porządku sortowania, lepiej nie implementować IComparable<T>. Na przykład, jeśli masz klasę Point, a nie wiesz — sortować po X, po Y czy po odległości od początku układu — lepiej, żeby sortowanie było przekazywane z zewnątrz przez funkcję porównującą (IComparer<T>). O tym pogadamy na następnym wykładzie.

2
Zadanie
C# SELF, poziom 30, lekcja 0
Niedostępne
Implementacja interfejsu IComparable do porównywania po jednym polu
Implementacja interfejsu IComparable do porównywania po jednym polu
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION