CodeGym /Kursy /C# SELF /Praktyczne zastosowanie komparatorów w .NET

Praktyczne zastosowanie komparatorów w .NET

C# SELF
Poziom 30, Lekcja 4
Dostępny

1. Wprowadzenie

Dlaczego komparatory to naprawdę coś…

W prawdziwych projektach wszystko nie jest takie trywialne, jak się wydaje w zadaniach szkolnych: obiekty są złożone, dane przychodzą od użytkowników albo innych serwisów, potrzebne nam "kolejności sortowania" (albo w ogóle zasady równości) często zmieniają się w zależności od zadania. Komparatory pozwalają zrobić kod elastycznym, a zachowanie — przewidywalnym i kontrolowanym.

W .NET komparatory są niezbędne zawsze, gdy trzeba sortować, szukać, grupować albo wykluczać duplikaty obiektów własnych klas, a także kiedy budujemy struktury danych z "kolejnością". Szczególnie często spotkasz je w kolekcjach, algorytmach i przy integracji z zewnętrznymi API.

Gdzie spotkasz komparatory w .NET:

  • Sortowanie kolekcji (List<T>.Sort, Array.Sort, OrderBy w LINQ)
  • Struktury danych z uporządkowaniem (SortedSet<T>, SortedDictionary<TKey, TValue>)
  • Wyszukiwanie i porównywanie obiektów w kolekcjach (Contains, IndexOf — kiedy trzeba określić "równość", a nie tylko kolejność)
  • Grupowanie, filtrowanie i deduplikacja (np. .Distinct())

Po co to w praktyce?

  • Raporty, gdzie ważny jest posortowany wynik (np. lista studentów alfabetycznie albo według średniej oceny)
  • Dopasowanie danych wejściowych do wartości referencyjnych (np. wyszukiwanie rekordu według określonego kryterium)
  • Oszczędność pamięci i szybkości (dobry wybór struktury danych przyspiesza wyszukiwanie i sprawia, że aplikacja jest bardziej responsywna)
  • Walidacja unikalności (np. przy rejestracji użytkownika po e-mailu)

2. Przykład z życia: sortowanie według różnych kryteriów

Załóżmy, że mamy klasę użytkownika:

public class User
{
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";
    public string Email { get; set; } = "";
    public int Age { get; set; }

    // Można dodać override Equals i GetHashCode — ale to na domowe czytanie
}

Mamy listę użytkowników i chcemy:

  • Posortować ich według nazwiska, a jeśli nazwiska są takie same — według imienia.
  • Pozwalać na wyszukiwanie użytkownika po e-mailu (ignorując wielkość liter).
  • Wykluczać duplikaty użytkowników przy dodawaniu.

Sortowanie za pomocą IComparer<T>

Wariant 1: klasyczny sposób — tworzymy osobny komparator.

// Komparator do sortowania po nazwisku i imieniu
public class UserFullNameComparer : IComparer<User>
{
    public int Compare(User? x, User? y)
    {
        if (ReferenceEquals(x, y)) return 0;
        if (x is null) return -1;
        if (y is null) return 1;

        int lastNameComparison = StringComparer.OrdinalIgnoreCase.Compare(x.LastName, y.LastName);
        if (lastNameComparison != 0)
            return lastNameComparison;

        return StringComparer.OrdinalIgnoreCase.Compare(x.FirstName, y.FirstName);
    }
}

Użycie:

var users = new List<User>
{
    new User { FirstName = "Ivan", LastName = "Petrov", Age = 20, Email = "ivan.petrov@email.com" },
    new User { FirstName = "Anna", LastName = "Smirnova", Age = 22, Email = "anna.smirnova@email.com" },
    new User { FirstName = "Petr", LastName = "Petrov", Age = 18, Email = "petr.petrov@email.com" }
};

users.Sort(new UserFullNameComparer());

foreach (var user in users)
{
    Console.WriteLine($"{user.LastName} {user.FirstName} ({user.Age})");
}

// Wynik — użytkownicy posortowani według nazwiska, a jeśli nazwisko równe — według imienia.

Na marginesie: Tak się robi, jeśli kolejność porównania jest potrzebna w kilku różnych miejscach aplikacji i jeśli lambda już nie ratuje przed kopiowaniem kodu.

Sortowanie "na szybko" za pomocą lambdy

Nie chcesz mnożyć klas dla jednorazowej kolejności? Lambda na ratunek!

users.Sort((x, y) =>
{
    int lastNameComparison = StringComparer.OrdinalIgnoreCase.Compare(x.LastName, y.LastName);
    if (lastNameComparison != 0)
        return lastNameComparison;
    return StringComparer.OrdinalIgnoreCase.Compare(x.FirstName, y.FirstName);
});

Działa tak samo, ale komparator tworzysz bezpośrednio w wywołaniu. Oszczędza linijki, ale nie zawsze wygodne do wielokrotnego użycia.

3. Sprytne wyszukiwanie: porównywanie e-mail bez uwzględniania wielkości liter

W prawdziwym życiu użytkownicy wpisują e-mail jak chcą, a ty — programista, a nie sędzia, więc logiczne jest porównywać e-mail bez uwzględniania wielkości liter.

Robimy to za pomocą komparatora i wyszukiwania:

public class EmailComparer : IEqualityComparer<User>
{
    public bool Equals(User? x, User? y)
    {
        if (ReferenceEquals(x, y)) return true;
        if (x is null || y is null) return false;
        return string.Equals(x.Email, y.Email, StringComparison.OrdinalIgnoreCase);
    }
    public int GetHashCode(User obj)
    {
        return obj.Email?.ToLowerInvariant().GetHashCode() ?? 0;
    }
}

Użycie w HashSet:

var usersSet = new HashSet<User>(new EmailComparer());
usersSet.Add(new User { Email = "Petrov@example.com" });
bool contains = usersSet.Contains(new User { Email = "petrov@example.com" }); // true!

Ważna uwaga: Kiedy tworzysz swój EqualityComparer, nie zapomnij zaimplementować obu metod: Equals i GetHashCode. Zapomnisz o drugiej — i zachowanie będzie dziwne: porównanie może "wariować" przy wyszukiwaniu w kolekcjach.

4. Zastosowanie SortedSet i SortedDictionary

Tutaj komparatory naprawdę pokazują swoją moc.

SortedSet<T> i SortedDictionary<TKey, TValue> nie będą działać z twoimi obiektami, jeśli nie wyjaśnisz .NET, jak je porównywać. I właśnie kolejność porównania i równości wpływa na to, które elementy są uznawane za różne!

Przykład z SortedSet<User>

var sortedUsersByFullName = new SortedSet<User>(new UserFullNameComparer())
{
    new User { FirstName = "Ivan", LastName = "Petrov", Age = 20 },
    new User { FirstName = "Anna", LastName = "Smirnova", Age = 22 },
    new User { FirstName = "Petr", LastName = "Petrov", Age = 18 },
    new User { FirstName = "Ivan", LastName = "Petrov", Age = 25 } // Duplikat po imieniu i nazwisku
};

// "Ivan Petrov" z różnym wiekiem — tylko jeden trafi do zbioru
Console.WriteLine("Użytkownicy w SortedSet:");
foreach (var user in sortedUsersByFullName)
    Console.WriteLine($"{user.LastName} {user.FirstName} ({user.Age})");

SortedSet nie doda dwóch "Ivanów Petrovów"!

Ważne: Komparator tutaj określa logikę "unikalności". Jeśli porównujesz tylko po nazwisku i imieniu, to użytkownicy z tym samym imieniem i nazwiskiem, ale różnym wiekiem są traktowani jako ten sam użytkownik.

5. Tabela: kiedy jaki komparator w .NET używać

Scenariusz Co zaimplementować Przykład użycia
Naturalna kolejność sortowania (jedna, uniwersalna dla typu)
IComparable<T>
List<T>.Sort()
Kilka wariantów sortowania (po imieniu, wieku, e-mail ...)
IComparer<T>
(klasy, lambdy)
List<T>.Sort(comparer)
Wyszukiwanie unikalnych elementów w kolekcji
IEqualityComparer<T>
HashSet<T>
,
Dictionary<TKey, TValue>
Grupowanie, usuwanie duplikatów
IEqualityComparer<T>
LINQ
.Distinct(comparer)
Sortowanie po czasie, dacie lub złożonej kombinacji pól
Comparison<T>
, lambda-na szybko
List<T>.Sort((a, b) => ...)
Użycie w LINQ (jednorazowe zapytania) lambda w
OrderBy
,
ThenBy
OrderBy(x => x.Name)

6. Praktyka: wyszukiwanie i porównywanie w kolekcjach

Zaimplementujmy w naszej aplikacji rejestrację użytkownika z unikalnym e-mailem (bez uwzględniania wielkości liter). Jeśli użytkownik z takim adresem już istnieje, trzeba o tym powiedzieć.

public static bool RegisterUser(List<User> users, User newUser)
{
    // Używamy Any z lambdą do sprawdzenia unikalności e-mail
    bool exists = users.Any(u =>
        string.Equals(u.Email, newUser.Email, StringComparison.OrdinalIgnoreCase));
    if (exists)
    {
        Console.WriteLine($"Użytkownik z e-mail: {newUser.Email} już zarejestrowany!");
        return false;
    }
    users.Add(newUser);
    Console.WriteLine($"Użytkownik {newUser.FirstName} dodany.");
    return true;
}

Użycie:

var userList = new List<User>
{
    new User { Email = "first@example.com" }
};

RegisterUser(userList, new User { FirstName = "Vasya", Email = "FIRST@example.com" });
// Wypisze: "Użytkownik z e-mail: FIRST@example.com już zarejestrowany!"

7. Typowe błędy przy używaniu komparatorów

Wszyscy lubią listy błędów, ale zrobimy to obrazowo.

Czasem, gdy programista implementuje komparator dla własnego typu, zapomina sprawdzić null albo — co gorsza — robi porównanie tak, że łamie "ścisłość porządku". Na przykład, jeśli w komparatorze zwraca sprzeczne wartości, to sortowanie będzie się zachowywać nieprzewidywalnie, a w kolekcjach zaczną ginąć albo "sklejać się" obiekty.

Jeszcze jedno częste nieporozumienie — to niespójność implementacji Equals i logiki komparatora. Na przykład, jeśli Equals uważa, że obiekty są różne, a komparator — że takie same, to w SortedSet albo SortedDictionary zacznie się bałagan: element nie zostanie znaleziony, chociaż wydaje się, że tam jest.

Zdarzają się też sytuacje, gdy programista porównuje tylko jedną cechę obiektu (np. nazwisko), zapominając, że innych użytkowników z tym samym nazwiskiem może być wielu. W efekcie obiekty "nadpisują się", giną i dane stają się niespójne. Czyli nie odzwierciedlają już rzeczywistego stanu systemu — pojawia się duplikacja, znikanie potrzebnych informacji albo naruszenie logiki działania programu.

2
Zadanie
C# SELF, poziom 30, lekcja 4
Niedostępne
Tworzenie komparatora do sortowania według wieku
Tworzenie komparatora do sortowania według wieku
1
Ankieta/quiz
Komparatory, poziom 30, lekcja 4
Niedostępny
Komparatory
Komparatory i porównywanie obiektów
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION