1. null у колекціях — це як пісок у підшипниках
Колекції та пайплайни (ланцюжки map/filter/...) зазвичай пишуть заради зрозумілості: «беремо список → чистимо → перетворюємо → отримуємо результат». Але якщо всередині колекції сидить null, то майже на кожному кроці зʼявляється ?., ?: або й узагалі магічне !!, яке перетворює код на квест «вгадай, де все впаде». Kotlin змушує нас бути чесними: nullable-тип — це сигнал, що далі потрібно ухвалити рішення.
Уявіть, що ми продовжуємо наш навчальний консольний проєкт (умовно назвімо його ExpenseTracker): він зберігає витрати й будує звіти. І ось ми вирішили обробити «сирий імпорт» рядків (наприклад, рядки прийшли із зовнішнього джерела або з історії команд).
Нехай формат рядка такий: amount;category;note, наприклад 120;food;pizza.
data class Expense(val amount: Int, val category: String, val note: String)
fun main() {
val rawLines = listOf(
"120;food;pizza",
" ;transport;metro",
"100; ;coffee",
"oops;fun;bug"
)
println(rawLines.size) // 4
}
Проблема в тому, що в реальності частина рядків «криві». Ми можемо спробувати тягнути їх далі як Expense?, але тоді майже кожна наступна операція буде змушена памʼятати про ?.
2. Два «погані запахи»: List<T?> і nullable-пайплайн
Коли в коді зʼявляються List<T?> або Map<K, V?>, це не завжди помилка. Іноді це нормальна проміжна форма. Але частіше це ознака того, що ви змішали дві речі: обробку «брудних» вхідних даних і осмислену роботу з даними. Тобто ви хотіли підрахувати суми за категоріями, а в підсумку обговорюєте філософію: «а що означає null у третьому елементі списку?».
У Kotlin особливо важливо помітити, де саме зʼявився null.
List<T?> зазвичай виникає тоді, коли ви робите map { ... }, а всередині інколи повертаєте null. Kotlin за замовчуванням підтримує таку ідею: якщо перетворення може повернути null, то далі ви або маєте з цим жити, або повинні явно його прибрати. Для цього є окремі інструменти — ми до них уже підходимо.
Мініілюстрація «як усе починається»:
fun parseAmountOrNull(text: String): Int? =
text.trim().toIntOrNull()
fun main() {
val rawAmounts = listOf("10", " ", "oops", "25")
val parsed: List<Int?> = rawAmounts.map { parseAmountOrNull(it) }
println(parsed) // [10, null, null, 25]
}
Список уже став List<Int?>. І якщо ви тепер захочете зробити sum(), то не вийде — і це правильно: «сума чого?» (чогось і null?) У такому вигляді ці дані неможливо коректно підсумувати.
3. filterNotNull(): прибираємо null і повертаємо нормальний тип
Коли ви точно знаєте, що null у колекції означає «не підходить / не вийшло / пропускаємо», найпростіша стратегія — прибрати null якнайраніше. Для цього існує filterNotNull(): після нього тип колекції змінюється з List<T?> на List<T>, і ви знову можете користуватися звичайними операціями без ?. на кожному кроці.
Це гарне місце для дисципліни: «одна функція чистить, далі працюємо з чистими даними». Тоді null не розповзається проєктом, як вода по клавіатурі після горнятка чаю (яке точно стояло «в безпечному місці»).
fun main() {
val parsed: List<Int?> = listOf(10, null, null, 25)
val clean: List<Int> = parsed.filterNotNull()
println(clean) // [10, 25]
println(clean.sum()) // 35
}
Тут важливо відчути різницю: filterNotNull() — це не просто «фільтр», це ще й зміна типу. Це одна з причин, чому він зручніший, ніж filter { it != null } у «ручному стилі»: Kotlin розуміє ваш намір і повертає колекцію без null (у типах).
4. mapNotNull(): перетворити й відкинути зайве за один крок
Іноді filterNotNull() — це другий крок, а хочеться одним махом: «спробувати розпарсити → якщо не вийшло, відкинути». Для цього існує mapNotNull { ... }. Його буквально створено під сценарій: «звичайний map, але якщо перетворення повернуло null, елемент не потрапляє в результат».
Повернімося до нашого імпорту витрат. Ми хочемо з List<String> отримати List<Expense>, пропускаючи погані рядки.
Зробімо маленькі функції-парсери. Вони повертають T?, і це нормально: ми перебуваємо на межі «сирого введення».
fun parseNonEmptyOrNull(raw: String): String? =
raw.trim().takeIf { it.isNotEmpty() }
fun parseIntOrNull(raw: String): Int? =
raw.trim().toIntOrNull()
Тепер парсер рядка повністю:
fun parseExpenseLineOrNull(line: String): Expense? {
val parts = line.split(";")
if (parts.size != 3) return null
val amount = parseIntOrNull(parts[0]) ?: return null
val category = parseNonEmptyOrNull(parts[1]) ?: return null
val note = parts[2].trim() // note нехай буде навіть порожнім, але не null
return Expense(amount = amount, category = category, note = note)
}
І головне: mapNotNull.
fun main() {
val rawLines = listOf(
"120;food;pizza",
" ;transport;metro",
"100; ;coffee",
"oops;fun;bug"
)
val expenses: List<Expense> = rawLines.mapNotNull { parseExpenseLineOrNull(it) }
println(expenses.size) // 1
println(expenses[0]) // Expense(amount=120, category=food, note=pizza)
}
Вийшло рівно те, що ми й хотіли: null був «усередині» парсингу, але назовні — у доменну колекцію — не потрапив. Пайплайн знову став читабельним: rawLines.mapNotNull(::parseExpenseLineOrNull) — майже як декларація наміру.
5. Патерн «шлюз»: чистимо вхід, далі працюємо з List<T>
У новачків часто виникає спокуса: «ну нехай буде List<Expense?>, а потім розберуся». Це працює рівно до першого звіту, де ви раптово пишете it?.amount ?: 0, потім ще раз, потім іще — і ось ви вже випадково порахували суму, де помилки введення перетворилися на нулі. А нуль — це не «помилка», це цілком собі число, яке тихо ламає статистику.
Правило, яке рятує психіку: зробіть один явний етап нормалізації/парсингу, а далі працюйте лише з чистими типами.
Схематично це можна подати так:
flowchart TD
A["Сирі рядки (List⟨String⟩)"] --> B["Нормалізація (trim, split)"]
B --> C["Парсинг (T?)"]
C --> D["mapNotNull / filterNotNull"]
D --> E["Чисті дані (List⟨T⟩)"]
E --> F["Звіти / сортування / групування"]
Тепер додамо маленьку практичну «сходинку» в наш проєкт: побудуємо звіт за сумами категорій. У нас уже є List<Expense>.
fun totalByCategory(expenses: List<Expense>): Map<String, Int> {
return expenses
.groupBy { it.category }
.mapValues { (_, items) -> items.sumOf { it.amount } }
}
fun main() {
val expenses = listOf(
Expense(120, "food", "pizza"),
Expense(80, "transport", "metro"),
Expense(30, "food", "coffee")
)
println(totalByCategory(expenses)) // {food=150, transport=80}
}
Ключове: тут немає жодного ?. І це не магія — це дисципліна «шлюза»: ми один раз перетворили сировину на Expense, і далі живемо як дорослі (іноді це боляче, зате ефективно).
6. Пошук у колекціях: T? і як не розмазати ?.
Коли ви шукаєте елемент у колекції (find, firstOrNull, getOrNull), Kotlin чесно каже: «може не знайтися» — тримайте T?. Це нормально: відсутність результату — цілком реальний сценарій.
І ось тут типова помилка: ви знайшли Expense?, а потім починаєте передавати його далі як nullable — і через три функції у вас раптово Expense? опиняється в самому центрі програми.
Замість цього зазвичай краще зробити одне з двох: або обробити відсутність поруч із пошуком, або перетворити відсутність на зрозуміле значення (через ?:, Result, sealed або ранній return — вибір залежить від контракту).
Приклад: шукаємо першу витрату за категорією.
fun findFirstByCategory(expenses: List<Expense>, category: String): Expense? =
expenses.find { it.category == category }
fun main() {
val expenses = listOf(Expense(120, "food", "pizza"))
val found = findFirstByCategory(expenses, "transport")
println(found) // null
}
А тепер «не розмазуємо null»: обробімо все поруч.
fun printFirstExpenseNote(expenses: List<Expense>, category: String) {
val expense = expenses.find { it.category == category } ?: run {
println("Нічого не знайдено для категорії='$category'") // Nothing found for category='transport'
return
}
println("Нотатка: ${expense.note}") // (не виконається, якщо не знайшли)
}
Зауважте: ми зробили «шлюз» ще раз. expense після Elvis (?:) стає не-null, і далі код простіший.
До речі, якщо ви працюєте з індексами, getOrNull() та схожі безпечні операції теж повертають nullable — і це очікувано: «елемент може бути відсутній».
7. Map<K, V?> у звітах: як отримати Map<K, V> без !!
Словники (Map) — це місце, де nullable зʼявляється «природним чином»: map[key] повертає V?, бо ключа може не бути. І тут легко випадково отримати Map<String, Int?> в обчисленнях, якщо ви почали кодувати «немає даних» як null.
Розгляньмо приклад: хочемо порахувати середню витрату за категорією, але якщо в категорії порожньо — вийде null (середнє не визначено). Це якраз той випадок, коли null може бути змістовним проміжним значенням.
fun avgByCategoryOrNull(expenses: List<Expense>): Map<String, Int?> {
return expenses
.groupBy { it.category }
.mapValues { (_, items) ->
if (items.isEmpty()) null else items.sumOf { it.amount } / items.size
}
}
Так, items.isEmpty() після groupBy зазвичай не трапиться (група не буде порожньою), але припустімо, що логіка складніша. Для нас важливіша сама ідея.
Тепер питання: як отримати звіт, у якому показуємо лише категорії з визначеним середнім, і хочемо Map<String, Int>?
Варіант «поганий, але популярний»:
// Не робіть так без залізної впевненості:
val clean = avgMap.filterValues { it != null }.mapValues { it.value!! }
Тут !! якраз і небезпечний: ви вручну обіцяєте компілятору, що null не буде. Краще зробити «чесно» через mapNotNull по entries:
fun onlyDefinedAvgs(avgMap: Map<String, Int?>): Map<String, Int> {
return avgMap
.entries
.mapNotNull { (category, avgOrNull) ->
avgOrNull?.let { avg -> category to avg }
}
.toMap()
}
Ідея проста: ми перетворюємо Map на список пар, де пари створюються лише тоді, коли значення не null, а потім збираємо все назад у Map. Так, це трохи більше букв — зате нуль ризику «впасти раптово» й нуль прихованих обіцянок.
Окремо приємно, що mapNotNull тут знову виражає намір: «перетворюю, але якщо null — відкидаю».
8. Корисні нюанси для пайплайнів
takeIf / takeUnless: контролюємо null в одному місці
Іноді null потрібен як проміжний сигнал: наприклад, «рядок після нормалізації порожній — отже, значення немає». Робити для цього окремий if можна, але в пайплайнах часто хочеться компактніше. Тут і допомагають takeIf і takeUnless: вони повертають значення, якщо умова істинна (або хибна), інакше повертають null.
Це звучить як «ми самі створюємо null», і так воно і є. Але в цьому й сенс: ми створюємо його в одному зрозумілому місці, де він означає рівно одне.
Приклад: нормалізуємо нотатку (note). Якщо після trim() порожньо, вважаємо, що нотатки немає, і хочемо відкинути її зі списку нотаток.
fun normalizeNoteOrNull(raw: String): String? =
raw.trim().takeIf { it.isNotEmpty() }
fun main() {
val notes = listOf(" pizza ", " ", "coffee", "")
val cleanNotes = notes.mapNotNull { normalizeNoteOrNull(it) }
println(cleanNotes) // [pizza, coffee]
}
Тут takeIf — це маленький, акуратний «генератор null за правилами», а mapNotNull — «пилосос», який одразу прибрав цей null з результату. У підсумку назовні вийшов List<String> — без nullable.
Мінізвіт: top categories без nullable всередині пайплайна
Тепер зробімо шматочок, який виглядає як реальний код у нашому ExpenseTracker: будуємо топ категорій за сумою, але хочемо ігнорувати рядки імпорту, які не розпарсилися. Важливо: ми не хочемо List<Expense?> і не хочемо Map<String, Int?> у фіналі.
Зберімо все в одному місці: «сировина → mapNotNull(parse) → звіт».
fun topCategories(rawLines: List<String>, topN: Int): List<Pair<String, Int>> {
val expenses = rawLines.mapNotNull { parseExpenseLineOrNull(it) }
return expenses
.groupBy { it.category }
.mapValues { (_, items) -> items.sumOf { it.amount } }
.entries
.sortedByDescending { it.value }
.take(topN)
.map { it.key to it.value }
}
fun main() {
val rawLines = listOf(
"120;food;pizza",
"80;transport;metro",
"oops;food;bug",
"30;food;coffee"
)
val top = topCategories(rawLines, topN = 2)
println(top) // [(food, 150), (transport, 80)]
}
Зверніть увагу на приємну річ: null був лише всередині parseExpenseLineOrNull, а в «бізнес-частині» звіту його немає взагалі. Це і є практична мета лекції: null має жити там, де він пояснюваний, і «вмирати» там, де він заважає.
9. Типові помилки під час вилучення null із колекцій і пайплайнів
Помилка № 1: залишати List<T?> «на потім», а потім писати it?.x ?: 0 у кожному звіті.
Такий код виглядає як турбота про безпеку, але на ділі ви починаєте тихо «виправляти» дані, перетворюючи помилки на нулі, порожні рядки або інші значення за замовчуванням. Це ламає сенс звітів: ви вже не відрізняєте «немає даних» від «дані дорівнюють нулю». Краще зробити один шлюз очищення (mapNotNull/filterNotNull) і далі працювати з List<T>.
Помилка № 2: використовувати !! після фільтра «ну я ж перевірив».
Сценарій зазвичай такий: ви зробили фільтр, потім десь трохи змінили умову, додали ще одну гілку — і !! раптово став точкою падіння. Навіть якщо зараз «усе очевидно», за тиждень це буде неочевидно вам же. Якщо вже ви фільтруєте null, робіть це так, щоб тип став не-null, або використовуйте ?.let { ... }, а не «обіцянку» через !!.
Помилка № 3: будувати пайплайн, який на кожному кроці створює нові nullable-значення.
Наприклад, map { ...? } → map { it?.something } → filter { it != null } → map { it!! }. Це перетворюється на «драбину з милиць». Зазвичай або робіть mapNotNull одразу, або розділіть на два читабельні кроки: val clean = ...filterNotNull() і потім val transformed = clean.map { ... }.
Помилка № 4: змішувати «не знайдено» і «помилка» в одному null.
У пайплайнах це особливо підступно: один null означає «рядок порожній», інший — «не розпарсилося число», третій — «не знайшлася категорія», і все це зрештою просто null. Потім ви робите filterNotNull() і втрачаєте інформацію, чому елемент зник. Для звітів це нормально, але для діагностики й UX — часто ні. Якщо причина важлива, краще повертати Result/sealed і вже потім вирішувати, що відкидати.
Помилка № 5: чистити null надто пізно — вже після групувань і розрахунків.
Якщо null потрапив усередину groupBy, mapValues, складних сортувань і top-N, то ви змушені робити перевірки всередині пайплайна, де вони гірше читаються. Найчастіше правильніше чистити раніше: «якомога раніше отримати List<T>», а вже потім групувати й агрегувати.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ