JavaRush /Курси /Kotlin SELF /Nullable як контракт: де null виправданий, а де заважає

Nullable як контракт: де null виправданий, а де заважає

Kotlin SELF
Рівень 41 , Лекція 0
Відкрита

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 живе поруч із джерелом (введення/пошук) і швидко перетворюється на зрозуміле рішення: «перепитай», «не знайдено», «команда некоректна».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ