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 значеннями.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ