JavaRush /Курси /Kotlin SELF /Smart-cast і як його застосовувати

Smart-cast і як його застосовувати

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

1. Smart-cast як обіцянка компілятора

Smart-cast — це не магія і не телепатія. Це цілком прагматичне рішення компілятора: якщо він упевнений, що в певній точці коду значення має вужчий тип (наприклад, String замість String?), то дозволяє звертатися до нього як до цього вужчого типу — без явного as і без !!.

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

Почнімо із ситуації, де smart-cast працює ідеально. Виглядає це приблизно так: «ну так і має бути»:

fun printNameLength(name: String?) {
    if (name != null) {
        println(name.length) // ok: усередині блока name сприймається як String
    }
}

Тут name — параметр функції (по суті, локальне посилання). У межах блока if компілятор може гарантувати: якщо ми сюди потрапили, значить name не null.

2. Чому smart-cast інколи не працює

Якщо описати проблему однією фразою, то вона така: smart-cast працює лише там, де компілятор упевнений, що об’єкт стабільний (тобто не зміниться непомітно).

Kotlin доволі насторожено ставиться до кількох ситуацій: змінюваних змінних (var), властивостей об’єкта (obj.prop) — особливо якщо там може бути власний ґетер, — а також до випадків, коли значення використовують усередині лямбди й воно може змінитися десь іще.

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

var і «а раптом його змінять?»

var — улюблене джерело болю, бо буквально означає: «це можна змінити». І якщо компілятор бачить, що змінна в принципі може змінитися, він часто відмовляється застосовувати smart-cast на довшій ділянці коду.

Подивімося на приклад, близький до реальності. Припустімо, у CLI-застосунку є «поточний рядок введення» (не найкращий дизайн, але в навчальних проєктах так буває).

var currentLine: String? = null

fun printCurrentLineLength() {
    if (currentLine != null) {
        // Компілятор може лаятися: currentLine — var, вона може стати null
        println(currentLine.length)
    }
}

Чому Kotlin може лаятися? Бо currentLine читається двічі: один раз в умові, другий — у println. Між цими читаннями теоретично міг виконатися будь-який код (у великому проєкті — хоч обробник події, хоч інша функція, що змінила глобальний стан). Навіть якщо у вас зараз «жодних потоків» і «нічого такого», компілятор усе одно дотримується контракту мови, а не поточного настрою проєкту.

Виправляється це класичним прийомом: знімком значення у локальний val.

var currentLine: String? = null

fun printCurrentLineLength() {
    val snapshot = currentLine
    if (snapshot != null) {
        println(snapshot.length) // ok: snapshot — val, стабільна в цьому блоці
    }
}

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

obj.property — це виклик доступу, а не «змінна»

Дуже поширена пастка: ми перевірили user.name != null, а потім хочемо використати user.name.length. Здається логічним, але компілятор дивиться на це інакше.

Ключова думка: user.name — це властивість, а доступ до властивості не зобов’язаний бути «простим читанням поля». Це може бути виклик ґетера (зокрема власного), який щоразу повертає нове значення. Тому компілятор часто не застосовує smart-cast до user.name «надовго».

Мінімальний приклад:

class User(var name: String?)

fun printUserNameLength(user: User) {
    if (user.name != null) {
        // Часто помилка: smart-cast не застосовується до user.name
        println(user.name.length)
    }
}

Навіть якщо name — звичайне var, логіка все одно та сама: ми читаємо user.name двічі. Між читаннями воно може змінитися (наприклад, хтось присвоїв user.name = null).

Правильна техніка — знову знімок:

class User(var name: String?)

fun printUserNameLength(user: User) {
    val name = user.name
    if (name != null) {
        println(name.length) // ok
    }
}

Це не «костиль», а нормальний стиль: «прочитали один раз → перевірили → використовуємо».

Чому з власним ґетером компілятор особливо обережний

Щоб стало зовсім зрозуміло, чому компілятор не зобов’язаний довіряти властивостям, уявіть, що ґетер справді хитрий:

class FlakyUser {
    val name: String?
        get() = if (System.currentTimeMillis() % 2L == 0L) "Neo" else null
}

fun printFlakyNameLen(u: FlakyUser) {
    if (u.name != null) {
        // Навіть людина не може гарантувати, що другий виклик u.name не дасть null
        println(u.name.length)
    }
}

Тут уже видно, що «перевірив і використовуй» не працює, бо u.name — це фактично функція. Компілятор поводиться строго, щоб однаково безпечно працювати і з простими, і зі «слизькими» випадками.

Nullable + лямбда: значення «вислизає» з-під контролю

Наступний клас ситуацій: ви перевірили x != null, а потім використовуєте x усередині лямбди. Іноді це працює, іноді — ні. Новачкові може здатися, що Kotlin просто знущається.

Чому так? Бо лямбда — це окремий шматок коду, який може виконатися не там і не тоді, де ви очікуєте. Тому компілятор має бути впевнений, що змінна не зміниться до моменту виконання лямбди.

Приклад: припустімо, ми форматуємо виведення через функцію, яка приймає дію.

fun render(block: () -> Unit) {
    block()
}

fun printLenInsideLambda(text: String?) {
    if (text != null) {
        render {
            println(text.length) // інколи тут виникають обмеження smart-cast
        }
    }
}

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

Найнадійніший прийом, який працює майже завжди: зробіть знімок до лямбди.

fun render(block: () -> Unit) {
    block()
}

fun printLenInsideLambda(text: String?) {
    val snapshot = text
    if (snapshot != null) {
        render {
            println(snapshot.length) // ok
        }
    }
}

Smart-cast і try/catch: коли інформація може «скинутися»

Ще один момент, який інколи дивує: ви ніби «вже знали», що змінна не null, але всередині try/catch або після деяких операцій компілятор може повернути вам nullable-тип назад.

Нам зараз не потрібно глибоко занурюватися в деталі, але корисно розуміти принцип. Якщо всередині try ви присвоюєте змінній інше значення (зокрема null), компілятор зобов’язаний переоцінити її тип після цього. Тому стиль «знімок у val» знову працює як універсальна страховка, коли ви хочете користуватися значенням як стабільним.

3. Прийоми переписування коду під smart-cast

Знімок у локальний val

Це базовий прийом майже для всіх ситуацій на кшталт «smart-cast impossible»: прочитали значення рівно один раз — і далі працюємо з ним як із локальним val.

Формула проста:

val snapshot = maybeNull
if (snapshot != null) {
    // використовуємо snapshot як не-null
}

Ця звичка розв’язує одразу дві проблеми: робить код зрозумілішим і прибирає повторні читання або виклики ґетерів.

Guard clause: ранній вихід, щоб далі все стало не-null

Іноді найкращий спосіб «переконати» компілятор — зробити код прямолінійнішим: якщо значення null, ми одразу виходимо. Це називається guard clause (охоронна перевірка). Плюси цього стилю — менше вкладеності й менше «сходинок» із if. І зазвичай компілятору простіше застосувати smart-cast до решти частини функції.

Порівняймо два варіанти.

Варіант із вкладеністю:

fun printUpper(text: String?) {
    if (text != null) {
        println(text.uppercase())
    }
}

Варіант із раннім виходом:

fun printUpper(text: String?) {
    if (text == null) return
    println(text.uppercase()) // text сприймається як String
}

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

requireNotNull / checkNotNull: явне перетворення T? -> T

Коли null означає «це баг / порушення контракту», можна не намагатися «умовити» smart-cast, а прямо сказати: «якщо null — падаємо зі зрозумілим повідомленням». Для цього є requireNotNull і checkNotNull.

Різниця за змістом така: require* — зазвичай про некоректні аргументи або вхідні дані функції, check* — про неправильний стан (інваріант зламано). У межах цієї лекції важливіше інше: обидві функції повертають не-null значення. Тобто це зручний і читабельний спосіб отримати T.

Приклад:

fun tokenLength(token: String?): Int {
    val t = requireNotNull(token) { "Токен має бути наданий" }
    return t.length
}

Тут ми навіть не просимо smart-cast працювати: ми явно зробили «перетворення» з String? у String.

Але важливо не зловживати. Якщо token — користувацьке введення, яке в нормальному сценарії може бути відсутнім, fail-fast може бути надто жорстким: програма просто «впаде» замість того, щоб попросити користувача ввести значення ще раз. Передумови доречні там, де null означає «програміст помилився» або «внутрішній контракт порушено».

4. Практика: команда remove і подвійне читання результату пошуку

Уявімо навчальний CLI-трекер витрат. У нас є репозиторій, який уміє знаходити витрату за id. За контрактом пошуку результат може бути відсутнім, отже, це Expense?.

Дуже проста модель:

data class Expense(val id: Int, val title: String, val amount: Int)

fun findExpenseById(expenses: List<Expense>, id: Int): Expense? {
    return expenses.find { it.id == id }
}

Тепер обробник команди remove у наївному стилі:

fun handleRemove(expenses: MutableList<Expense>, id: Int) {
    if (findExpenseById(expenses, id) != null) {
        // Погано: ми повторно викликаємо пошук і сподіваємося, що результат той самий
        val title = findExpenseById(expenses, id).title
        println("Видалено: $title")
    }
}

Тут проблема навіть не лише у smart-cast — вона ще й логічна: ми двічі шукаємо елемент. Між викликами список міг змінитися, та й просто це зайва робота.

Правильний стиль: один раз знайшли → зберегли → далі працюємо зі «знімком результату».

fun handleRemove(expenses: MutableList<Expense>, id: Int) {
    val found = findExpenseById(expenses, id)
    if (found == null) {
        println("Немає що видаляти: id=$id")
        return
    }

    expenses.remove(found)
    println("Видалено: ${found.title}") // Видалено: ...
}

Зверніть увагу: це одночасно і «snapshot», і guard clause. Такий код майже завжди простіший для компілятора, швидший для програми й зрозуміліший для людини.

5. Шпаргалка: де smart-cast працює, а де ні

Іноді корисно не «зазубрювати правила», а тримати в голові одну думку: smart-cast можливий, коли значення стабільне. Але щоб було простіше звірятися, ось компактна таблиця.

Ситуація в коді Чому компілятор може відмовити Що переписати
Перевірили if (x != null) і далі використовуємо x Зазвичай ok, якщо x — локальний val або параметр Нічого, це «ідеальний» випадок
var x: T? перевірили й використовуємо далі var може змінитися, особливо якщо читається двічі Зробіть val snapshot = x і працюйте зі snapshot
Перевірили obj.prop != null, а потім використовуємо obj.prop.something prop читається двічі, ґетер може повернути інше val p = obj.prop і далі p
Використання всередині лямбди після перевірки Лямбда може виконатися пізніше або в іншому контексті Знімок до лямбди або ?.let { ... }
Складний код, де після перевірки є присвоєння в ту саму змінну Перевірка могла стати неактуальною Guard clause або знімок перед змінами

І так: у Kotlin 2.x частина сценаріїв стала «проходити» краще, ніж раніше (особливо у зв’язці з inline-функціями та за певних умов). Але ви все одно виграєте, якщо пишете код у стилі «одне читання → перевірка → використання».

6. Типові помилки

Помилка № 1: «Я перевірив user.name != null, значить user.name.length точно можна».
Це одна з найпоширеніших пасток, бо на око здається, що все очевидно. Але доступ до властивості — не те саме, що доступ до локальної змінної: це може бути ґетер, повторне читання або зміна стану. Виправлення майже завжди просте: val name = user.name, і далі працюємо з name.

Помилка № 2: «Зараз швиденько поставлю !!, бо я впевнений».
!! — це не розв’язання проблеми smart-cast. Це лише спосіб попросити програму впасти пізніше й голосніше. Якщо ви впевнені, що null тут неможливий за контрактом, краще використати requireNotNull/checkNotNull зі зрозумілим повідомленням. Якщо null можливий — отже, ви маєте обробити цей сценарій (ранній return, ?:, ?.let { ... }), а не перетворювати його на міну в коді.

Помилка № 3: повторні обчислення замість збереження результату.
Коли ви пишете if (find(...) != null) println(find(...).title), ви сподіваєтеся не лише на smart-cast, а й на те, що функція пошуку детермінована й стан не змінюється. Навіть якщо це правда зараз, код стає менш читабельним і може перетворитися на джерело багів під час рефакторингу. Правильний стиль: один раз обчислили → поклали в val found → далі працюємо з ним.

Помилка № 4: «Знімок зробив, але далі все одно використовую початкову змінну».
Іноді розробник пише val snapshot = x, перевіряє snapshot != null, а потім усередині блока знову звертається до x. Це повертає вас до початкової проблеми: x усе ще nullable й потенційно змінюване. Якщо вже зробили знімок — користуйтеся ним послідовно.

Помилка № 5: перетворювати ?.let { ... } на ляльку-вкладеньку з let усередині let.
let чудовий, але якщо ви починаєте будувати «піраміду» з трьох вкладених let, код знову стає важким. У таких випадках краще зробити кілька зрозумілих проміжних val, використати guard clauses або акуратно розбити код на дві-три функції, де кожна працює з уже не-null значеннями.

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