1. Занурюємося в null
Коли ви бачите String?, мозок новачка часто читає це так: «там може бути null, отже потрібно поставити ?. і їхати далі». Але для дизайну програми корисніше інше прочитання: String? — це обіцянка автора функції або поля, що відсутність значення є нормальною й очікуваною розвʼязкою. І отут починається доросле життя: якщо null — нормальна розвʼязка, його сенс має бути таким само зрозумілим, як сенс true/false або 0/1.
Закріпімо думку на простому прикладі. У Kotlin в навчальних прикладах часто показують парсер: якщо рядок не є числом, повертаємо null. Це дуже чесний контракт: «не зміг розпарсити — ось сигнал, без винятків і містики».
fun parsePort(raw: String): Int? {
val trimmed = raw.trim()
return trimmed.toIntOrNull()
}
fun main() {
println(parsePort("8080")) // 8080
println(parsePort("oops")) // null
}
Тут null означає цілком конкретну річ: «рядок не містить коректного цілого числа». Це не помилка системи, не баг і не «щось пішло не так» — це звичайна ситуація на межі введення.
А тепер уявіть, що ви зробили так:
fun loadConfigText(): String? {
// ... щось читаємо
return null
}
І все. А що означає null? Конфіг не знайдено? Немає прав? Файл порожній? Ви забули дописати функцію? Це вже поганий контракт: він змушує того, хто викликає, вгадувати.
Ключовий критерій: якщо ви ставите ?, ви зобовʼязані відповісти на запитання «чому значення може не бути?» так, щоб це не виглядало як «бо мені лінь».
2. Де null зазвичай виправданий: межі програми
Зазвичай null — чудовий інструмент на межах системи. Там, де ви зустрічаєтеся з непередбачуваним світом: користувацьким введенням, аргументами командного рядка, даними з мережі, читанням із файлу, пошуком за ключем (коли елемент справді може бути відсутнім). На межі світ має право принести вам порожнечу, дивний рядок і «а я взагалі нічого не ввів». І саме там null доречний.
У Kotlin навіть базові операції з колекціями натякають на цей підхід. Наприклад, читання за ключем із Map повертає V?, тому що ключа може не існувати, і це нормальний сценарій. У такому місці null читається як «не знайдено», а не як «зламалося».
Візьмімо приклад ближче до нашого практичного консольного застосунку (умовно назвімо його ExpenseTracker: невеликий облік витрат у CLI). Припустімо, у нас є список витрат, і ми шукаємо витрату за ID. Цілком нормально повернути Expense?: витрату могли видалити, ID міг бути неправильним або користувач міг помилитися.
data class Expense(
val id: Int,
val title: String,
val amount: Int
)
fun findExpenseById(expenses: List<Expense>, id: Int): Expense? {
return expenses.find { it.id == id }
}
fun main() {
val expenses = listOf(
Expense(id = 1, title = "Coffee", amount = 300),
Expense(id = 2, title = "Book", amount = 1200),
)
val e = findExpenseById(expenses, 2)
println(e?.title) // Book
}
Зверніть увагу: контракт читається ідеально. Expense? тут означає «може не знайтися». І це не «помилка» — це частина предметної області.
3. Де null починає заважати: всередині моделі та бізнес-логіки
Усередині програми (після того, як дані вже розпізнано й перевірено) null часто перетворюється на «наліт хаосу». Причина проста: null починає протікати крізь шари коду. Одне nullable-поле тягне за собою друге, потім третє. Далі зʼявляються ?. у форматуванні, ?: у підрахунках, if (x != null) у трьох різних місцях, а наприкінці — філософське запитання: «а що взагалі вважається коректним обʼєктом?».
Уявімо погану версію нашої моделі витрати:
data class ExpenseDraft(
val title: String?,
val amount: Int?
)
fun main() {
val draft = ExpenseDraft(title = null, amount = null)
println("title=${draft.title}, amount=${draft.amount}") // title=null, amount=null
}
Ззовні це здається зручним: «можна створити хоч щось». Але далі ви захочете:
- вивести витрату користувачеві,
- порахувати суму,
- відсортувати,
- згрупувати.
І всюди ви думатимете: «а тут може бути null?». Це як носити парасолю вдома, на кухні, про всяк випадок: формально можна, але готувати стає незручно.
Важлива думка сьогоднішньої лекції: nullable-поля всередині «нормальної» моделі часто означають, що ви змішали різні стадії даних. Є «сире введення», а є «валідна сутність». Якщо ви змішали їх в одному типі, null швидко «розповзеться».
4. Два значення null: «немає даних» і «помилка»
Ця частина особливо важлива, бо саме тут зʼявляються найнеприємніші баги — не ті, що падають одразу, а ті, що тихо псують логіку.
null зазвичай використовують у двох різних ситуаціях:
| Ситуація | Що відбувається за змістом | Чи нормально повертати null? | Приклад |
|---|---|---|---|
| Відсутність значення | Значення може не існувати, і це очікувано | Так | «не знайдено за ID», «порожнє введення», «не розпарсилося число» |
| Помилка/неможливість | Ми хотіли значення, але не отримали його через проблему | Небезпечно | «не вдалося прочитати файл», «помилка мережі», «зламалася логіка» |
Проблема в тому, що обидві ситуації технічно виглядають однаково: «повернули null». Але за змістом вони різні — як «у магазині немає хліба» і «магазин згорів».
Зробімо навмисно поганий приклад і подивімося, чому він поганий:
fun loadUserName(userId: Int): String? {
// Уявімо, що тут могла бути помилка бази даних,
// але ми просто повернули null
return null
}
Код, який викликає, тепер не відрізнить:
- користувача з таким id справді немає (це нормально),
- користувач є, але база «впала» (це вже не нормально),
- розробник забув реалізувати функцію (це теж не нормально).
І далі, як правило, хтось пише:
val name = loadUserName(10) ?: "UNKNOWN"
println("Привіт, $name") // Привіт, UNKNOWN
І от воно — «тихий баг»: програма продовжила працювати, але сенс даних зіпсовано. Це небезпечніше, ніж падіння, бо падіння хоча б чесно кричить: «мені погано». А "UNKNOWN" може полетіти у звіт, в експорт, у підсумкові суми — і далі ланцюжком.
Сьогоднішня мета — навчитися помічати такі місця і не використовувати null як універсальну «затичку для всього».
5. Практика: як тримати null під контролем
Критерій: «Чи можу я пояснити null однією фразою?»
Щоб не перетворювати дизайн на філософію, корисно мати дуже прикладний тест.
Якщо функція або поле має тип T?, спробуйте дописати до нього однорядковий коментар — без збентеження. Не абзац і не «ну взагалі там…», а одну фразу.
Наприклад, хороші варіанти:
- findExpenseById(...) : Expense? — «повертає витрату або null, якщо не знайдено».
- parseAmount(...) : Int? — «повертає суму або null, якщо рядок не є цілим числом».
- normalizeTitle(...) : String? — «повертає рядок без пробілів або null, якщо після trim рядок порожній».
А ось погані варіанти, які мають насторожити:
- «повертає null, якщо щось пішло не так»
- «повертає null у разі помилки»
- «може повернути null, я не впевнений»
Якщо ви ловите себе на таких формулюваннях, то майже напевно намагаєтеся сховати в null те, що за змістом є помилкою або окремим станом.
ExpenseTracker: робимо контракти читабельними
Зараз ми не будемо додавати «просунуті» конструкції та нові типи результатів (це окрема тема наступних лекцій). Натомість потренуємося правильно вибирати T або T? і робити контракт зрозумілим.
Нормалізація користувацького введення: String? доречний
Користувач може ввести порожній рядок, рядок із пробілів або взагалі «натиснути Enter і нічого не сказати». Це класична межа. Тут null чесний.
fun normalizeNonEmptyText(raw: String): String? {
val s = raw.trim()
return if (s.isEmpty()) null else s
}
fun main() {
println(normalizeNonEmptyText(" hello ")) // hello
println(normalizeNonEmptyText(" ")) // null
}
null тут означає «після нормалізації тексту його немає». Це відсутність даних, а не помилка.
Парсинг числа: Int? доречний
Kotlin прямо підштовхує до цього стилю: toIntOrNull() повертає nullable, бо розпарсити вдається не завжди. Це приклад «очікуваного провалу», і він хороший.
fun parseAmount(raw: String): Int? {
val s = raw.trim()
return s.toIntOrNull()
}
fun main() {
println(parseAmount("1200")) // 1200
println(parseAmount("12o0")) // null
}
Пошук у колекції: Expense? доречний
Це «не знайдено». Нормальна історія.
fun removeExpenseById(expenses: MutableList<Expense>, id: Int): Boolean {
val found = expenses.find { it.id == id } ?: return false
expenses.remove(found)
return true
}
fun main() {
val expenses = mutableListOf(
Expense(1, "Coffee", 300),
Expense(2, "Book", 1200),
)
println(removeExpenseById(expenses, 2)) // true
println(removeExpenseById(expenses, 99)) // false
}
Тут я спеціально зробив так, щоб назовні не витікав Expense?. Функція «видалити» всередині використовує nullable-пошук, але назовні віддає простий і зрозумілий результат: Boolean («видалили / не видалили»). Це теж частина культури: nullable тримаємо ближче до місця, де він зʼявився, а назовні повертаємо зручніший контракт, якщо можемо.
Антипатерн: nullable всюди «щоб компілятор менше сварився»
Дуже спокусливо: компілятор свариться, що ви звертаєтеся до nullable — і ви такі: «гаразд, зроблю все nullable, і хай світ горить». На короткій дистанції здається, що стало легше. На довгій — це перетворює код на квест «знайди місце, де забули ?.».
Щоб відчути різницю, порівняйте два підходи до форматування:
fun formatExpenseBad(title: String?, amount: Int?): String {
val safeTitle = title ?: "UNKNOWN"
val safeAmount = amount ?: 0
return "$safeTitle: $safeAmount"
}
fun main() {
println(formatExpenseBad(null, null)) // UNKNOWN: 0
}
З одного боку, програма не падає. З іншого — дані вже підмінено. 0 — це не «немає суми», це «сума нуль». Тож звіт тепер може стати хибним.
Якщо у вашому застосунку 0 — це допустима витрата (наприклад, промокод, знижка, повернення), ви більше не відрізните «порожньо» від «нуль». І це якраз приклад того, чому null — це контракт сенсу, а не «просто відсутність значення».
Сентинел-значення замість null: чим вони небезпечні
Іноді розробники кажуть: «Я не люблю null, давайте замість нього використовувати -1». Таке значення називають сентинелом: спеціальне число або рядок, що означає «нічого немає».
Це може працювати, але тут є ризик: ви додаєте в код приховану домовленість. Якщо ви забули перевірити -1 в одному місці — логіка ламається, але компілятор вам не допоможе, бо тип усе ще Int.
Ось невеликий приклад, у якому помилка виглядає «пристойно», але сенс уже неправильний:
fun parseAmountOrMinusOne(raw: String): Int {
return raw.trim().toIntOrNull() ?: -1
}
fun main() {
val amount = parseAmountOrMinusOne("oops")
println(amount + 100) // 99 (і це виглядає так, ніби все нормально)
}
Ось чому Kotlin просуває nullable: Int? змушує вас обробити випадок «немає значення» явно, а -1 дозволяє випадково «проскочити» без перевірки.
Це не означає, що сентинели завжди зло. Це означає, що якщо ви їх використовуєте, ви зобовʼязані зробити це частиною контракту, а не «тихою хитрістю».
Один «шлюз» обробки замість розмазування null
У хорошому коді часто видно стиль: null живе в одному місці, поруч із введенням або пошуком, а далі або перетворюється на конкретну дію, або відсікається. У поганому коді null просочується на всі рівні: доменна модель, звіти, форматування, сортування.
Схематично це виглядає так:
flowchart LR
A["Зовнішній світ (введення, пошук, дані)"] -->|може бути null| B["Шлюз: нормалізація/перевірка"]
B -->|не-null| C["Внутрішня логіка (модель, розрахунки)"]
B -->|немає значення| D["Зрозуміла реакція (повідомлення/гілка/вихід)"]
Ідея в тому, щоб у вашому коді було менше місць, де ви щоразу винаходите «а що робити з null?». Нехай це вирішується в одному зрозумілому місці.
Наприклад, під час розбору команди:
data class AddExpenseCommand(val title: String, val amount: Int)
fun parseAddCommand(line: String): AddExpenseCommand? {
val parts = line.trim().split(" ")
if (parts.size < 3) return null
val title = parts[1].trim().ifEmpty { return null }
val amount = parts[2].trim().toIntOrNull() ?: return null
return AddExpenseCommand(title = title, amount = amount)
}
fun main() {
println(parseAddCommand("add coffee 300")) // AddExpenseCommand(title=coffee, amount=300)
println(parseAddCommand("add coffee oops")) // null
}
Тут null означає конкретну річ: «команда некоректна». І важливо, що null не просочився далі. Ми не зробили AddExpenseCommand(title: String?, amount: Int?). Ми або повернули нормальну команду, або null як сигнал «не розпізнали».
Nullable-ресивер в extension-функціях: локалізуємо перевірки
Іноді хочеться зробити невелику утиліту, яка вміє працювати, навіть якщо значення null. Kotlin дозволяє писати extension-функції на nullable-типі (Any?, String? тощо). Це корисно, коли ви хочете сховати повторювану перевірку в одному місці й не писати її в кожному виклику. В офіційній документації Kotlin це теж показано: extension на Any? може повернути рядок "null" замість падіння, а всередині тіла після перевірки this == null компілятор уже розуміє, що this не null.
Для нашого застосунку можна зробити щось зовсім просте — безпечне відображення тексту:
fun String?.toUiText(): String {
return this?.trim()?.takeIf { it.isNotEmpty() } ?: "<порожньо>"
}
fun main() {
val a: String? = " hello "
val b: String? = " "
val c: String? = null
println(a.toUiText()) // hello
println(b.toUiText()) // <порожньо>
println(c.toUiText()) // <порожньо>
}
Зверніть увагу на тонку межу: це доречно в UI/виведенні, але не завжди доречно в розрахунках і зберіганні даних. Усередині моделі "<порожньо>" був би дивним «сентинелом», а от для друку в консоль — нормальний, усвідомлений вибір.
6. Типові помилки під час роботи з null
Помилка №1: робити поля моделі nullable «про всяк випадок».
Найчастіше це стається через бажання «швидше створити обʼєкт». У підсумку обʼєкт стає легким у створенні, але важким у використанні: у кожному місці доводиться памʼятати про ?. і вигадувати, що робити з відсутністю. Зазвичай це ознака того, що в один тип ви змішали «сире введення» та «валідну сутність».
Помилка №2: повертати null і для «не знайдено», і для «помилки».
Такий контракт змушує код, який викликає, вгадувати причину. А отже, він або почне «мовчки лагодити» все через ?:, або почне падати через !!, або просто друкуватиме "UNKNOWN" і псуватиме дані. Якщо ви ловите себе на фразі «повернемо null, якщо щось пішло не так», це майже завжди сигнал, що контракт туманний.
Помилка №3: лікувати дизайн null-контрактів оператором !!.
!! іноді потрібен, але в реальному проєкті він часто перетворюється на «я втомився думати». Проблема в тому, що ви не розвʼязали питання сенсу: ви просто перенесли падіння в рантайм. У контексті «nullable як контракт» це виглядає так: «контракт каже, що значення може не бути, але я роблю вигляд, що воно є».
Помилка №4: замінювати null на сентинели (0, -1, "UNKNOWN") без жорсткої домовленості.
Якщо ви підставляєте значення «за замовчуванням», ви змінюєте сенс даних. Іноді це доречно (особливо для UI), але якщо це робиться без єдиної політики й без розуміння предметної області, ви отримуєте тихі логічні помилки: суми рахуються «майже правильно», звіти виглядають «майже нормально», а користувач потім питає, чому в нього витрата "UNKNOWN: 0".
Помилка №5: розмазувати null по пайплайну замість одного шлюза обробки.
Коли null проходить через 5–10 функцій, ви втрачаєте контроль: де саме він зʼявився і що він означає. Набагато стійкіше, коли null живе поруч із джерелом (введення/пошук) і швидко перетворюється на зрозуміле рішення: «перепитай», «не знайдено», «команда некоректна».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ