1. Вступ
Коли ви сортуєте список чисел, усе просто: число і є значенням, за яким ми порівнюємо елементи. Але реальне життя швидко підкидає складніші дані: «категорія — сума», «імʼя — бали», «товар — ціна», «покупка — вартість — коментар». Елементи вже не «просяться» на пряме порівняння — тож потрібно вирішити: за чим саме ми сортуємо.
Оце «за чим» і називають ключем сортування: ми беремо елемент, обчислюємо з нього значення для порівняння (ключ) — і сортуємо вже за цим ключем. Kotlin якраз дає для цього дуже зручні інструменти: sortedBy і sortedByDescending.
Уявіть, що у вас є коробка з посилками, а на кожній — наліпка «вага». Сортувати можна посилки (елементи списку), але порівнювати зручніше за наліпкою (ключем). Якщо наліпка — вага, сортуємо за вагою; якщо наліпка — місто, сортуємо за містом.
2. Сортування за ключем через sortedBy і sortedByDescending
Почнімо з найпоширенішого сценарію. Маємо список: його елементи не обовʼязково «порівнювані самі по собі», зате з кожного елемента можна дістати щось порівнюване (число, рядок, довжину рядка). Операція Kotlin sortedBy { selector } якраз приймає функцію-селектор ключа й повертає новий список, відсортований за зростанням ключа.
Сортування за зростанням: sortedBy {...}
Спочатку — мініприклад без нашого проєкту. Просто подивімося на механіку. Сортуємо рядки за довжиною (ключ — it.length):
fun main() {
val words = listOf("bbb", "a", "cc")
val sorted = words.sortedBy { it.length }
println(sorted) // [a, cc, bbb]
}
Тепер перенесімо це в практичний застосунок. Нагадаю модель даних нашого поточного консольного трекера витрат (поки що без класів): витрата — це Triple(category, amount, note). Категорія й нотатка — рядки, сума — Int.
Сортування всіх витрат за сумою (за зростанням) виглядає так:
fun main() {
val expenses = listOf(
Triple("food", 450, "coffee"),
Triple("taxi", 1200, "airport"),
Triple("food", 150, "snack")
)
val sortedByAmount = expenses.sortedBy { it.second }
println(sortedByAmount)
// [(food, 150, snack), (food, 450, coffee), (taxi, 1200, airport)]
}
Тут важливо правильно «прочитати» код: ми сортуємо самі Triple, але порівнюємо їх за ключем it.second, тобто за сумою.
Сортування за спаданням: sortedByDescending {...}
Більш «прикладний» варіант — відсортувати за спаданням. У звітах ми майже завжди хочемо бачити найбільші значення на початку. Для цього є sortedByDescending { selector }. Принцип той самий: сортуємо за ключем, лише порядок — зворотний.
Наприклад, хочемо вивести найдорожчі покупки на початку:
fun main() {
val expenses = listOf(
Triple("food", 450, "coffee"),
Triple("taxi", 1200, "airport"),
Triple("books", 700, "kotlin book")
)
val sorted = expenses.sortedByDescending { it.second }
println(sorted)
// [(taxi, 1200, airport), (books, 700, kotlin book), (food, 450, coffee)]
}
І ось тут уже напрошується наступний крок: «А можна не весь список, а лише перші N?». Можна — і це буде наш класичний патерн top‑N.
3. Top‑N: sortedByDescending {...}.take(n) і чому take без сортування — не топ
Коли у вимогах пишуть «покажи топ‑3 категорій», майже завжди мають на увазі: відсортуй за метрикою й візьми перші три. Kotlin дозволяє виразити це одним ланцюжком: sortedByDescending(...).take(n).
Важливо не переплутати: take(n) не шукає найкращі елементи. Він просто бере перші n елементів у поточному порядку. Тому «взяти top‑N» без сортування — це буквально «взяти перші N як доведеться».
Спочатку — простий приклад на числах:
fun main() {
val scores = listOf(10, 70, 50, 20, 90)
val top3 = scores.sortedDescending().take(3)
println(top3) // [90, 70, 50]
}
Тепер зробімо top‑2 найдорожчих витрат у нашому форматі Triple:
fun main() {
val expenses = listOf(
Triple("food", 450, "coffee"),
Triple("taxi", 1200, "airport"),
Triple("books", 700, "kotlin book"),
Triple("food", 150, "snack")
)
val top2 = expenses
.sortedByDescending { it.second }
.take(2)
println(top2)
// [(taxi, 1200, airport), (books, 700, kotlin book)]
}
Зверніть увагу на приємний момент: якщо елементів менше, ніж n, take(n) просто поверне «скільки є» — без падінь і драм.
4. Кілька критеріїв: sortedWith і compareBy
Одна з найчастіших «побутових» задач — сортувати за кількома правилами. Наприклад, хочемо відсортувати список пар (category, total) так, щоб спочатку йшли категорії за назвою (для гарного виводу), а якщо назва однакова — другим ключем була сума (або, скажімо, вік).
Сортування за кількома критеріями — це як сортування документів у теці: спочатку за «Прізвищем», а серед однакових прізвищ — за «Імʼям», а серед однакових імен — за «Датою». Одного критерію вже недостатньо.
У Kotlin для випадків, коли «потрібно задати явне правило порівняння», є sortedWith(comparator), а щоб не писати компаратор вручну, — фабрика компараторів compareBy(...). Стандартний шлях виглядає так: sortedWith(compareBy {...}).
sortedWith(comparator): сортуємо за явним правилом
sortedWith(...) приймає обʼєкт Comparator<T> і повертає новий відсортований список. Це корисно, коли:
- елемент не Comparable,
- у вас кілька критеріїв,
- у вас складна логіка порівняння (наприклад, «спочатку VIP, потім за сумою»).
Спочатку приклад простіший: сортуємо рядки за довжиною через компаратор:
fun main() {
val words = listOf("aaa", "bb", "c")
val sorted = words.sortedWith(compareBy { it.length })
println(sorted) // [c, bb, aaa]
}
Технічно це схоже на sortedBy { it.length }, але ідея інша: ми явно кажемо «ось правило порівняння». Це особливо зручно, коли критеріїв кілька.
compareBy(...): компаратор за одним ключем
compareBy — це спосіб сказати: «побудуй мені компаратор, який порівнює елементи за такими-то ключами». Ми зараз не заглиблюємося в устрій компаратора, а використовуємо його як готовий інструмент.
Наприклад, сортуємо наші витрати (Triple) за категорією (за абеткою):
fun main() {
val expenses = listOf(
Triple("taxi", 1200, "airport"),
Triple("food", 450, "coffee"),
Triple("books", 700, "kotlin book")
)
val sorted = expenses.sortedWith(compareBy { it.first })
println(sorted)
// [(books, 700, kotlin book), (food, 450, coffee), (taxi, 1200, airport)]
}
compareBy({ ... }, { ... }): компаратор за кількома ключами
compareBy уміє приймати кілька селекторів. Тоді сортування працює так: спочатку за першим ключем, у разі рівності — за другим, і так далі.
Зробімо приклад на парах (category, total) і відсортуймо спочатку за назвою категорії, а потім за сумою (за зростанням):
fun main() {
val totals = listOf(
"food" to 200,
"taxi" to 250,
"food" to 150
)
val sorted = totals.sortedWith(
compareBy<Pair<String, Int>>({ it.first }, { it.second })
)
println(sorted) // [(food, 150), (food, 200), (taxi, 250)]
}
Тут тип Pair<String, Int> я вказав явно, щоб компілятору було простіше (і читачеві теж). Якщо ви поки не дуже впевнені у виведенні типів, не соромтеся їх указувати: це не «слабкість», а страховка від загадкових помилок, які о 23:00 здаються містикою.
5. Мініінтеграція: звіт top‑N категорій за сумою
Тепер зберімо мінісценарій, дуже схожий на реальну фічу в трекері витрат. Маємо Map<String, Int> із підсумками за категоріями й хочемо вивести top‑N категорій за сумою.
Ми поки не використовуємо groupBy (це буде пізніше), тож уявімо, що totalsByCategory уже пораховано раніше нашим кодом (наприклад, в обробнику команди total). Далі — чиста механіка сортування.
Схема пайплайна:
Map<String, Int>
→ entries
→ List<Pair<String, Int>>
→ sortedByDescending { сума }
→ take(n)
→ друк
Код (розібʼємо на маленькі кроки, щоб усе читалося по-людськи):
fun main() {
val totalsByCategory = mapOf(
"food" to 1200,
"taxi" to 2500,
"books" to 900
)
val topN = 2
val pairs = totalsByCategory.entries.map { it.key to it.value }
val top = pairs.sortedByDescending { it.second }.take(topN)
println(top) // [(taxi, 2500), (food, 1200)]
}
Тут особливо важливі дві речі.
По-перше, ми сортуємо не Map напряму, а його елементи, перетворені на список пар. Це нормальна практика: сортування — операція над послідовністю (списком), а Map сам по собі — «про доступ за ключем».
По-друге, top‑N робиться строго в такому порядку: спочатку сортуємо, потім беремо перші n. Якщо поміняти місцями, отримаєте «перші N за внутрішнім порядком Map», а це майже ніколи не є топом (окрім випадкових збігів, які потім ламають довіру до програми).
6. Як обирати інструмент і читати sortedBy
У якийсь момент студенти починають питати: «А що краще — sortedBy чи sortedWith(compareBy(...))?». Питання цілком нормальне: обидва варіанти часто виглядають схоже.
Нижче — маленька таблиця, щоб закріпити вибір:
| Задача | Що писати | Чому так |
|---|---|---|
| Один простий ключ, за зростанням | |
Найкоротший і найчитабельніший варіант. |
| Один простий ключ, за спаданням | |
Той самий принцип, просто «спочатку більше». |
| Кілька критеріїв | |
Явно видно набір критеріїв і порядок їх застосування. |
| Нестандартне правило порівняння | |
Коли потрібно вручну описати логіку порівняння. |
І невелике «людське правило»: якщо у вас один ключ — беріть sortedBy. Якщо критеріїв кілька, найчастіше sortedWith(compareBy(...)) читається краще й спокійніше масштабується.
Перед фіналом давайте прямо проговоримо типову плутанину. У sortedBy {...} усередині лямбди it — це елемент списку, а результат лямбди — ключ сортування.
Приклад, де люди часто плутаються (бо Pair і так короткий):
fun main() {
val totals = listOf("food" to 1200, "taxi" to 2500)
val sorted = totals.sortedBy { it.second }
println(sorted) // [(food, 1200), (taxi, 2500)]
}
it — це пара (String, Int). it.second — це Int, ключ сортування. Ми сортуємо пари, а порівнюємо числа.
Якщо ви впіймали себе на думці «чому я сортую Int, якщо в мене список пар?» — вітаю: ви якраз помітили ключ сортування. Отже, мозок усе робить правильно.
7. Типові помилки
Помилка №1: використовувати take(n) без сортування й називати це «топ‑N».
take(n) — це не «вибери найкращі», а «відріж перші n». Якщо вихідний порядок — порядок додавання, то ви отримаєте «перші додані», а не «найбільші». Виправлення майже завжди одне: спочатку сортування за метрикою, потім take.
Помилка №2: сортувати за неправильним ключем і переплутати first/second/third.
Коли дані зберігаються в Pair/Triple, дуже легко відсортувати не за тим полем і кілька хвилин дивитися на результат із відчуттям, що програма «знущається». Рятує проста звичка: перед сортуванням називати зміст полів у голові («first = категорія, second = сума»), а в коді інколи тимчасово друкувати пару елементів для перевірки.
Помилка №3: намагатися відсортувати Map «як є», а потім дивуватися.
Map — це не список; основний сенс цього типу — доступ за ключем, а не порядок. Для сортування майже завжди потрібно зробити entries.map { ... }, отримати список — і вже його сортувати. Це не милиця, а нормальна трансформація контейнера під задачу.
Помилка №4: намагатися втиснути кілька критеріїв в одну «монструозну» лямбду.
Коли критеріїв два-три, новачки інколи пишуть величезний sortedWith { a, b -> ... }, де порівняння перемішані, дужки живуть своїм життям, і все це нагадує заклинання. У більшості випадків читабельніше й безпечніше використати sortedWith(compareBy({ ... }, { ... })), де прямо видно, у якому порядку застосовуються ключі.
Помилка №5: «загубити» результат сортування.
Сортування на кшталт sortedBy і sortedWith повертають новий список, а вихідний не змінюють. Якщо ви написали expenses.sortedBy { it.second } і далі друкуєте expenses, то нічого «магічно» не сталося. Лікується просто: зберігайте результат у val або продовжуйте ланцюжок одразу (наприклад, sortedByDescending { ... }.take(3)), як ми робили вище.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ