1. Вступ
Коли ви вперше стикаєтеся з null у даних, може здатися: «Ну null — і null, я ж умію писати ?:». Але сортування — це не просто «обробити кожен елемент окремо». Воно вимагає порівнювати елементи попарно, а отже, потрібна відповідь на запитання: якщо один елемент null, а інший — не null, хто з них «більший», а хто «менший»? У null за змістом немає природного місця в порядку, тож це рішення завжди трохи «політичне»: ви самі домовляєтеся, де null має опинитися — на початку чи в кінці.
У Kotlin сортування будують навколо ідеї порівнюваності та компараторів: або природний порядок (Comparable), або користувацький — через Comparator (зокрема зручний compareBy). Якщо ваш ключ сортування може бути null, правило варто зробити явним. Так, інколи стандартна бібліотека «якось» уміє порівнювати nullable-ключі, але проблема не лише в компіляторі — проблема в читабельності. За тиждень ви й самі не згадаєте, чому null опинився на початку списку, а не в кінці.
Привʼяжімо це до нашого практичного консольного застосунку (умовно назвімо його BudgetBuddy): ми зберігаємо витрати як пари "категорія" to сума. І уявімо, що інколи сума невідома (null) — наприклад, ви вирішили дозволити «чернетки» витрат: категорію вже ввели, а суму — додасте пізніше.
2. Модель даних: витрати з невідомою сумою
Коли ми говоримо про null, важливо не перетворювати це на абстрактну філософію. Нехай null означає щось конкретне. У нашому застосунку вважатимемо, що null — це невідома сума. Це не «нуль», а саме «даних немає». І це принципово: нуль — число, а null — відсутність числа.
Ось невеликий приклад даних, на яких зручно тренуватися:
fun main() {
val expenses: List<Pair<String, Int?>> = listOf(
"food" to 250,
"taxi" to null,
"books" to 90,
"food" to null
)
println(expenses)
// [(food, 250), (taxi, null), (books, 90), (food, null)]
}
Тут видно, що null трапляється не як екзотика, а як звичайний елемент колекції. Nullable-типи в Kotlin змушують нас прямо визнати: значення може бути відсутнім. І це добре — мова буквально не дає «забути» про прогалину в даних.
Тепер уявімо, що ми хочемо зробити звіт: вивести категорії, відсортовані за сумою (наприклад, щоб потім узяти top‑N). Але суми подекуди null. Куди їх подіти?
3. Стратегія 1: значення за замовчуванням через ?:
Найпопулярніший підхід у новачків — «давайте підставимо щось замість null і поїдемо далі». Це справді робоча стратегія, якщо ви акуратно підібрали значення для заміни. Elvis-оператор ?: якраз для цього й створений: «якщо зліва null, візьми справа».
null у кінці під час сортування за зростанням
Якщо ми сортуємо за зростанням і хочемо, щоб невідомі значення пішли в кінець, логіка проста: для null підставляємо дуже велике число, щоб воно опинилося «останнім».
fun main() {
val totals: List<Pair<String, Int?>> = listOf(
"food" to 250,
"taxi" to null,
"books" to 90
)
val sorted = totals.sortedBy { (_, total) ->
total ?: Int.MAX_VALUE
}
println(sorted)
// [(books, 90), (food, 250), (taxi, null)]
}
Тут ідея читається навіть людиною, яка не знає Kotlin: «якщо суми немає, вважаємо її нескінченно великою, щоб вона опинилася в кінці».
Прийом простий, але має тонкість: ви не повинні обирати значення за замовчуванням «навмання». Якщо ви підставите 0, null опиниться на початку. А це вже виглядатиме як «таксі = 0», хоча ми мали на увазі «таксі = невідомо».
null на початку під час сортування за зростанням
Іноді за змістом, навпаки, хочеться побачити всі «дірки» зверху — щоб звіт починався з проблемних даних. Тоді для null підставляємо дуже мале число.
fun main() {
val totals: List<Pair<String, Int?>> = listOf(
"food" to 250,
"taxi" to null,
"books" to 90
)
val sorted = totals.sortedBy { (_, total) ->
total ?: Int.MIN_VALUE
}
println(sorted)
// [(taxi, null), (books, 90), (food, 250)]
}
Це вже інша домовленість: «null — найменший». З погляду алгоритму все чесно. З погляду бізнесу — теж чесно, якщо ви саме так вирішили.
sortedByDescending і «перевернуте» значення за замовчуванням
Ось тут новачки часто спотикаються: у sortedByDescending усе навпаки. Якщо ви хочете null у кінці під час сортування за спаданням, то null має стати дуже малим. Інакше він раптом опиниться на початку як «найбільший».
fun main() {
val totals: List<Pair<String, Int?>> = listOf(
"food" to 250,
"taxi" to null,
"books" to 90
)
val sorted = totals.sortedByDescending { (_, total) ->
total ?: Int.MIN_VALUE
}
println(sorted)
// [(food, 250), (books, 90), (taxi, null)]
}
Якщо ваші суми за змістом не бувають відʼємними, можна вибрати ще більш «промовисте» значення за замовчуванням, наприклад -1, щоб прямо підкреслити: «невалідне / невідоме».
4. Коли ?: стає небезпечним
Стратегія «підставимо число» виглядає як універсальний ключ, але має приховану ціну. Підставляючи значення за замовчуванням, ви фактично вбудовуєте бізнес-рішення в арифметику: перетворюєте «немає даних» на «якесь число». Так, ви робите це лише заради сортування, але наступний читач коду може не зрозуміти, що це «технічна милиця», а не реальна логіка.
Уявіть, що ви підставили Int.MAX_VALUE, а потім хтось вирішив порахувати «середнє» за цими ключами (або додати ще один крок у ланцюжок обробки) і випадково використав уже «замінені» числа. У звіті раптом зʼявиться категорія із сумою 2147483647, і це виглядатиме як серйозна фінансова проблема, хоча насправді це проблема програміста.
Тому правило таке: стратегія ?: добра, коли ви використовуєте її лише як ключ сортування (усередині sortedBy({ … })), а вихідні дані (null) лишаєте як є. Тобто ми не переписуємо дані — ми лише домовляємося, як їх порівнювати.
5. Стратегія 2: розділяємо null і non-null
Друга стратегія зазвичай виходить довшою, зате виграє за читабельністю. Замість того щоб «вдавати, що null — число», ми кажемо чесно: «спочатку сортуємо відомі значення, потім додаємо невідомі туди, куди домовилися».
Цей підхід особливо добрий, коли null означає «невідомо», і ви не хочете, щоб хтось навіть теоретично сприйняв його за «0» або «нескінченність».
Почнімо з простого випадку: список чисел List<Int?>.
fun main() {
val xs: List<Int?> = listOf(3, null, 1, null, 2)
val sortedNotNull = xs.filterNotNull().sorted()
val nullCount = xs.count { it == null }
val result = mutableListOf<Int?>()
for (v in sortedNotNull) result.add(v)
repeat(nullCount) { result.add(null) }
println(result)
// [1, 2, 3, null, null]
}
Тут код багатослівніший, зате максимально прямолінійний. Ми використали filterNotNull() і count { … }, які вже траплялися в операціях над колекціями: спочатку «очистили» дані, потім порахували, скільки прибрали, і наприкінці повернули null назад — але вже в кінці списку. І так, цей прийом прямо підкреслює, що null не загубився, а був тимчасово винесений в «окрему кишеню».
6. Вибір стратегії та читабельне правило для null
Сортуємо пари, зберігаючи категорію
Реальні звіти рідко сортують «просто числа». Зазвичай у нас є звʼязка «імʼя–значення», і важливо не втратити імʼя. Тому застосуймо стратегію розбиття до List<Pair<String, Int?>>.
Тут зручна ідея — «розділити на дві колекції»: одну з відомими сумами, іншу — з невідомими. У Kotlin є partition, але навіть без нього можна написати прозорий цикл (а цикл новачкам зазвичай зрозуміліший, ніж «магічні» функції).
fun main() {
val totals: List<Pair<String, Int?>> = listOf(
"food" to 250,
"taxi" to null,
"books" to 90
)
val known = mutableListOf<Pair<String, Int>>()
val unknown = mutableListOf<Pair<String, Int?>>()
for ((cat, total) in totals) {
if (total == null) unknown.add(cat to null) else known.add(cat to total)
}
val knownSorted = known.sortedBy { it.second }
val result = knownSorted.map { it.first to it.second } + unknown
println(result)
// [(books, 90), (food, 250), (taxi, null)]
}
Зверніть увагу на хитрість: у known ми зберігаємо вже не-null суми (Int), тому далі сортування за it.second стає «звичайним», без ?:. Це знижує ризик випадково прийняти null за число й робить подальшу логіку простішою.
Мініорієнтир: коли ?:, а коли розбиття
Коли у вас зʼявляється вибір між двома підходами, легко впасти в крайнощі. Одні починають усюди писати ?: Int.MAX_VALUE і почуваються чарівниками. Інші, навпаки, всюди розділяють дані на пʼять списків і почуваються бухгалтерами. Щоб не перегнути, корисно мати простий критерій: наскільки небезпечно «підміняти зміст» і наскільки важлива читабельність.
Нижче — невеликий орієнтир. Це не закон, але він допомагає ухвалити рішення без довгих медитацій.
| Ситуація | ?: (значення за замовчуванням) | Розбиття (split + merge) |
|---|---|---|
| Дані малі, правило просте («null у кінець») | Зазвичай чудово | Теж ок, але може бути багатослівно |
| Значення за замовчуванням легко підібрати так, щоб воно точно було поза доменом | Чудово | Не обовʼязково |
| Є ризик, що значення за замовчуванням перетнеться з реальними значеннями | Небезпечно й неочевидно | Надійніше |
| Ви боїтеся, що хтось «перевикористає» значення за замовчуванням як реальне число | Краще не треба | Надійніше |
| Потрібно явно зберегти / показати null як проблему даних | Може приховати зміст | Підкреслює зміст |
Якщо хочеться зовсім короткого правила: якщо ви можете одним поглядом пояснити, чому значення за замовчуванням безпечне, використовуйте ?:. Якщо ж починаються фрази на кшталт «ну, взагалі-то суми в нас майже завжди додатні, крім…», краще розділяйте.
«Готові правила»: null first/last як ідея
Іноді ви побачите код, де null ставлять «на початок / у кінець» через готові компаратори або спеціальні функції порівняння. Сьогодні ми не заглиблюємося в «зоопарк» компараторів (щоб не роздулася тема дня), але важливо не лякатися: це все та сама ідея, просто запакована в бібліотечний інструмент.
У будь-якому разі базова думка незмінна: сортування — це контракт, і для null треба вибрати явне місце. Kotlin узагалі любить, коли намір читається з коду: як із nullable-типами, де T? прямо каже «значення може бути відсутнім». А для сортувань Kotlin дає інструменти на кшталт sortedWith(compareBy{ … }), щоб правило порівняння було явним, а не схованим у загадковому if.
7. Вбудовуємо в застосунок: звіт за категоріями
Зараз зробимо невеликий шматочок того, «як це виглядає в живому коді застосунку», але без ускладнень архітектури. Нехай у нас є функція, яка будує «підсумок за категоріями». Але якщо хоча б один витратний запис у категорії невідомий (null), то підсумок теж невідомий (бо ми не можемо чесно порахувати суму).
fun totalsByCategory(expenses: List<Pair<String, Int?>>): List<Pair<String, Int?>> {
val categories = expenses.map { it.first }.distinct()
val result = mutableListOf<Pair<String, Int?>>()
for (cat in categories) {
var total = 0
var hasUnknown = false
for ((c, amount) in expenses) {
if (c != cat) continue
if (amount == null) hasUnknown = true else total += amount
}
result.add(cat to if (hasUnknown) null else total)
}
return result
}
Тепер застосуймо сортування так, щоб null був у кінці, а відомі суми йшли за спаданням (типовий «топ категорій», тільки без take(n) — його ви робили в попередній лекції).
fun main() {
val expenses = listOf(
"food" to 250,
"taxi" to null,
"books" to 90,
"food" to null
)
val totals = totalsByCategory(expenses)
val sorted = totals.sortedByDescending { (_, total) ->
total ?: Int.MIN_VALUE
}
println(sorted)
// [(books, 90), (food, null), (taxi, null)]
}
Зверніть увагу: food і taxi обидва null, тож їхній відносний порядок уже не такий важливий (він може залежати від початкового порядку). Головне — вони обидва чесно в кінці. А books із відомим підсумком — попереду.
І так, якщо вас бентежить Int.MIN_VALUE як «магічне число», це нормальне відчуття. У такому разі якраз варто віддати перевагу стратегії розбиття: вона довша, зате менше магії.
8. Типові помилки
Помилка №1: підставляти значення за замовчуванням «для зручності», не думаючи про зміст даних.
Найчастіший варіант — total ?: 0. Це майже завжди неправда: 0 — це «точно нуль», а null — це «невідомо». Після такої підстановки звіт стає логічно хибним: категорії з невідомими сумами раптом виглядають як «найменші», і ви самі собі підсовуєте неправильні висновки.
Помилка №2: переплутати значення за замовчуванням для sortedBy і sortedByDescending.
У сортуванні за спаданням багато хто автоматично робить ?: Int.MAX_VALUE «щоб було в кінці», але отримують null на початку. Бо «максимум» у спаданні — це якраз лідер. Добрий спосіб не помилятися — проговорювати вголос: «я хочу, щоб null був гірший за всіх; отже, у descending він має стати найменшим».
Помилка №3: випадково втратити null, коли він значущий.
Функція filterNotNull() чудова, але вона буквально означає «викинути всі null». Якщо null важливий як маркер проблеми даних, то після filterNotNull() ви вже не зможете показати користувачу «у вас є 2 записи без суми». У таких випадках або рахуйте null окремо (count { it == null }), або застосовуйте розбиття, а не повне видалення.
Помилка №4: зробити ключ сортування надто «розумним», так що його неможливо прочитати.
Іноді трапляється лямбда в sortedWith або sortedBy, яка одночасно нормалізує, валідовує, робить trim(), обробляє null, ще й щось додає «щоб гарніше було». У результаті сортування перетворюється на мініпрограму всередині програми. Якщо правило не вміщується в один зрозумілий рядок, краще винести його в маленьку функцію з промовистим імʼям — і сортування знову стане читабельним.
Помилка №5: використовувати «магічні числа» без коментаря і без привʼязки до домену.
Int.MIN_VALUE і Int.MAX_VALUE технічно працюють, але для новачка це виглядає як заклинання. Якщо ви все-таки обираєте стратегію ?:, намагайтеся або добирати доменне значення за замовчуванням (наприклад, -1, якщо суми не бувають відʼємними), або хоча б залишати короткий коментар, чому саме це значення безпечне.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ