1. Навіщо потрібен null
Програмісти люблять визначеність: якщо у нас є змінна name, то там завжди має бути імʼя. Але реальне життя (і реальні користувачі) легко ламає цю романтичну картину. Хтось не ввів нікнейм, хтось пропустив вік, а хтось натиснув Enter «просто щоб подивитися, що буде». І ось постає запитання: як зберігати «невідомо»?
Варіант «ну нехай буде порожній рядок ""» іноді спрацьовує. Але часто він спотворює сенс. Порожній рядок — це теж значення, просто «рядок довжини 0». А інколи нам потрібно зберігати саме відсутність значення: «поля не було», «людина не вказала», «ми не змогли порахувати». Для цього в Kotlin є спеціальне значення — null.
Уявіть, що ви заповнюєте анкету. Поле «По батькові» можна не заповнювати. Якщо ви його не заповнили, це не означає, що ваше по батькові — порожній рядок. Це означає, що інформації немає. Саме для таких ситуацій і потрібен null.
null — це не "", не 0 і не false
Важливо звикнути до думки: null — це не «порожнє значення», а факт відсутності значення. Порожній рядок "" — це рядок. Нуль 0 — це число. false — це логічне значення. А null — це «нічого», «не задано», «не існує».
Чому ця різниця справді важлива? Бо інакше ви починаєте плутати сенси й писати дивні умови. Наприклад, якщо вік не введено, можна обрати стратегію «вважаємо, що вік 0». Але тоді програма поводитиметься так, ніби користувач — новонароджений. Смішно, але лише перші два рази.
Порівняймо кілька «схожих, але не однакових» варіантів:
| Значення | Типовий сенс |
|---|---|
|
текст є, але він порожній |
|
текст є, але складається з пробілів (зазвичай його ще чистять trim()) |
|
текст "0" (це не число!) |
|
число нуль |
|
значення немає взагалі |
І тут Kotlin робить дуже правильний хід: він не дозволяє вам «випадково» змішувати ці сенси. Якщо змінна може бути null, це має бути видно прямо з її типу. У Kotlin nullable-типи явно позначаються знаком питання ? наприкінці назви типу.
2. Nullable та non-nullable типи
Non-nullable типи T
Майже всі типи, з якими ви працювали раніше, насправді були non-nullable: String, Int, Boolean. Тобто змінна такого типу не має права містити null. І це не «суворість заради суворості», а фундаментальний захист: якщо тип String, то в нього точно є рядкові властивості та методи (наприклад, довжина). Kotlin може на це спиратися.
Подивімося на мінімальний приклад:
fun main() {
val title: String = "Kotlin"
println(title) // Kotlin
title = null // Помилка компіляції: String не допускає null
}
Тут title — звичайний рядок. Kotlin гарантує, що title завжди містить рядок, а не «нічого». Тож якщо ви спробуєте присвоїти null, компілятор зупинить вас раніше, ніж програма встигне запуститися.
Чому це круто? Тому що помилки на кшталт «ой, тут виявився null» — одні з найнеприємніших. Вони спливають у середовищі виконання (коли користувач уже запускає програму), а не під час компіляції (коли ви ще можете спокійно все виправити).
Nullable-типи T?: як оголошувати та читати
Коли ви розумієте, що значення може бути відсутнім, треба чесно сказати про це Kotlin. Робиться це просто: додаємо ? до типу. String? читається як «рядок або null», Int? — як «ціле число або null».
Ось базовий приклад:
fun main() {
var nickname: String? = null
println(nickname) // null
nickname = "neo"
println(nickname) // neo
}
Зверніть увагу на два моменти. По-перше, nickname — var, бо ми хочемо спочатку зберігати відсутність значення, а потім присвоїти звичайний рядок. По-друге, println друкує null як текст null — це нормально, але це ще не «гарний вивід». Гарно ми навчимося робити пізніше; зараз нам важливо зрозуміти типи.
Порівняймо String і String? в одній маленькій таблиці:
| Тип | Можна присвоїти "hi" | Можна присвоїти null | Що означає |
|---|---|---|---|
|
так | ні | «рядок обовʼязково є» |
|
так | так | «рядок може бути відсутнім» |
Ще один важливий висновок: nullable-тип — це не «рядок із якимись бонусами». Це інший тип. Тому Kotlin не дає «вдавати», що String? — це String.
3. Nullable-результати функцій
Контракт T? у сигнатурі
До цього ви звикли, що функція або повертає нормальне значення, або ви якось «самі розберетеся» в її тілі. Але з null зʼявляється дуже корисна дисципліна: якщо функція може не дати результату, це відображається в типі, що повертається.
Це схоже на чесний контракт. Якщо написано Int, код, що викликає, має право очікувати число завжди. Якщо написано Int?, код, що викликає, зобовʼязаний памʼятати: «можу отримати число, а можу отримати null».
Найзнайоміший вам приклад — парсинг:
fun parseAge(text: String): Int? = text.toIntOrNull()
fun main() {
println(parseAge("42")) // 42
println(parseAge("oops")) // null
}
Тут відбувається важлива річ: функція parseAge не зобовʼязана «вигадувати» вік у разі помилки. Вона чесно каже: «якщо вийде — поверну Int, якщо ні — поверну null». І це не «погана новина». Це просто гілка реальності, яку тепер прямо видно з типу.
До речі, саме тому назви на кшталт ...OrNull у стандартній бібліотеці Kotlin — не випадковість, а підказка: «можливий null».
toIntOrNull() як перший практичний приклад Int?
Ви вже зустрічали toIntOrNull(), але сьогодні корисно подивитися на нього не як на «рятівника від падінь», а як на функцію з дуже зрозумілим контрактом: вона повертає Int?. Тобто або число, або null.
Зробімо маленький фрагмент, максимально схожий на те, що ви реально пишете в консольних завданнях:
fun main() {
print("Введіть вік: ")
val input = readln().trim()
val age: Int? = input.toIntOrNull()
println("age = $age") // наприклад: age = 25 або age = null
}
Поки ми нічого «розумного» не робимо — просто читаємо, прибираємо пробіли й намагаємося розпарсити. Найважливіше тут інше: змінна age має тип Int?, бо ми отримуємо результат від функції, яка може повернути null.
4. Міні застосунок: профіль користувача
Щоб null не залишався абстрактною філософією, давайте почнемо складати невеликий консольний застосунок. У наступних темах ми зможемо поступово його покращувати (але сьогодні — строго в межах типів). Ідея проста: користувач заповнює профіль. Частина полів обовʼязкова, а частина — ні. Наприклад, імʼя обовʼязкове, а нікнейм і вік — за бажанням.
Тут важливо правильно обрати, де буде String, а де String?. Це корисна звичка: за замовчуванням робити типи non-nullable і додавати ? лише там, де відсутність значення — реальна й осмислена ситуація.
Обовʼязкові та необовʼязкові рядки
Почнімо з найпростішого: читаємо імʼя (обовʼязкове) й нікнейм (його можна пропустити). Щоб у нас зʼявився null, домовмося так: якщо користувач увів порожній рядок після trim(), вважатимемо, що значення немає.
fun main() {
print("Імʼя (обовʼязково): ")
val name: String = readln().trim()
print("Нікнейм (можна порожньо): ")
val nickInput = readln().trim()
val nickname: String? = if (nickInput == "") null else nickInput
println("name = $name") // наприклад: name = Alice
println("nickname = $nickname") // наприклад: nickname = null
}
Зверніть увагу: ми поки не «покращуємо UX» і не валідуємо імʼя як слід. Нам зараз важливіше побачити типову модель: обовʼязкове поле — String, необовʼязкове — String?.
Необовʼязковий вік: Int?
Тепер додамо вік. Він теж необовʼязковий, але до того ж має бути числом. Тобто «порожньо» і «не число» — обидва випадки мають призвести до null. Це зручно виразити через toIntOrNull():
fun main() {
print("Вік (можна порожньо): ")
val ageInput = readln().trim()
val age: Int? = if (ageInput == "") null else ageInput.toIntOrNull()
println("age = $age") // наприклад: age = 30 або age = null
}
Так, тут є невелика «косметична шорсткість»: якщо введуть "abc", то toIntOrNull() поверне null, і ви не відрізните «порожньо» від «помилка». Але для сьогоднішньої лекції це навіть корисно: ми тренуємося бачити, що обидва сценарії призводять до відсутності результату, і це виражено типом Int?.
Збираємо профіль повністю
Тепер зберімо все разом і додамо виведення профілю. Оскільки ми ще не вивчали зручні інструменти для роботи з nullable-значеннями, друкуватимемо «як є», щоб null було видно явно:
fun main() {
print("Імʼя (обовʼязково): ")
val name: String = readln().trim()
print("Нікнейм (можна порожньо): ")
val nickInput = readln().trim()
val nickname: String? = if (nickInput == "") null else nickInput
print("Вік (можна порожньо): ")
val ageInput = readln().trim()
val age: Int? = if (ageInput == "") null else ageInput.toIntOrNull()
println("Профіль: імʼя=$name, нік=$nickname, вік=$age")
// наприклад: Профіль: імʼя=Alice, нік=null, вік=28
}
І ось головний практичний результат лекції: ви не «вигадуєте» значення замість користувача й не робите вигляд, ніби все заповнено. Ви зберігаєте реальну картину світу в типах. Якщо нікнейм не введено — це null. Якщо вік не зчитався — це null. Це чесно, передбачувано й дуже зручно для подальшої логіки.
Щоб закріпити навичку «читати типи очима», спробуйте подумки проговорити вголос:
- val name: String — «рядок завжди є».
- val nickname: String? — «рядок може бути відсутнім».
- val age: Int? — «число може бути відсутнім».
Так, це звучить як вправа з мовної школи, але вона справді допомагає. Програмування — це, зокрема, уміння читати домовленості, а типи в Kotlin — одна з найважливіших таких домовленостей.
5. Типові помилки
Помилка № 1: плутати null і "" (порожній рядок).
Дуже легко почати зберігати відсутність значення як порожній рядок, а потім раптово виявити, що ви не можете відрізнити «користувач нічого не ввів» від «користувач справді хотів порожнє значення» (так, іноді й таке буває). Краще заздалегідь домовитися: якщо поле опційне, відсутність — це null, а порожній рядок — це все одно рядок.
Помилка № 2: робити все nullable «про всяк випадок».
Новачкові здається безпечним оголосити всі рядки як String?, щоб «точно не впасти». Проблема в тому, що ви розмазуєте невизначеність по всьому коду: тепер кожен рядок треба обробляти як потенційно відсутній, навіть якщо за сенсом він обовʼязковий (наприклад, назва команди або назва меню). Хороша звичка в Kotlin — вважати значення non-nullable за замовчуванням і додавати ? лише там, де це справді відображає реальність.
Помилка № 3: очікувати, що toIntOrNull() поверне 0 у разі помилки.
Дехто підсвідомо чекає, що при невдалому розборі «ну буде нуль». Але контракт у toIntOrNull() інший: він повертає null, якщо рядок не є числом. І це логічно: 0 — конкретне число й може бути реальним введенням користувача, а null — чесний сигнал «не вийшло».
Помилка № 4: намагатися повернути null із функції, не вказавши ? у типі результату.
Якщо ви оголосили fun parseAge(...): Int, то повернути null не можна — і компілятор вас зупинить. Це не прискіпування, а допомога: спочатку визначаємо контракт (результат обовʼязковий чи ні), а потім пишемо реалізацію. Якщо «може не вийти», тип, що повертається, має бути Int? (або інший T?).
Помилка № 5: вважати, що null — це «просто ще одне значення» і нічого не змінюється.
Формально так, null — значення. Але семантично воно означає відсутність. Тож щойно null зʼявляється в типі, він починає впливати на весь код навколо: Kotlin не дозволить вам поводитися з nullable-значенням так само, як зі звичайним. Це може дратувати перші пів години, але потім ви починаєте розуміти: компілятор просто не дає вам наступити на граблі, які люди в інших мовах вважають «нормою життя».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ