1. Багаторівневе (ієрархічне) сортування
Чому порівняння не завжди просте?
На співбесідах з C# часто люблять питати про сортування та порівняння складних об’єктів. І це не просто мода, а відображення реальних проблем, з якими стикається будь‑який розробник. Пошук записів у базі, сортування вітрин і таблиць в інтерфейсі, унікальність у колекціях — усе це безпосередньо пов’язано з коректністю порівняння об’єктів.
Якщо порівнювати числа — усе просто. А от якщо ми хочемо впорядкувати користувачів за прізвищем, потім за ім’ям, додатково врахувати, що деякі поля можуть бути порожніми (null), а рядки написані різними мовами… Тут уже потрібен системний підхід.
Часто потрібно: спочатку сортувати за однією ознакою, якщо є рівність — за іншою, а коли й тут рівність — за третьою.
Приклад: Користувач з ім’ям, прізвищем і датою народження
public class User
{
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime BirthDate { get; set; }
// Для краси — виводимо інформацію про користувача
public override string ToString()
=> $"{LastName} {FirstName} ({BirthDate:yyyy-MM-dd})";
}
Навіщо потрібна ієрархія?
Уявіть, що маємо список користувачів і хочемо вивести їх у алфавітному порядку: спочатку за прізвищем, потім за ім’ям. Якщо і прізвища, і імена збіглися — за датою народження.
Логіка порівняння: «ланцюжок відповідальності»
Це схоже на те, як сортують учасників олімпіад: спочатку за балами, за рівності — за часом здачі, якщо й тут рівність — за алфавітом. У коді таке реалізується просто:
public class UserComparer : IComparer<User>
{
public int Compare(User x, User y)
{
// Порівнюємо прізвища
int result = string.Compare(x.LastName, y.LastName, StringComparison.OrdinalIgnoreCase);
if (result != 0) return result; // Якщо різні — цього досить
// Якщо прізвища рівні — порівнюємо імена
result = string.Compare(x.FirstName, y.FirstName, StringComparison.OrdinalIgnoreCase);
if (result != 0) return result;
// Якщо прізвища й імена рівні — порівнюємо дати народження
return x.BirthDate.CompareTo(y.BirthDate);
}
}
Як застосувати:
var users = new List<User>
{
new User { FirstName = "Іван", LastName = "Іванов", BirthDate = new DateTime(1990, 1, 1) },
new User { FirstName = "Петро", LastName = "Іванов", BirthDate = new DateTime(1992, 5, 1) },
new User { FirstName = "Анна", LastName = "Петрова", BirthDate = new DateTime(1985, 8, 30) }
};
users.Sort(new UserComparer());
users.ForEach(Console.WriteLine);
// Петрова Анна (1985-08-30)
// Іванов Іван (1990-01-01)
// Іванов Петро (1992-05-01)
2. Як порівнювати рядки? Культурні особливості
Порівняння рядків: Ordinal, CurrentCulture, InvariantCulture
Здається, рядок — і скрізь рядок, але не все так очевидно! Наприклад, українські ї та і, німецькі ss та ß, різний регістр…
У .NET для порівняння рядків є особливі правила, які задаються через StringComparison. Це може впливати і на сортування, і на пошук.
Приклад порівняння рядків:
// У німецькій мові ß майже еквівалентна 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
У перших двох випадках порівняння відбувається побайтово — без урахування культури та мовних особливостей, тому ß і SS вважаються різними. А у третьому випадку використовується поточна культура (наприклад, німецька), і рядок сприймається так, як його прочитав би носій мови: ß трактується як ss, а регістр ігнорується. Тому eq3 повертає true.
Як вибрати правильний спосіб порівняння?
- Ordinal — швидкий, побайтовий; добре підходить для технічних завдань (наприклад, порівняння ідентифікаторів).
- CurrentCulture / InvariantCulture — підходять для користувацьких текстів; враховують правила мови ОС або заданої культури.
У Sort, Compare та інших методах намагайтеся явно вказувати спосіб порівняння:
string.Compare(x, y, StringComparison.CurrentCultureIgnoreCase)
Особливості сортування різними мовами
Під час сортування українською ї може йти після і, а може вважатися рівною (залежить від культури!). Тому, якщо працюєте над чимось серйозним (наприклад, ведете довідник), уточніть у замовника або бізнес‑аналітика, як правильно сортувати «особливі» літери.
3. Захист від null: не всі об’єкти добре виховані
Що робити, якщо в полі — null?
У реальних застосунках хтось обов’язково забуде заповнити поле, і тоді при порівнянні — бабах! — виникне виняток NullReferenceException. Наше завдання — бути до цього готовими.
Приклад без захисту від null:
public int Compare(User x, User y)
{
return x.LastName.CompareTo(y.LastName); // якщо LastName == null, станеться помилка!
}
Приклад із захистом (null менше будь‑якого не‑null):
public int Compare(User x, User y)
{
// Використовуємо спеціальний компаратор для рядків, який враховує значення null
int byLastName = Comparer<string>.Default.Compare(x.LastName, y.LastName);
if (byLastName != 0) return byLastName;
// і так далі...
}
Ще коротше:
public int Compare(User x, User y)
{
return string.Compare(x?.LastName, y?.LastName, StringComparison.OrdinalIgnoreCase);
}
«null» на початку чи в кінці?
Можна зробити так, щоб усі користувачі з «порожніми» прізвищами потрапляли на початок або в кінець списку — залежить від завдання.
public int Compare(User x, User y)
{
if (x.LastName == null && y.LastName == null) return 0;
if (x.LastName == null) return 1; // null — у кінець
if (y.LastName == null) return -1; // null — у кінець
return string.Compare(x.LastName, y.LastName, StringComparison.OrdinalIgnoreCase);
}
4. Порівняння за кількома ознаками
Часта помилка новачків — використовувати арифметику замість «ланцюжка відповідальності» під час порівняння, наприклад:
// Не робіть так!
public int Compare(User x, User y)
{
// Поганий приклад
return (x.Age - y.Age) + string.Compare(x.FirstName, y.FirstName, StringComparison.Ordinal);
}
Цей спосіб не гарантує коректне сортування: якщо різниця у віці -100, а порівняння рядків повертає 1, підсумок буде -99, що не відповідає очікуваній логіці сортування.
Правильно використовувати чітку послідовність:
якщо вже є різниця — повертаємо її, інакше дивимося на наступну ознаку.
5. Корисні нюанси
Що робити, якщо об’єкти рівні за основними ознаками?
Якщо все ж таки об’єкти виявляються «рівними», важливо, щоб алгоритм сортування був стабільним: не змінював порядок елементів, які рівні згідно з порівнянням. Вбудований List<T>.Sort() не гарантує стабільність. Якщо вона критична (наприклад, під час сортування таблиць із кількома рівнями користувацького сортування), використовуйте LINQ‑методи OrderBy/ThenBy — вони стабільні.
Порівняння з урахуванням необов’язкових (nullable) полів
У .NET трапляються моделі, де поле типу, наприклад, DateTime? (Nullable<DateTime>) або int?. Логіка проста: null менше за не‑null — або навпаки; це залежить від завдання. Можна скористатися помічниками зі стандартної бібліотеки:
int result = Nullable.Compare<DateTime>(u1.BirthDate, u2.BirthDate);
Порівняння з додатковими правилами
Іноді потрібно, щоб порівняння враховувало «вагу» ознаки. Наприклад, VIP‑клієнти завжди йдуть першими. Рішення — додати ознаку VIP на початок сортування.
public int Compare(User x, User y)
{
// VIP — на початок
int vipResult = y.IsVip.CompareTo(x.IsVip); // true = 1, false = 0; сортуємо за спаданням
if (vipResult != 0) return vipResult;
// Решта — як завжди
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. Поради та найкращі практики
Завжди перевіряйте на null
Сучасний C# щораз більше прагне до «null‑безпечності», але старий код — не «null‑безпечний». Завжди додавайте захисти або використовуйте відповідні методи.
Уникайте «магічних» чисел
Не пишіть return x.Field - y.Field; для полів, де можливе переповнення типу (overflow). Якщо поля long, переповнення цілком імовірне.
Використовуйте StringComparison
Не покладайтеся на поведінку порівняння рядків «за замовчуванням». Явно передавайте StringComparison.OrdinalIgnoreCase або інший варіант, що підходить для вашого завдання.
Розрізняйте порівняння та рівність
Інтерфейси IComparable<T> і IEqualityComparer<T> вирішують різні завдання. Для сортування використовуйте компаратори, для пошуку унікальних об’єктів — еквівалентність. Таке трапляється, і результати можуть відрізнятися.
Додавайте тести на «нетипові» випадки
Перевірте, що сортування працює коректно, якщо поля рівні, поля порожні, а рядки різного регістру чи мови.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ