JavaRush /Курси /Kotlin SELF /Platform types у Kotlin: T! і контракт T? vs T

Platform types у Kotlin: T! і контракт T? vs T

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

1. Вступ

Якщо ви пишете лише «чистим» Kotlin, компілятор доволі суворо оберігає вас від NullPointerException: якщо тип String, то null туди не потрапить. Але Kotlin/JVM живе у світі Java‑бібліотек, де історично null — це «ну, може бути, а може й ні». Тож щойно ви викликаєте Java API, Kotlin інколи змушений чесно зізнатися: «Я не знаю, nullable це чи ні».

І саме тут зʼявляється platform type — штука, через яку можна зловити NPE навіть у Kotlin‑коді. Не тому, що Kotlin «поганий», а тому, що він дружньо співпрацює з Java й не може переписати всю екосистему замість вас.

Що таке platform type: T! простими словами

Platform type — це тип, який Kotlin отримує з Java‑коду, коли не може визначити nullability. В IntelliJ IDEA ви часто побачите це як String!, Int!, List<String!>! тощо. Важливо: T! — це не «новий тип», який ви можете написати в коді. Це «позначка на полях» від компілятора/IDE: «обережно, контракт неясний».

Технічно це повʼязано з так званими flexible types (гнучкими типами) під час Java‑interop. У цьому випадку Kotlin тримає діапазон можливих типів, доки ви самі не «приземлите» значення в зрозумілий Kotlin‑контракт. У специфікації це інколи описують як потребу «вибухнути раніше», якщо ви присвоюєте Java‑результат в очікуваний (non‑null) тип, — щоб помилка проявилася одразу, а не через три функції й за пʼять хвилин.

Уявіть, що вам принесли коробку з наліпкою «Скло». Усередині може бути скло, а може — печиво. Ви не дізнаєтеся, доки не відкриєте. Platform type — це така коробка. А String? або String — це вже «ви відкрили й перевірили».

2. Чим небезпечні platform types: компілятор мовчить, а застосунок падає під час виконання

Найнеприємніше в platform types те, що код може виглядати «абсолютно по‑котлінівському», але впасти під час виконання. Причому це буде не компіляційна помилка (до неї ми вже звикли як до «вихователя»), а справжній виняток під час виконання.

Класичний приклад — System.getProperty(...). Це Java API, і воно може повернути null, якщо властивості немає.

fun main() {
    val mode = System.getProperty("my.app.mode") // IDE може підсвітити як String!
    println(mode.length)                          // може впасти NPE
}

З погляду Kotlin змінна mode тут — platform type: ніби «і String, і String? одночасно» — залежно від того, як подивитися. А далі ви викликаєте .length, і якщо насправді прийшов null, буде NPE.

У світі Kotlin ми звикли, що таке не компілюється, — і це правда для звичайних типів (String / String?). Але platform types — «лазівка», потрібна для сумісності з Java.

3. Стратегія: фіксуємо контракт на межі (T? або T)

Із platform types найкраще боротися не героїзмом («та годі, там точно не null»), а дисципліною: щойно значення прийшло з Java — ви одразу вирішуєте, що воно означає в Kotlin.

Цю ідею «межі» можна уявити так:

flowchart LR
    A[Java API: може повернути null] --> B[Platform type T!]
    B --> C{Вибір контракту}
    C -->|nullable| D[T? + ?. + ?: + перевірки]
    C -->|non-null| E[T + fail-fast перевірка]

Таблиця вибору контракту

Що ви робите на межі Коли це доречно Що ви отримуєте
val x: T? = javaCall()
null — нормальна ситуація («значення немає») Kotlin знову захищає вас, змушуючи обробляти null
val x: T = javaCall()
null — порушення контракту, і ви хочете впасти одразу Fail-fast: якщо null усе ж прийшов, програма падає швидко й явно

Зверніть увагу на важливу деталь: коли є очікуваний тип (наприклад, ви присвоюєте в val x: String), Kotlin може вставити перевірки/асерти, щоб «вибухнути раніше», якщо значення не відповідає очікуванням. Це не скасовує проблеми, але робить падіння раннішим і передбачуванішим.

5. Варіант 1: обираємо T? і обробляємо null

Коли значення «може бути відсутнім», це нормальний стан, а не помилка. Наприклад, змінна середовища "APP_TOKEN" може бути не задана, і ви хочете просто працювати в режимі «без токена».

fun main() {
    val raw: String? = System.getenv("APP_TOKEN") // фіксуємо контракт як nullable

    val token = raw
        ?.trim()
        ?.takeIf { it.isNotEmpty() }

    println(token ?: "<no token>") // <no token>
}

Тут ми одразу робимо три корисні речі: (1) кажемо компілятору «це String?», (2) нормалізуємо пробіли через trim(), (3) не плутаємо «порожньо» й «немає значення», відкидаючи порожній рядок через takeIf { it.isNotEmpty() }.

Чому це добре саме в темі platform types? Тому що ми перетворюємо «коробку» (String!) на зрозумілий контракт (String?), а далі застосовуємо ті самі прийоми, які ви вже опанували в null-safety.

Нюанс: null і "" — не одне й те саме

Дуже поширений «фікс» виглядає так: val x = javaCall() ?: "". Це може бути помилкою в дизайні. Порожній рядок — це значення. null — це відсутність значення. Якщо ви автоматично замінюєте одне на інше, ви стираєте сенс.

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

6. Варіант 2: обираємо T і робимо fail-fast

Іноді відсутність значення — це не «нормально», а «програма не може працювати». Наприклад, ви домовилися: застосунок запускається лише якщо задано "APP_DATA_DIR". Тоді ви хочете, щоб він упав одразу й зрозуміло, а не продовжував працювати з кривим станом.

Тут є кілька способів, і важлива саме ідея: ми явно стверджуємо контракт.

Спосіб 1: require(...) + guard clause

Ми вже знайомі з require (підхід fail-fast). Використаймо його як «ворота» для Java‑виклику:

fun main() {
    val dir: String? = System.getenv("APP_DATA_DIR")

    require(dir != null) { "APP_DATA_DIR не задано" }

    println("Каталог даних = $dir") // Каталог даних = ...
}

Після require(dir != null) змінна dir у цій точці смарткаститься в String (як незмінюваний val), і ви працюєте далі без ?.. Цей підхід узгоджується із загальною філософією «перевірили передумову → далі код простіший».

Спосіб 2: усвідомлений !!

!! — це «я хочу, щоб програма впала, якщо тут null». За змістом це теж fail-fast, але без пояснювального повідомлення (або з менш зручним).

fun main() {
    val home = System.getenv("HOME")!!
    println("HOME=$home") // HOME=...
}

Цей варіант припустимий лише тоді, коли ви справді впевнені в контракті середовища. Якщо впевненості немає, require краще: у нього повідомлення зрозуміліше, і під час налагодження ви швидше збагнете, що саме пішло не так.

7. Вбудовуємо в консольний застосунок: конфіг із Java API

Щоб тема не залишилася «у вакуумі», давайте додамо в наш навчальний консольний застосунок (умовний трекер витрат із командами) невеликий шар конфігурації, який береться з Java‑оточення.

Наша мета цілком практична: показати, що platform type зʼявляється «там, де ви не очікували», і як його приборкати.

Читаємо режим роботи через System.getProperty

Властивість "my.app.mode" може бути не задана. Тоді режим буде "DEFAULT".

fun readAppMode(): String {
    val raw: String? = System.getProperty("my.app.mode") // platform -> фіксуємо як String?
    val normalized = raw?.trim()?.uppercase()

    return normalized ?: "DEFAULT"
}

fun main() {
    val mode = readAppMode()
    println("mode = $mode") // mode = DEFAULT (якщо властивості немає)
}

Зверніть увагу: ми не «виконуємо трюк» прямо всередині println, а спершу фіксуємо nullable‑контракт і лише потім робимо нормалізацію. Це робить код читабельним і, що важливіше, полегшує налагодження: ви зможете поставити точку зупину на raw і побачити, що насправді прийшло.

Читаємо імʼя користувача й не плутаємо null з порожнім рядком

"user.name" часто задано, але ми не будемо «вірити на слово».

fun readUserNameOrNull(): String? {
    val raw: String? = System.getProperty("user.name")
    return raw?.trim()?.takeIf { it.isNotEmpty() }
}

fun main() {
    val user = readUserNameOrNull()
    println("user = ${user ?: "<anonymous>"}") // user = <anonymous> (якщо порожньо/немає)
}

Тут добре видно, чому takeIf — корисний друг: він допомагає виразити правило «значення вважається валідним лише тоді, коли воно не порожнє».

Додамо ідентифікатор запуску через Java UUID

Генерація UUID — це приклад Java API, де проблем із null зазвичай немає. Але приклад корисний, щоб побачити: «Java‑методи виглядають звично», і їх не треба боятися. Плюс це зручно для логів.

import java.util.UUID

fun newRunId(): String {
    return UUID.randomUUID().toString()
}

fun main() {
    val runId = newRunId()
    println("runId = $runId") // runId = 550e8400-e29b-41d4-a716-446655440000 (приклад)
}

Смисловий висновок для теми platform types: Java API буває «безпечним» і «небезпечним», але зовнішній вигляд виклику однаковий. Тож звичка «на межі фіксуємо контракт» — це не параноя, а нормальна гігієна.

Чому краще спочатку приземлити тип у змінну

Дуже хочеться написати так:

val mode = System.getProperty("my.app.mode").trim().uppercase()

Саме так і народжуються NPE «у Kotlin, який же безпечний!». Проблема не в trim() і не в uppercase(). Проблема в тому, що System.getProperty(...) може повернути null, а через platform type компілятор не завжди змусить вас обробити це заздалегідь.

Правильний стиль виглядає трохи довшим, зате ви виграєте в передбачуваності:

fun main() {
    val raw: String? = System.getProperty("my.app.mode")
    val mode = raw?.trim()?.uppercase() ?: "DEFAULT"

    println(mode) // DEFAULT
}

Так, на 2 рядки більше. Зате на 20 хвилин менше «чому впало в користувача, а в мене ні».

8. Типові помилки під час роботи з platform types

Помилка №1: думати, що Kotlin завжди захищає від NPE.
У межах «чистого» Kotlin — майже завжди так. Але на межі з Java зʼявляються platform types (T!), і вони можуть поводитися як «майже non‑null» — аж до першого реального null під час виконання. Лікується дисципліною: одразу приземляти Java‑результат у T? або T і не залишати його «плаваючим».

Помилка №2: присвоювати Java‑результат у non‑null тип «бо так зручніше».
val x: String = System.getProperty("x") виглядає красиво, доки не зʼясовується, що властивості немає. Так, компілятор намагається «вибухнути раніше», якщо значення не відповідає контракту, але для користувача це все одно падіння. Краще зробити вибір усвідомлено: або String? + обробка, або fail-fast із виразним require.

Помилка №3: «лікувати» null дефолтом "" без розуміння сенсу.
Порожній рядок часто не дорівнює відсутності значення. Якщо ви робите ?: "" на шляху/токені/режимі, ви можете отримати програму, яка «не падає», але працює неправильно. Якщо значення справді опційне, залишайте String?. Якщо воно обовʼязкове, падайте одразу зі зрозумілим повідомленням.

Помилка №4: писати довгі ланцюжки викликів прямо на Java‑результаті.
System.getenv("X").trim().lowercase() — коротко, але небезпечно. Окрема змінна raw: String? і акуратна обробка через ?. та ?: робить код трохи довшим, зате набагато стабільнішим і простішим для читання та налагодження.

Помилка №5: зловживати !! як «універсальною латкою».
!! перетворює потенційний null на гарантоване падіння, але часто без контексту й із менш дружнім повідомленням. Якщо падіння — це справді дизайн‑рішення, краще виразити його через require/перевірку, щоб у разі помилки було зрозуміло, чого саме бракує і чому це критично.

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