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) { "Token must be provided" }
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("Removed: $title")
}
}
Здесь проблема даже не только в smart-cast — здесь проблема логическая: мы два раза ищем элемент. Между вызовами список мог измениться, да и просто это лишняя работа.
Правильный стиль: один раз нашли → сохранили → дальше работаем со «снимком результата».
fun handleRemove(expenses: MutableList<Expense>, id: Int) {
val found = findExpenseById(expenses, id)
if (found == null) {
println("Nothing to remove: id=$id")
return
}
expenses.remove(found)
println("Removed: ${found.title}") // Removed: ...
}
Обратите внимание: это одновременно и «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 значениями.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ