JavaRush /Курси /Kotlin SELF /@Nullable/@NotNull і nullability Java API у Kotlin

@Nullable/@NotNull і nullability Java API у Kotlin

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

1. Навіщо Kotlin потрібні @Nullable/@NotNull

Kotlin на JVM постійно взаємодіє з Java-світом: це і JDK, і будь-які Java-бібліотеки, і старий код у проєкті. У Kotlin усе виглядає «по-котлінівськи», але різниця фундаментальна: у Java історично null — звичайне значення, і далеко не завжди із сигнатури зрозуміло, чи може метод повернути null.

Звідси й головна тема: як Kotlin дізнається про nullability Java-методів і колекцій. Коли він бачить чіткий контракт (String або String?), а коли залишає платформену невизначеність (String!).

Коли ви пишете Kotlin-код, захист від null може здаватися «магією компілятора»: написали String — і все безпечно. Але щойно ви викликаєте Java-код, повертається реальність: у Java null допускається майже всюди, а відповідь на запитання «чи може метод повернути null» часто зберігається в голові автора бібліотеки (а інколи — лише в баг-трекері).

Щоб Kotlin міг бути строгим не лише всередині Kotlin-коду, а й під час викликів Java-коду, йому потрібні підказки. Ними стають анотації nullability у Java: @Nullable («може бути null») і @NotNull («null заборонено»). Kotlin уміє розпізнавати такі анотації (зокрема анотації JetBrains) і на їхній основі будувати типи, видимі в Kotlin. Це описують як enhancing signatures — поліпшення сигнатур типів під час імпорту Java API у Kotlin.

Важливо розуміти тонкий момент: анотації в Java — це не «залізобетонна гарантія», а контракт. Тобто автор обіцяє: «я повертаю не-null». Kotlin вірить цьому контракту настільки, що дозволяє писати строгіший код. Але якщо контракт порушено, JVM усе одно здатна «протягнути» null — і тоді ви отримаєте збій (щонайменше раніше й зрозуміліше, ніж десь далі в ланцюжку).

2. Як Kotlin будує типи: T, T? і T!

Коротка формула така: Kotlin намагається перетворити «незрозуміло що з Java» на «зрозумілий Kotlin-тип». Робить він це за пріоритетом інформації: спочатку дивиться на анотації (@Nullable/@NotNull), а якщо їх немає (або вони конфліктують), може залишити platform type (T!).

У специфікації Kotlin це формулюють приблизно так: Java-тип без анотацій перетворюється на flexible type (IDE часто показує як T!), а наявність анотацій може «уточнити» цей тип у бік T або T?.

Зафіксуймо просту таблицю, яку зручно тримати в голові:

Як виглядає в Java Як Kotlin це зазвичай бачить
Foo (без анотацій)
Foo!
@Nullable Foo
Foo?
@NotNull Foo
Foo

Тепер важлива практична частина: чому Foo! — це не «ще один різновид nullable», а саме «небезпечна невизначеність».

  • Foo? змушує вас писати безпечний код.
  • Foo змушує вас жити як оптиміст (і часом «падати», якщо оптимізм був зайвим).
  • Foo! — це стан «компілятор не впевнений». Іноді він дозволяє писати код так, ніби там Foo, хоча в середовищі виконання може прилетіти null.

Ось невеликий приклад, який показує «психологію» компілятора: якщо ви явно обираєте тип String, Kotlin має право вставити runtime-перевірку й «упасти одразу», бо ви оголосили строгий контракт (у специфікації це описують як генерацію перевірок за наявності expected type).

fun main() {
    val rawFromJava = System.getProperty("my.app.mode") // може бути null
    val mode: String = rawFromJava  // тут може впасти одразу, якщо rawFromJava == null
    println(mode.length)
}

У коректній програмі ми б, звісно, «приземлили» це в String?, але сам факт важливий: щойно ви в Kotlin обираєте T, ви ніби підписуєте папір «я впевнений, що не null».

3. Де ставлять @Nullable/@NotNull у Java

Досі ми говорили абстрактно: «Java-метод анотований». Але в новачків часто «ламається мозок» на рівні «а куди саме ставити анотацію і що саме вона означає?». До того ж є ще один шар складності: анотація може стосуватися не лише самого типу, а й типових аргументів (наприклад, елементів списку).

У специфікації Kotlin перелічують, що може бути анотовано: поле (анотація стосується типу поля), метод (анотація стосується типу, що повертається), параметр (анотація стосується типу параметра). Окремо згадують і анотації типу (Java 8+) — це якраз TYPE_USE.

Уявімо, що десь у Java-бібліотеці є такий інтерфейс (ми його не пишемо й не компілюємо — просто читаємо як «паспорт» API):

interface UserDirectory {
    @NotNull String findDisplayName(@NotNull String id);
    @Nullable String findEmail(@NotNull String id);
}

У Kotlin це читатиметься дуже природно:

fun printUserInfo(dir: Any /* припустімо, це UserDirectory */) {
    // умовно: displayName: String (не nullable)
    // умовно: email: String? (nullable)
}

Але найцікавіше починається тоді, коли Java починає анотувати внутрішність колекцій. Наприклад:

interface TagService {
    @NotNull List<@Nullable String> loadTags();
}

Тоді Kotlin бачить: «список точно є, але елементи можуть бути null» — тобто приблизно List<String?>.

І це не теоретична рідкість. У документації до компілятора Kotlin 2.x наводять приклад: за Java-сигнатури виду @NotNull ResultContainer<@Nullable String> fetchData() Kotlin переносить nullability в тип аргументу й отримує ResultContainer<String?>.

4. Колекції: null у контейнера і null у елементів

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

Контраст зручно побачити на типах:

  • List<String>? означає «список може бути відсутній, але якщо він є — рядки всередині не null».
  • List<String?> означає «список є, але всередині можуть бути null».
  • List<String?>? означає «може не бути списку, а якщо список є — всередині можуть бути null».

Якщо ви бачите такий тип (або підозрюєте, що у вас platform type, який фактично поводиться так само), найдоросліший підхід — нормалізація на межі: перетворити «брудний зовнішній світ» на «чистий внутрішній світ вашого застосунку».

Для нормалізації є знайомі інструменти: ?: emptyList(), filterNotNull(), mapNotNull(). Ми не вивчаємо нічого «нового й страшного» — просто правильно застосовуємо базовий Kotlin.

Ось невелика й дуже практична функція-шлюз:

fun normalizeTags(raw: List<String?>?): List<String> {
    return raw
        ?.map { it?.trim() }          // готуємо елементи (trim)
        ?.filterNotNull()             // викидаємо null-елементи
        ?.filter { it.isNotEmpty() }  // викидаємо порожні після trim
        ?: emptyList()                // якщо списку немає — вважаємо його порожнім
}

І приклад використання:

fun main() {
    val raw: List<String?>? = listOf(" kotlin ", null, "  ", "jvm")
    val tags = normalizeTags(raw)

    println(tags) // [kotlin, jvm]
}

Зверніть увагу на стиль: ми не намагаємося «тягнути nullable» далі через усю програму. Ми один раз, на межі, приймаємо реальність (дані брудні), чистимо її — і далі працюємо з нормальним List<String>.

І ось тут Java-анотації @Nullable/@NotNull справді допомагають: якщо бібліотека чесно написала @NotNull List<@Nullable String>, Kotlin покаже вам правду як List<String?>, і ви одразу розумієте, що потрібно зробити filterNotNull().

5. Вбудовування в CLI: «брудний вхід» → «чиста Kotlin-модель»

У курсі вже є практичний консольний застосунок: він зберігає дані в колекціях, приймає команди й намагається бути стійким до сміттєвого введення. Зараз ми додамо невеликий, але типовий шар: «дані прийшли ззовні» (у реальності це могла б бути Java-бібліотека, плагін або SDK), а ми хочемо вбудувати ці дані в доменний код, не рознісши null по всьому проєкту.

Уявімо, що десь «ззовні» нам приходять назви категорій. Ми не писатимемо Java-код, зате писатимемо Kotlin так, ніби нам дали функцію, що повертає «список рядків, можливо з null». Навіть якщо в Java це було анотовано, ми все одно хочемо мати єдиний шлюз.

Зробімо утиліту, яка перетворює зовнішній список на список категорій, придатний для команд add/list:

private fun normalizeCategoryNames(raw: List<String?>?): List<String> {
    return raw
        ?.map { it?.trim()?.lowercase() }
        ?.filterNotNull()
        ?.filter { it.isNotEmpty() }
        ?: emptyList()
}

Тепер додамо використання в наш «main-цикл» (або в обробник команди). Нехай команда categories друкує доступні категорії (у реальності — із зовнішнього джерела):

fun main() {
    // ніби прийшло з Java-бібліотеки:
    val rawFromJava: List<String?>? = listOf(" Food ", null, "Transport", "  ")

    val categories = normalizeCategoryNames(rawFromJava)

    println("Категорії (${categories.size}):")
    println(categories.joinToString()) // food, transport
}

Методологічно тут важливе таке: ми не обговорюємо, чому там зʼявився null, і не намагаємося «вилікувати» весь світ. Ми просто будуємо «санпропускник»: далі в застосунку категорії — це звичайний List<String>, і код команд не перетворюється на нескінченний фестиваль ?.

Якщо завтра зовнішнє джерело стане чеснішим і почне віддавати @NotNull List<@NotNull String>, то Kotlin-тип стане ще строгішим, але наш санпропускник однаково залишиться коректним (просто стане трохи надлишковим).

6. Чому Kotlin залишає T!

Тут корисно зняти рожеві окуляри: «анотації врятують світ» — звучить красиво, але реальність складніша. Kotlin намагається використовувати @Nullable/@NotNull, але в нього є важливе правило сумісності: якщо анотації зникнуть (або бібліотека виявиться без них), код усе одно має мати шанс скомпілюватися. Тому за неоднозначностей Kotlin може «відкотитися» до platform type і, можливо, показати попередження.

У специфікації це описують прямо: якщо «поліпшена» сигнатура несумісна (наприклад, конфліктує із сигнатурами в ієрархії типів), то enhancing може бути відкинуто, і замість строгого типу Kotlin візьме platform type.

Звідси нам потрібен практичний висновок:

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

Наприклад, ось такий стиль коду — стійкий:

fun safeToken(raw: String?): String? {
    return raw
        ?.trim()
        ?.takeIf { it.isNotEmpty() }
}

fun main() {
    val tokenFromJava: String? = System.getenv("APP_TOKEN") // прийшло ззовні
    val token = safeToken(tokenFromJava)

    println(token ?: "<немає токена>") // <немає токена>
}

Як побачити String, String? і String! в IntelliJ IDEA

IDE — ваш рентген: завдяки їй ви розумієте, що насправді повернув Java-код і що Kotlin зміг вивести з анотацій. Тож корисно привчити себе час від часу зупинятися й дивитися на тип виразу.

В IntelliJ IDEA зазвичай достатньо навести курсор на вираз або змінну.

  • Якщо ви побачили String, це означає, що десь у контракті гарантується non-null.
  • Якщо побачили String?, це означає, що хтось чесно сказав «може бути null» (і ви зобовʼязані це обробити).
  • А от String! — це табличка «Обережно, можливі сюрпризи»: Kotlin не впевнений, і відповідальність знову на вас.

І ще одна підказка: повідомлення компілятора про nullability зазвичай доволі буквальні. Якщо ви намагаєтеся передати String? туди, де очікують String, він скаже Type mismatch. Якщо ви намагаєтеся викликати .length у String?, він нагадає про safe call. Це не «лається» — це буквально ваша програма на етапі «не даю собі наступити на граблі».

7. Типові помилки під час роботи з @Nullable/@NotNull і Java-колекціями

Помилка №1: вважати, що @NotNull — це магічне заклинання, яке фізично забороняє null.
Анотація — це контракт, а не броня. Kotlin може повірити цьому контракту й дати вам тип T, але JVM усе ще може принести null, якщо бібліотека помилилася. Тому навіть із @NotNull важливо розуміти, де ви довіряєте зовнішньому світові, а де хочете «упасти одразу» й продіагностувати проблему.

Помилка №2: плутати «список може бути null» і «усередині списку можуть бути null».
List<String>? і List<String?> ламають код по-різному. У першому випадку ви навіть не можете ітеруватися без перевірки контейнера, у другому — ітеруватися можна, але ви зобовʼязані вирішити долю кожного елемента. Під час Java-інтеграцій легко отримати обидва ризики одразу, і тоді потрібен акуратний санпропускник: ?: emptyList() плюс filterNotNull().

Помилка №3: тягнути platform types (T!) глибоко в доменну логіку.
Якщо ви дозволяєте String! або MutableList<String!>! «гуляти» проєктом, то «раптовий NPE» стає питанням часу. Набагато дешевше один раз на межі вирішити: «це nullable чи ні?» — і далі працювати за нормальними Kotlin-правилами.

Помилка №4: «лікувати» все через !!, бо так швидше.
!! — це не інструмент інтеграції, а аварійна кнопка «зламай програму тут». Іноді це справді правильне рішення (наприклад, без значення застосунок не може працювати), але в обробці зовнішніх даних частіше потрібні fallback (?:), нормалізація (trim(), takeIf()) і мʼяка поведінка (порожній список замість null-списку).

Помилка №5: очікувати, що List у Kotlin робить дані незмінними, якщо джерело — Java.
Навіть якщо ви тримаєте посилання як List<String>, Java-джерело могло повернути змінювану колекцію. Якщо для вашого сценарію важлива стабільність «знімка», зробіть копію через toList() після нормалізації. Це не «параноя», а спосіб зробити поведінку передбачуваною — особливо коли дані приходять із чужого коду.

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