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) | |
|
| Kilka wariantów sortowania (po imieniu, wieku, e-mail ...) | (klasy, lambdy) |
|
| Wyszukiwanie unikalnych elementów w kolekcji | |
, |
| Grupowanie, usuwanie duplikatów | |
LINQ |
| Sortowanie po czasie, dacie lub złożonej kombinacji pól | , lambda-na szybko |
|
| Użycie w LINQ (jednorazowe zapytania) | lambda w , |
|
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.
GO TO FULL VERSION