JavaRush /Курси /Kotlin SELF /Nullable-типи T? і значення null

Nullable-типи T? і значення null

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

1. Навіщо потрібен null

Програмісти люблять визначеність: якщо у нас є змінна name, то там завжди має бути імʼя. Але реальне життя (і реальні користувачі) легко ламає цю романтичну картину. Хтось не ввів нікнейм, хтось пропустив вік, а хтось натиснув Enter «просто щоб подивитися, що буде». І ось постає запитання: як зберігати «невідомо»?

Варіант «ну нехай буде порожній рядок ""» іноді спрацьовує. Але часто він спотворює сенс. Порожній рядок — це теж значення, просто «рядок довжини 0». А інколи нам потрібно зберігати саме відсутність значення: «поля не було», «людина не вказала», «ми не змогли порахувати». Для цього в Kotlin є спеціальне значення — null.

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

null — це не "", не 0 і не false

Важливо звикнути до думки: null — це не «порожнє значення», а факт відсутності значення. Порожній рядок "" — це рядок. Нуль 0 — це число. false — це логічне значення. А null — це «нічого», «не задано», «не існує».

Чому ця різниця справді важлива? Бо інакше ви починаєте плутати сенси й писати дивні умови. Наприклад, якщо вік не введено, можна обрати стратегію «вважаємо, що вік 0». Але тоді програма поводитиметься так, ніби користувач — новонароджений. Смішно, але лише перші два рази.

Порівняймо кілька «схожих, але не однакових» варіантів:

Значення Типовий сенс
""
текст є, але він порожній
"   "
текст є, але складається з пробілів (зазвичай його ще чистять trim())
"0"
текст "0" (це не число!)
0
число нуль
null
значення немає взагалі

І тут 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
}

Зверніть увагу на два моменти. По-перше, nicknamevar, бо ми хочемо спочатку зберігати відсутність значення, а потім присвоїти звичайний рядок. По-друге, println друкує null як текст null — це нормально, але це ще не «гарний вивід». Гарно ми навчимося робити пізніше; зараз нам важливо зрозуміти типи.

Порівняймо String і String? в одній маленькій таблиці:

Тип Можна присвоїти "hi" Можна присвоїти null Що означає
String
так ні «рядок обовʼязково є»
String?
так так «рядок може бути відсутнім»

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

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