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 (без анотацій) | |
| @Nullable Foo | |
| @NotNull 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() після нормалізації. Це не «параноя», а спосіб зробити поведінку передбачуваною — особливо коли дані приходять із чужого коду.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ