JavaRush /Курси /Kotlin SELF /Smart cast після перевірок на null

Smart cast після перевірок на null

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

1. Базові випадки smart cast

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

Це й називають smart cast — автоматичним «звуженням типу» в безпечній області. У Kotlin 2.x (K2-компілятор) таких ситуацій стало більше, а аналіз — розумнішим, але принцип той самий: компілятор починає «довіряти» змінній лише там, де ця гарантія не може раптово зникнути.

Уявіть, що String? — це коробка, у якій може лежати рядок, а може — порожнеча. Smart cast — це момент, коли ви зазирнули в коробку, побачили рядок і кажете: «Гаразд, доки я не заплющив очі й ніхто не чіпав коробку, вважатимемо, що рядок точно є».

Після if (x != null)

Найприємніший сценарій — ви перевірили змінну на null, і всередині гілки if компілятор дозволив звертатися до неї як до звичайної non-null змінної. У цей момент код стає значно чистішим: замість ланцюжків ?. ви пишете звичайні виклики методів і властивостей.

Саме заради цього smart cast і люблять: він робить код водночас безпечним і читабельним — без зайвої «пунктуації». До того ж він допомагає мислити логікою програми: «у цій гілці значення точно є».

Приклад: параметр функції (найнадійніший кейс)

fun printNicknameInfo(nickname: String?) {
    if (nickname != null) {
        println("Довжина ніка: ${nickname.length}")  // Довжина ніка: 3 (наприклад)
        println("Верхній регістр: ${nickname.uppercase()}") // Верхній регістр: NEO
    }
}

Чому це зазвичай працює ідеально? Тому що параметр nickname усередині функції сам по собі не «стрибає»: ви його не переназначаєте. Компілятор бачить перевірку nickname != null і вважає, що всередині блоку if можна працювати з ним як зі String.

Приклад: guard clause (ранній вихід) — smart cast без вкладеності

fun formatNickname(nickname: String?): String {
    if (nickname == null) return "(anonymous)"
    return nickname.uppercase()
}

Після return залишається лише гілка, де nickname точно не null, і компілятор це розуміє. Такий стиль часто читається легше, ніж великий if/else, бо код іде «прямо», а не блукає лабіринтом розгалужень.

Smart cast і &&

Раніше ви вже бачили, як && дає змогу писати коротше: якщо зліва false, права частина не обчислюється. Для null-safety це справжній подарунок: вираз x != null && x.length > 3 безпечний, бо x.length буде обчислено лише тоді, коли x не null.

І компілятор це враховує: у другій частині умови x часто smart castиться до non-null. Виходить, що && працює як мініпідказка: «спочатку перевіряємо наявність, потім використовуємо».

Приклад: перевірка на null + перевірка довжини

fun isGoodNickname(nickname: String?): Boolean {
    return nickname != null && nickname.length >= 3
}

fun main() {
    println(isGoodNickname(null))     // false
    println(isGoodNickname("ab"))     // false
    println(isGoodNickname("neo"))    // true
}

Тут nickname.length не потребує ?., бо права частина виконується лише за умови nickname != null.

Приклад: «порожній рядок» vs null

fun main() {
    val raw = "   "
    val nickname: String? = raw.trim().ifEmpty { null }

    if (nickname != null && nickname.isNotBlank()) {
        println("Нік: ${nickname.uppercase()}")
    } else {
        println("Нік не задано") // Нік не задано
    }
}

Зверніть увагу на ідею: null — це «немає значення», а " " — «значення є, але воно сміттєве». Саме тому інколи спочатку нормалізують рядок, а вже потім застосовують smart cast.

3. Іноді smart cast не спрацьовує

Ось тут і починається та сама ситуація, коли новачок каже: «Але ж я перевірив! Чому компілятор не вірить?!»

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

З погляду компілятора логіка така: «Ти перевірив nickname != null. Але якщо це var, то хто гарантує, що за один рядок вона не стала null?» І компілятор, як обережна людина, воліє перестрахуватися.

Приклад: var можна змінити прямо всередині if

fun main() {
    var nickname: String? = "neo"

    if (nickname != null) {
        nickname = null

        println(nickname.length) // так писати не можна: після переназначення nickname знову може бути null
        println(nickname) // null
    }
}

Тут навіть людині очевидно, що nickname.length після nickname = null — погана ідея. Компілятор просто не дає вам зробити дурницю.

Приклад: змінну змінює функція

fun main() {
    var nickname: String? = "neo"

    fun dropNickname() {
        nickname = null
    }

    if (nickname != null) {
        dropNickname()

        println(nickname.length) // компілятор не дасть: nickname міг стати null
        println(nickname?.length)   // null
    }
}

Для компілятора dropNickname() — «чорна скринька». Навіть якщо ви зараз знаєте, що робить функція, завтра ви (або колега) додасте туди нову логіку — і гарантія зникне. Kotlin віддає перевагу безпеці.

Приклад: «перевірив одне, використовую інше» (тонка логічна пастка)

fun main() {
    var nickname: String? = "neo"

    val ok = nickname != null
    if (ok) {
        // Усередині блока nickname не стає автоматично String,
        // бо перевіряли ми змінну ok, а не nickname напряму.
        println(nickname?.length) // 3
    }
}

У Kotlin 2.x компілятор у деяких випадках справді навчився краще знаходити такі «доведення» з коду. Але зазвичай новачкові корисно думати так: smart cast найпростіше працює, коли перевірка стоїть поруч із використанням.

4. Що робити, якщо smart cast не спрацював

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

Набагато краще мати три нормальні стратегії: зробити знімок у val, використати ?./?: або перебудувати код так, щоб перевірка була ближче до використання.

Стратегія A: snapshot — зберігаємо значення у val

fun main() {
    var nickname: String? = "neo"

    val snapshot: String? = nickname
    if (snapshot != null) {
        println(snapshot.length) // 3
    }
}

Чому це працює? Бо snapshot — це val. Його не можна переназначити, отже в межах блоку if компілятор вважає його стабільним і спокійно робить smart cast.

Стратегія B: safe call ?. + Elvis ?:

fun main() {
    val nickname: String? = null

    val len: Int = nickname?.length ?: 0
    println("Довжина ніка: $len") // Довжина ніка: 0
}

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

Стратегія C: перебудувати код через ранній return

fun printUpper(nickname: String?) {
    if (nickname == null) {
        println("Нік не задано")
        return
    }

    println(nickname.uppercase())
}

fun main() {
    printUpper(null)    // Нік не задано
    printUpper("neo")   // NEO
}

Тут ви не змушуєте компілятор бути телепатом: ви явно відокремлюєте гілку null, а далі працюєте з non-null значенням.

5. Як компілятор думає: схема і табличка

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

Це не точна внутрішня модель компілятора, але як «ментальна картинка» — корисно.

flowchart TD
    A["Є змінна x: T?"] --> B{"Ми перевірили x != null?"}
    B -- "ні" --> C["Потрібні ?. / ?: / if / return"]
    B -- "так" --> D{"x стабільний?"}
    D -- "так (val / параметр / локальний знімок)" --> E["Smart cast: x вважається T всередині області"]
    D -- "ні (var може змінитися)" --> F["Компілятор не дає x.member без безпечного доступу"]

І ще одна маленька табличка, щоб закріпити ідею «стабільності» без занурення в магію компілятора:

Що перевіряємо на null Зазвичай smart cast працює? Чому
Параметр функції p: String? Так Параметр не можна переназначити, він стабільний у тілі функції
Локальний val x: String? Так val не змінюється
Локальний var x: String? Іноді Доки компілятор упевнений, що x не зміниться до використання
var, який може змінити функція/замикання Часто ні Компілятор не може гарантувати, що x не стане null

6. Міні-застосунок: анкета користувача

Давайте зберемо невеличкий консольний застосунок, який запитує в користувача нік (його можна пропустити), а потім друкує привітання. Ми спеціально зробимо код так, щоб зустріти і «приємний» smart cast, і ситуацію, де він не спрацьовує, і розвʼязання через snapshot.

Ідея проста: користувач вводить рядок. Якщо після trim() він порожній, вважаємо, що нік не задано, тобто це null. Це якраз той випадок, коли null має сенс: «користувач не надав значення».

Версія 1: лінійний код із класичним smart cast

fun main() {
    print("Введіть нік (Enter — пропустити): ")
    val raw = readln().trim()

    val nickname: String? = if (raw.isEmpty()) null else raw

    if (nickname != null) {
        println("Привіт, ${nickname.uppercase()}!") // Привіт, NEO! (наприклад)
    } else {
        println("Привіт, незнайомцю!")              // Привіт, незнайомцю!
    }
}

Тут nicknameval, отже всередині if (nickname != null) компілятор спокійно сприймає nickname як String.

Версія 2: додамо var і «підозрілу» функцію

Тепер уявімо, що ми хочемо дозволити «скидання ніка» якоюсь логікою (наприклад, за спецкомандою). Це штучний приклад, але він чудово показує причину, чому smart cast інколи відмовляється працювати.

fun main() {
    print("Введіть нік (Enter — пропустити): ")
    val raw = readln().trim()

    var nickname: String? = if (raw.isEmpty()) null else raw

    fun maybeDropNickname() {
        // Уявімо, що тут складна логіка.
        // Для прикладу "складна логіка" — це просто скидання.
        nickname = null
    }

    if (nickname != null) {
        maybeDropNickname()

        // println(nickname.length) // компілятор не дозволить
        println("Довжина ніка: ${nickname?.length}")  // Довжина ніка: null
    }
}

Тут важливий сам факт: раз nicknamevar і є функція, яка може її змінити, компілятор не дасть звертатися до неї як до String після перевірки.

Версія 3: лагодимо через snapshot (і зберігаємо читабельність)

fun main() {
    print("Введіть нік (Enter — пропустити): ")
    val raw = readln().trim()

    var nickname: String? = if (raw.isEmpty()) null else raw

    fun maybeDropNickname() {
        nickname = null
    }

    val snapshot = nickname
    if (snapshot != null) {
        // Навіть якщо nickname потім зміниться, snapshot уже не зміниться
        println("Привіт, ${snapshot.uppercase()}!") // Привіт, NEO!
    }

    maybeDropNickname()
    println("nickname зараз: $nickname")           // nickname зараз: null
}

Сенс простий: якщо вам потрібно «зафіксувати значення на момент перевірки», зробіть знімок. Це дуже поширений прийом у реальному коді, зокрема й тоді, коли згодом ви перейдете до складніших програм.

7. Типові помилки під час використання smart cast з null

Помилка №1: вважати, що перевірка x != null робить x безпечним «назавжди».
Smart cast діє в конкретній області — зазвичай усередині блоку if — і лише доти, доки компілятор упевнений, що значення не можна змінити. Варто вийти з блоку або змінити змінну — гарантія зникає, і це нормально: код має чесно показувати, де у вас є доказ, а де його вже немає.

Помилка №2: «лагодити компіляцію» через !!, бо «я точно знаю».
!! — це не «зроби, будь ласка, щоб компілювалося», а «якщо тут null, нехай програма впаде». Іноді така поведінка справді потрібна, але в більшості навчальних задач це лише спосіб сховати помилку. Зазвичай правильніше використати if, ?., ?: або snapshot у val — і зробити сценарій із null явним.

Помилка №3: розносити перевірку і використання надто далеко одне від одного.
Коли між if (x != null) та використанням x зʼявляється десяток рядків, виклики функцій або вкладені умови, компілятору складніше гарантувати безпеку, а людині — складніше читати код. Хороший стиль — або використати значення одразу всередині гілки, або зберегти знімок у val, або зробити ранній return, щоб код залишався лінійним.

Помилка №4: намагатися «вмовити» компілятор замість того, щоб допомогти йому структурою коду.
Новачки інколи починають переставляти дужки, додавати зайві перевірки або писати «магічні» конструкції, сподіваючись, що smart cast раптом зʼявиться. Зазвичай краще не вгадувати, а обрати просту стратегію: snapshot, ?./?: або ранній return. Kotlin цінує зрозумілий код і охоче «підтримує» його.

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