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") // наприклад: "Вік: не вказано"
}
Тут ми зробили ще один «місток»: age — Int?, а 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. Тоді ?. і ?: починають працювати рівно так, як ви очікуєте, і програма стає передбачуваною.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ