1. Вступ
Уявіть, що ви просите друга розкласти книжки по полицях. Якщо це книжки з номерами на корінцях (1, 2, 3…), ваш друг зробить це без проблем: «Ага, 1 йде перед 2, 2 перед 3». Це — природний порядок чисел (за порядком). Так само, якщо це книжки, назви яких починаються з літер «А», «Б», «В», він розкладе їх за абеткою. Це теж «природний» порядок для рядків (за абеткою).
Але що, коли на корінцях зазначені лише прізвища авторів? І ви просите: «Розкладіть за порядком». Ваш друг запитає: «За яким? За прізвищем? За роком видання? За кількістю сторінок?» Ось тут і починається проблема. Для ваших унікальних сутностей немає очевидного природного порядку.
Те саме і в програмуванні. Коли ми намагаємося відсортувати список цілих чисел List<int>, C# чудово знає, як це зробити. Для List<string> він також це вміє, використовуючи лексикографічний порядок (за абеткою). Але якщо у нас є List<Student>, де кожен Student — це об’єкт, що об’єднує імʼя, прізвище, вік, ідентифікатор та ще багато чого, то C# не знає, як діяти. Він не розуміє, за якою з цих ознак порівнювати двох студентів. За імʼям? За ID? За середнім балом? Це і є та сама проблема, із якою ми зіткнулися у лекції «Сортування колекцій», коли List<T>.Sort() видав помилку під час спроби відсортувати колекцію користувацьких типів.
Щоб розв’язати цю задачу, нам потрібно надати C# чітку інструкцію, за яким принципом порівнювати наші об’єкти. Для цього існує інтерфейс IComparable<T>.
2. Інтерфейс IComparable<T>
Отже, щоб навчити наші об’єкти «знати» своє місце порівняно з іншими такими самими об’єктами, ми використовуємо інтерфейс IComparable<T>. Це — контракт. Коли ваш клас або структура реалізує цей інтерфейс, ви ніби кажете компілятору: «Мої об’єкти можна порівнювати один з одним, і ось інструкція, як саме це робити».
Як це працює?
Інтерфейс IComparable<T> визначає лише один метод:
public interface IComparable<in T>
{
int CompareTo(T other);
}
Цей метод приймає об’єкт типу T (того ж типу, що й поточний об’єкт) і має повертати:
- від’ємне число (< 0), якщо поточний об’єкт «менший» за порівнюваний;
- нуль (0), якщо вони «рівні» (з погляду сортування);
- додатне число (> 0), якщо поточний об’єкт «більший».
По суті, це схоже на суддів на змаганнях, наприклад, у боксерському поєдинку: якщо ви діяли переконливіше — виграли раунд і отримали більше балів; якщо слабше — менше; якщо бій був рівний — рахунок однаковий. Тільки тут замість суддів усе ще простіше: число зі знаком.
Чому саме так?
Методи сортування (наприклад, List<T>.Sort()) викликають CompareTo для елементів списку, щоб зрозуміти, кого ставити раніше, а кого — пізніше. Якщо ваш клас реалізує цей інтерфейс — його можна сортувати!
3. Практика
Припустімо, у нас є такий клас користувача (User):
public class User
{
public string Name { get; set; }
public int Age { get; set; }
}
Спробуймо відсортувати список користувачів:
List<User> users = new List<User>
{
new User { Name = "Сергій", Age = 31 },
new User { Name = "Марія", Age = 22 },
new User { Name = "Антон", Age = 27 }
};
users.Sort(); // Помилка: InvalidOperationException
З’являється помилка: «At least one object must implement IComparable» (принаймні один об’єкт має реалізувати IComparable).
Виправляємо: реалізуємо IComparable<User>
Додаємо інтерфейс до нашого класу. Нехай спочатку сортування буде за віком — від молодших до старших:
public class User : IComparable<User>
{
public string Name { get; set; }
public int Age { get; set; }
public int CompareTo(User other)
{
// Перевірка на null: якщо other == null, поточний користувач вважається більшим
if (other == null) return 1;
return this.Age.CompareTo(other.Age); // сортуємо за віком
}
}
Тепер сформуємо список, викличемо users.Sort(); і виведемо в консоль результат:
List<User> users = new List<User>
{
new User { Name = "Сергій", Age = 31 },
new User { Name = "Марія", Age = 22 },
new User { Name = "Антон", Age = 27 }
};
// Сортування за віком (використовує CompareTo)
users.Sort();
// Виведення відсортованих користувачів
foreach (User user in users)
{
Console.WriteLine($"{user.Name}, {user.Age}");
}
Список відсортується за віком:
Марія, 22
Антон, 27
Сергій, 31
Візуалізація: до і після
| Імʼя | Вік |
|---|---|
| Сергій | 31 |
| Марія | 22 |
| Антон | 27 |
Після сортування:
| Імʼя | Вік |
|---|---|
| Марія | 22 |
| Антон | 27 |
| Сергій | 31 |
4. Важливі деталі та часті помилки
Захист від null
Усередині CompareTo дуже важливо перевіряти, що other не дорівнює null. Якщо цей момент пропустити, можна отримати NullReferenceException. Зазвичай, якщо порівнюваний об’єкт дорівнює null, вважають, що поточний більший:
public int CompareTo(User other)
{
if (other == null) return 1;
// ...
}
Дотримуйтеся транзитивності
Якщо А < B і B < C, то А має бути < C. Якщо не дотримуватися цього правила, сортування поводитиметься непередбачувано (тобто весело, але неправильно!).
Якщо потрібно сортувати за кількома полями
Припустімо, спочатку потрібно сортувати за віком, а якщо двоє користувачів — однолітки, сортуємо їх за ім’ям за абеткою. Це робиться так:
public int CompareTo(User other)
{
if (other == null) return 1;
int ageCompare = this.Age.CompareTo(other.Age);
if (ageCompare != 0) return ageCompare;
// Якщо вік однаковий — порівнюємо за імʼям
return this.Name.CompareTo(other.Name);
}
5. Сортування: тепер і з вашими об’єктами
Усе, що вміє List<int>, тепер уміє й ваш клас
Тепер можна використовувати будь-який метод, який потребує порівняння: Sort, BinarySearch, навіть вставлення до відсортованих колекцій (наприклад, у SortedSet<T>).
users.Sort();
// users тепер відсортований за віком (й за імʼям за однакового віку)
Приклад у контексті застосунку курсу
Припустімо, раніше ви вже створювали застосунок для обліку користувачів. Тепер ми можемо додати для них «природний» порядок сортування просто в наявний код. Ось як це виглядатиме:
// Наш клас User вже реалізує IComparable<User>
List<User> users = new List<User>
{
new User { Name = "Іван", Age = 45 },
new User { Name = "Галина", Age = 27 },
new User { Name = "Юрій", Age = 27 }
};
// Відсортуємо за віком, потім за імʼям
users.Sort();
foreach (var u in users)
{
Console.WriteLine($"{u.Name} - {u.Age}");
}
Результат:
Галина - 27
Юрій - 27
Іван - 45
Галина і Юрій — однолітки, сортування за ім’ям при однаковому віці.
6. Як влаштований CompareTo: сувора математика
Зупинімося ще раз на стандарті значень, які повертаються:
- Від’ємне число (наприклад, -1): поточний об’єкт іде перед порівнюваним.
- Нуль: вважаються рівними з погляду сортування.
- Додатне число (наприклад, 1): поточний об’єкт іде після порівнюваного.
Вбудовані типи (наприклад, Age.CompareTo(other.Age)) уже реалізують цей стандарт, завжди повертаючи -1, 0 або 1.
Таблиця повернень для методу CompareTo
| Повертається значення | Що означає? | Приклад |
|---|---|---|
| < 0 | Менше (йде раніше) | |
| 0 | Рівні | |
| > 0 | Більше (йде пізніше) | |
7. Множинне сортування: комбінуємо поля
Іноді потрібно виконати складніше сортування: наприклад, за прізвищем, ім’ям і віком. Використовуючи вже знайомий прийом, можна порівнювати послідовно:
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. Коли НЕ варто реалізовувати IComparable<T>
Ситуація з життя (програмістська!): якщо в об’єкта немає «природного» порядку сортування, краще не реалізовувати IComparable<T>. Наприклад, якщо у вас клас Point, а ви не знаєте — сортувати за X, за Y чи за відстанню від початку координат, — варто передавати порівняння ззовні за допомогою функції порівняння (IComparer<T>). Про це поговоримо на наступній лекції.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ