JavaRush /Курси /Kotlin SELF /Інструменти роботи з null: ?., ?:, !!

Інструменти роботи з null: ?., ?:, !!

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

1. Навіщо потрібні ?. і ?: та чому одного if замало

Коли ви щойно дізналися про null, перша реакція зазвичай така: «Гаразд, буду всюди писати if (x != null)». Це нормальний старт, але доволі швидко ви відчуєте біль: вкладені if розростаються, логіка «розповзається» кодом, а читання програми стає схожим на проходження лабіринту (і наприкінці на вас усе одно може чекати мінотавр).

Kotlin пропонує виразніші конструкції — короткі оператори, які роблять оброблення null частиною виразу. Це важливо, адже багато завдань — це не «зроби дію або не роби», а «отримай значення, якщо воно є, інакше використай запасний варіант». Саме для цього існують ?. і ?:.

Щоб тримати картинку в голові, ось невеличка таблиця:

Оператор Як читається Ідея Що повертає
?.
«якщо не null — зроби, інакше поверни null» безпечний доступ найчастіше T?
?:
«якщо зліва null — візьми справа» значення за замовчуванням / запасна поведінка часто знімає nullable
!!
«я клянуся, що тут не null» примусовий доступ T, але з ризиком падіння

Далі спокійно й на прикладах розберемо кожен інструмент.

2. Safe call ?.: безпечний доступ без падінь

Оператор ?. зазвичай люблять за те, що він робить код коротшим і безпечнішим. Але головне — він робить поведінку явною: ви прямо в рядку коду показуєте, що значення може бути відсутнім, і ви готові до такого сценарію. Це як пристебнутися паском безпеки: ви не плануєте аварію, але жити стає спокійніше.

?. на властивостях: .length без ризику

Найпростіший приклад — довжина рядка. Якщо рядок nullable, то напряму звертатися до властивості не можна:

fun main() {
    val nickname: String? = null

    println(nickname.length) // так не можна: nickname може бути null

    val len: Int? = nickname?.length
    println(len) // null
}

Тут важливо помітити одну річ: результат nickname?.length має тип Int?, тому що операція могла «не виконатися» (якщо зліва null, то довжину обчислювати немає сенсу).

І це цілком логічно: safe call не «вигадує» довжину з повітря. Він чесно каже: «Або я поверну довжину, або null».

?. на методах: безпечний виклик uppercase()

Той самий принцип — із методами:

fun main() {
    val nickname: String? = "neo"

    val upper: String? = nickname?.uppercase()
    println(upper) // NEO
}

Якщо nickname буде null, то upper теж стане null, і програма не впаде.

Ланцюжки safe call: «доміно з ?.»

Зазвичай ?. використовують не один раз, а ланцюжком: «візьми рядок → обріж пробіли → перетвори на верхній регістр → візьми довжину». І на кожному кроці значення може стати null (якщо на вході було null).

fun main() {
    val raw: String? = "   Kotlin   "

    val len: Int? = raw
        ?.trim()
        ?.uppercase()
        ?.length

    println(len) // 6
}

Читається приємно: доки значення є — ідемо далі; щойно значення зникає — увесь ланцюжок перетворюється на null.

Мінісхема: що робить ?.

Щоб закріпити, уявімо роботу ?. як маленьку блок-схему:

flowchart TD
    A["Є значення x: T?"] --> B{ x == null? }
    B -->|так| C["Результат = null"]
    B -->|ні| D["Виконуємо x.member / x.func()"]
    D --> E["Результат = значення"]

Так, оператор виглядає як два символи, але за ним стоїть дуже конкретна логіка.

3. Оператор ?:

Оператор ?: (Elvis, бо нагадує зачіску Елвіса Преслі) дозволяє замінити null на нормальне значення (або на нормальну дію).

Elvis як «значення за замовчуванням»

Найпоширеніша форма:

fun main() {
    val nickname: String? = null

    val len: Int = nickname?.length ?: 0
    println(len) // 0
}

Зверніть увагу на тип: зліва nickname?.length дає Int?, але Elvis підставляє 0, тому підсумок — звичайний Int.

Це один із ключових підходів: ?. часто «породжує nullable», а ?: часто «знімає nullable».

Elvis і рядкові повідомлення: нормальна взаємодія в консолі

У наших консольних програмах важливо не лише «не впасти», а й говорити з користувачем по-людськи.

fun main() {
    val nickname: String? = null

    val label: String = nickname?.uppercase() ?: "ANONYMOUS"
    println("Hello, $label!") // Hello, ANONYMOUS!
}

Якщо значення є — гарно форматуємо. Якщо немає — показуємо запасний текст.

Elvis як ранній вихід

Іноді null — це не «давайте підставимо 0», а «отже, далі працювати не можна». Тоді замість значення за замовчуванням ми робимо ранній вихід із функції.

Kotlin дозволяє використовувати return разом з Elvis:

fun parsePositiveInt(text: String): Int? {
    val x: Int = text.toIntOrNull() ?: return null
    if (x <= 0) return null
    return x
}

fun main() {
    println(parsePositiveInt("42"))   // 42
    println(parsePositiveInt("oops")) // null
    println(parsePositiveInt("-5"))   // null
}

Ідея така: ми «локалізуємо проблему» одразу. Якщо не розпарсилося — повертаємо null і не будуємо вкладені if.

4. Повірте мені на слово

Оператор !! виглядає як простий спосіб «заткнути компілятор»: він перетворює T? на T. Але робить це дуже прямолінійно: якщо значення виявилося null, програма падає під час виконання (зазвичай це KotlinNullPointerException).

Важлива думка: !! не робить ваш код безпечнішим. Він лише змушує код компілюватися, а відповідальність за безпеку перекладає на вашу впевненість.

Приклад: компілюється, але падає

fun main() {
    val nickname: String? = null

    val len: Int = nickname!!.length
    println(len) // сюди програма не дійде
}

Якщо запустити цей код, ви отримаєте падіння програми. Тобто !! — це не «оброблення null», це «я гарантую, що тут не null».

Коли !! узагалі буває доречним

Без фанатизму: !! можна сприймати як аварійну кнопку «розбити в разі пожежі». Іноді він доречний, коли ви абсолютно впевнені, що значення не може бути null. І якщо воно раптом усе-таки null — це справді баг, який краще виявити одразу.

Але на практиці, коли новачок ставить !!, це часто означає: «Я не зрозумів(ла), як правильно обробити null». Тому базове правило просте: спочатку шукаємо розвʼязання через ?., ?: або перевірку if, і лише потім (у рідкісних випадках) розглядаємо !!.

5. Анкета користувача: опційні поля й оброблення null

Давайте продовжимо розвивати той самий консольний застосунок: просту «анкету користувача». Раніше ми читали рядки, робили trim(), парсили числа через toIntOrNull(). Тепер додамо опційні поля (які можна пропустити) й застосуємо ?., ?: та акуратні підходи.

Домовимося про сенс: порожній рядок vs null

Дуже поширена плутанина: користувач натиснув Enter і ввів порожній рядок "". Це не null. Але для нашої логіки «нічого не ввів» часто означає «значення немає». Тому ми можемо самі перетворити порожній рядок на null.

Зробимо маленьку функцію-помічник:

fun emptyToNull(text: String): String? {
    val trimmed = text.trim()
    return if (trimmed.isEmpty()) null else trimmed
}

Читаємо нікнейм як String? і форматуємо виведення

fun emptyToNull(text: String): String? {
    val trimmed = text.trim()
    return if (trimmed.isEmpty()) null else trimmed
}

fun main() {
    println("Введіть нікнейм (можна порожньо):")
    val nicknameInput = readln()
    val nickname: String? = emptyToNull(nicknameInput)

    val label = nickname?.uppercase() ?: "ANONYMOUS"
    println("Ваш ярлик: $label") // якщо порожньо: "Ваш ярлик: ANONYMOUS"
}

Тут одразу два інструменти: ?.uppercase() і ?: "ANONYMOUS".

Вік: toIntOrNull() дає Int?, і це нормально

Тепер запитаємо вік, але зробимо його необовʼязковим (наприклад, користувач не хоче ділитися).

fun emptyToNull(text: String): String? {
    val trimmed = text.trim()
    return if (trimmed.isEmpty()) null else trimmed
}

fun main() {
    println("Введіть вік (можна порожньо):")
    val ageText: String? = emptyToNull(readln())

    val age: Int? = ageText?.toIntOrNull()
    println("age = $age") // наприклад: age = 25 або age = null
}

Дуже важливий момент: ми не можемо зробити ageText.toIntOrNull(), тому що ageText nullable. Отже, або if, або ?.. Ми обрали ?..

Гарний вивід: формуємо фразу, а не «null»

Тепер зробимо нормальне повідомлення для користувача:

fun emptyToNull(text: String): String? {
    val trimmed = text.trim()
    return if (trimmed.isEmpty()) null else trimmed
}

fun main() {
    println("Введіть вік (можна порожньо):")
    val ageText: String? = emptyToNull(readln())

    val age: Int? = ageText?.toIntOrNull()

    val ageLabel: String = age?.toString() ?: "не вказано"
    println("Вік: $ageLabel") // наприклад: "Вік: не вказано"
}

Тут ми зробили ще один «місток»: ageInt?, а Elvis перетворює це на текст, який можна вивести без незручності.

Підхід «обробіть null одразу»: уникаємо вкладеності

Уявімо, що ми хочемо порахувати «рік народження приблизно» (умовно 2026 - age). Важливо: число 2026 тут — просто вибраний рік для прикладу. Якщо віку немає, рахувати беззмістовно.

Зробимо функцію, яка повертає рядок-результат:

fun estimateBirthYear(ageText: String?): String {
    val age: Int = ageText?.toIntOrNull() ?: return "Рік народження: невідомий"
    val year = 2026 - age
    return "Рік народження приблизно: $year"
}

fun main() {
    println(estimateBirthYear("20"))   // Рік народження приблизно: 2006
    println(estimateBirthYear(null))   // Рік народження: невідомий
    println(estimateBirthYear("abc"))  // Рік народження: невідомий
}

Тут Elvis використовується не зі значенням за замовчуванням, а з поведінкою за замовчуванням: раннім поверненням.

Збирання анкети повністю

Зберемо спрощену версію анкети: імʼя обовʼязкове, нікнейм і вік — ні.

fun emptyToNull(text: String): String? {
    val trimmed = text.trim()
    return if (trimmed.isEmpty()) null else trimmed
}

fun main() {
    println("Імʼя (обовʼязково):")
    val name: String = readln().trim()

    println("Нікнейм (можна порожньо):")
    val nickname: String? = emptyToNull(readln())

    println("Вік (можна порожньо):")
    val ageText: String? = emptyToNull(readln())
    val age: Int? = ageText?.toIntOrNull()

    val shownNickname = nickname?.uppercase() ?: "ANONYMOUS"
    val shownAge = age?.toString() ?: "не вказано"

    println("Профіль: $name / $shownNickname / вік: $shownAge")
    // Приклад: "Профіль: Alice / ANONYMOUS / вік: не вказано"
}

Тут у нас акуратна «труба оброблення»:

readln()trim() → (можливо) emptyToNull() → (можливо) ?.toIntOrNull() → форматування через ?:.

Це і є нормальний стиль роботи з nullable: без перегинів, але й без ігнорування.

6. Типові помилки під час роботи з ?., ?: і !!

Помилка № 1: очікувати, що ?. підставляє значення за замовчуванням.
Новачки іноді читають safe call так: «якщо null, то якось саме виправиться». Ні: ?. лише гарантує, що програма не впаде на зверненні до властивості/методу. Якщо зліва null, результат виразу теж буде null. Якщо вам потрібне значення за замовчуванням, це вже задача Elvis ?:, і воно має бути змістовним, а не «аби компілювалося».

Помилка № 2: ланцюжок ?. стає задовгим і перестає читатися.
Технічно можна написати пʼять-шість safe call підряд, і воно навіть працюватиме. Але мозок людини — не компілятор: йому складно тримати в голові довгі ланцюжки, особливо якщо там ще й ?: наприкінці. У таких випадках краще зробити проміжні val і дати їм зрозумілі імена, щоб код читався як послідовність кроків, а не як закляття.

Помилка № 3: використовувати ?: 0 або ?: "" без сенсу й тим самим маскувати проблему.
Elvis дуже спокусливий: поставили ?: 0 — і все «працює». Але інколи це приховує помилку введення або ламає логіку. Наприклад, вік 0 може бути коректним числом у математиці, але для анкети це радше дивина. Краще обирати такі варіанти «за замовчуванням», які мають сенс для вашої предметної області: «не вказано», «невідомо», «—», або ранній return, якщо без значення подальша логіка беззмістовна.

Помилка № 4: «лікувати» всі помилки компілятора через !!.
!! справді швидко «прибирає» повідомлення компілятора, бо перетворює T? на T. Але ціна цього — ризик падіння під час виконання, причому часто в місці, де ви цього не очікуєте (особливо якщо значення прийшло з введення). На вашому етапі значно правильніше сприймати !! як рідкісний інструмент для ситуацій, де падіння при null — усвідомлений контракт, а не як універсальний молоток для всіх цвяхів.

Помилка № 5: плутати null і порожній рядок та через це дивно комбінувати ?. і ?:.
Якщо ви не домовилися, що означає «користувач нічого не ввів», ви отримуватимете сюрпризи. Порожній рядок "" — це значення, і ?. до нього взагалі не стосується (він не null). Тому часто корисно явно нормалізувати введення, перетворюючи «порожньо після trim()» на null. Тоді ?. і ?: починають працювати рівно так, як ви очікуєте, і програма стає передбачуваною.

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