CodeGym /Kursy /C# SELF /Trudne przypadki porównywania i najlepsze praktyki

Trudne przypadki porównywania i najlepsze praktyki

C# SELF
Poziom 30 , Lekcja 3
Dostępny

1. Wielopoziomowe (hierarchiczne) sortowanie

Dlaczego porównywanie nie zawsze jest proste?

Na rozmowach o pracę z C# często lubią zadawać pytania o sortowanie i porównywanie złożonych obiektów. I to nie jest tylko moda, ale odzwierciedlenie realnych problemów, z którymi spotyka się każdy programista. Szukanie rekordów w bazie, sortowanie widoków i tabel w interfejsie, unikalność w kolekcjach — wszystko to jest bezpośrednio związane z poprawnością porównywania obiektów.

Jeśli porównujesz liczby — wszystko proste. Ale jeśli chcemy uporządkować użytkowników po nazwisku, potem po imieniu, a jeszcze uwzględnić, że niektóre pola mogą być puste (null) albo stringi są napisane w różnych językach... Tu już trzeba podejść systemowo.

Częsty wymóg: najpierw sortować po jednym kryterium, potem — jeśli równe — po drugim, a jeśli i tu równość — po trzecim.

Przykład: Użytkownik z imieniem, nazwiskiem i datą urodzenia


public class User
{
    public string FirstName { get; set; }
    public string LastName  { get; set; }
    public DateTime BirthDate { get; set; }

    // Dla urody — wyświetlamy info o użytkowniku
    public override string ToString()
        => $"{LastName} {FirstName} ({BirthDate:yyyy-MM-dd})";
}

Po co ta hierarchia?

Wyobraź sobie, że mamy listę użytkowników i chcemy wyświetlić ich alfabetycznie: najpierw po nazwisku, potem po imieniu. Jeśli nazwiska i imiona się pokrywają — po dacie urodzenia.

Logika porównywania: "łańcuch odpowiedzialności"

To bardzo przypomina sortowanie uczestników olimpiad: najpierw po punktach, przy równości — po czasie oddania, jeśli i tu równość — alfabetycznie. W kodzie robi się to prosto:


public class UserComparer : IComparer<User>
{
    public int Compare(User x, User y)
    {
        // Porównujemy nazwiska
        int result = string.Compare(x.LastName, y.LastName, StringComparison.OrdinalIgnoreCase);

        if (result != 0) return result; // Jeśli różne — wystarczy

        // Jeśli nazwiska równe — porównujemy imiona
        result = string.Compare(x.FirstName, y.FirstName, StringComparison.OrdinalIgnoreCase);
        if (result != 0) return result;

        // Jeśli nazwiska i imiona równe — porównujemy daty urodzenia
        return x.BirthDate.CompareTo(y.BirthDate);
    }
}

Jak użyć:


var users = new List<User>
{
    new User { FirstName = "Ivan", LastName = "Ivanov", BirthDate = new DateTime(1990, 1, 1) },
    new User { FirstName = "Piotr", LastName = "Ivanov", BirthDate = new DateTime(1992, 5, 1) },
    new User { FirstName = "Anna", LastName = "Petrova", BirthDate = new DateTime(1985, 8, 30) }
};

users.Sort(new UserComparer());
users.ForEach(Console.WriteLine);
// Petrova Anna (1985-08-30)
// Ivanov Ivan (1990-01-01)
// Ivanov Piotr (1992-05-01)

2. Jak porównywać stringi? Kulturalne niuanse

Porównywanie stringów: Ordinal, CurrentCulture, InvariantCulture

Niby string to string, ale nie wszystko takie oczywiste! Na przykład rosyjskie ё i е, niemieckie ss i ß, różna wielkość liter...

W .NET do porównywania stringów są specjalne reguły, ustawiane przez StringComparison. To może wpływać i na sortowanie, i na wyszukiwanie.

Przykład porównywania stringów:


// w niemieckim ß prawie równa się ss
string a = "straße";
string b = "STRASSE";

bool eq1 = string.Equals(a, b, StringComparison.Ordinal); // false
bool eq2 = string.Equals(a, b, StringComparison.OrdinalIgnoreCase); // false
bool eq3 = string.Equals(a, b, StringComparison.CurrentCultureIgnoreCase); // true 

W dwóch pierwszych przypadkach porównanie jest bajtowe — bez uwzględnienia kultury i językowych niuansów, więc ß i SS są różne. Ale w trzecim przypadku używana jest aktualna kultura (np. niemiecka) i string jest traktowany tak, jak przeczytałby go native speaker: ß traktuje się jak ss, a wielkość liter jest ignorowana. Dlatego eq3 zwraca true.

Jak wybrać właściwy sposób porównywania?

  • Ordinal — szybki, bajtowy, dobry do technicznych rzeczy (np. porównywania identyfikatorów).
  • CurrentCulture / InvariantCulture — do tekstów użytkownika, uwzględniają reguły języka systemu lub ustawionej kultury.

W Sort, Compare i innych metodach staraj się jawnie podawać wariant porównywania:


string.Compare(x, y, StringComparison.CurrentCultureIgnoreCase)

Niuanse sortowania w różnych językach

Sortowanie po rosyjsku — ё może być po е, a może być traktowane jako równe (zależy od kultury!). Więc jeśli robisz coś poważnego (np. prowadzisz słownik), dopytaj klienta lub analityka biznesowego, jak poprawnie sortować "specjalne" litery.

3. Ochrona przed null: nie każdy obiekt jest grzeczny

Co robić, jeśli w polu jest null?

W prawdziwych programach ktoś na pewno zapomni wypełnić pole i wtedy przy porównaniu — bum! — wyjątek NullReferenceException. Naszym zadaniem jest być na to gotowym.

Przykład bez ochrony przed null:


public int Compare(User x, User y)
{
    return x.LastName.CompareTo(y.LastName); // jeśli LastName == null, będzie błąd!
}

Przykład z ochroną (null mniejszy od każdego nie-null):


public int Compare(User x, User y)
{
    // Używamy specjalnego comparera dla stringów, który uwzględnia null
    int byLastName = Comparer<string>.Default.Compare(x.LastName, y.LastName);
    if (byLastName != 0) return byLastName;

    // i tak dalej...
}

Jeszcze krócej:


public int Compare(User x, User y)
{
    return string.Compare(x?.LastName, y?.LastName, StringComparison.OrdinalIgnoreCase);
}

"Null-e" na początku czy na końcu?

Można zrobić tak, żeby wszyscy użytkownicy z "pustymi" nazwiskami trafiali na początek albo na koniec listy — zależy od zadania.


public int Compare(User x, User y)
{
    if (x.LastName == null && y.LastName == null) return 0;
    if (x.LastName == null) return 1;   // null — na koniec
    if (y.LastName == null) return -1;  // null — na koniec
    return string.Compare(x.LastName, y.LastName, StringComparison.OrdinalIgnoreCase);
}

4. Porównywanie po kilku kryteriach

Częsty błąd początkujących — używanie arytmetyki zamiast "łańcucha odpowiedzialności" przy porównywaniu, np.:


// Nie rób tak!
public int Compare(User x, User y)
{
    // Zły przykład
    return (x.Age - y.Age) + string.Compare(x.FirstName, y.FirstName, StringComparison.Ordinal);
}

Ten sposób nie gwarantuje poprawnego sortowania: jeśli różnica wieku to -100, a porównanie stringów zwraca 1, wynik będzie -99, co nie odpowiada oczekiwanej logice sortowania.

Poprawnie jest używać jasnej sekwencji:
jeśli już jest różnica — zwracamy ją, w przeciwnym razie patrzymy na następne kryterium.

5. Przydatne niuanse

Co jeśli obiekty są równe po głównych kryteriach?

Jeśli jednak obiekty okazują się "równe", ważne, żeby algorytm sortowania był stabilny: nie zmieniał kolejności elementów, które są równe według porównania. Wbudowany List<T>.Sort() nie gwarantuje stabilności. Jeśli to ważne (np. przy sortowaniu tabel z wieloma poziomami sortowania użytkownika), używaj LINQ-owych metod OrderBy/ThenBy — one są stabilne.

Porównywanie z uwzględnieniem opcjonalnych/nullable pól

W .NET spotyka się modele, gdzie pole jest np. typu DateTime? (Nullable<DateTime>) albo int?. Tu logika jest prosta: null mniejszy od nie-null, albo odwrotnie — zależy od zadania. Można użyć helperów ze standardowej biblioteki:


int result = Nullable.Compare<DateTime>(u1.BirthDate, u2.BirthDate);

Porównywanie z dodatkowymi regułami

Czasem trzeba, żeby porównanie uwzględniało "wagę" kryterium, np. VIP-klienci zawsze idą pierwsi. Rozwiązanie — dodać "VIP-flagę" na początek sortowania.


public int Compare(User x, User y)
{
    // VIP idą na początek
    int vipResult = y.IsVip.CompareTo(x.IsVip); // true = 1, false = 0; sortujemy malejąco
    if (vipResult != 0) return vipResult;

    // Reszta — klasycznie
    int result = string.Compare(x.LastName, y.LastName, StringComparison.OrdinalIgnoreCase);
    if (result != 0) return result;
    return string.Compare(x.FirstName, y.FirstName, StringComparison.OrdinalIgnoreCase);
}

6. Porady i najlepsze praktyki

Zawsze sprawdzaj null
Nowoczesny C# coraz bardziej dąży do "null-bezpieczeństwa", ale stary kod — nie jest "null-bezpieczny". Zawsze dodawaj ochronę lub używaj odpowiednich metod.

Unikaj "magicznych" liczb
Nie pisz return x.Field - y.Field; dla pól, gdzie możliwe jest przekroczenie zakresu (overflow). Jeśli pola są typu long — na pewno mogą być błędy.

Używaj StringComparison
Nie polegaj na domyślnym zachowaniu porównywania stringów. Jawnie przekazuj StringComparison.OrdinalIgnoreCase albo inny odpowiedni do zadania.

Oddzielaj porównywanie i równość
Interfejsy IComparable<T> i IEqualityComparer<T> służą do różnych rzeczy. Do sortowania używaj comparatorów, do szukania unikalnych obiektów — równości. Czasem mogą dawać różny wynik!

Dodawaj testy na "nietypowe" przypadki
Sprawdź, czy sortowanie działa poprawnie, jeśli pola są równe, puste, stringi mają różną wielkość liter lub język.

Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION