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) { "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 значениями.

1
Задача
Kotlin SELF, 41 уровень, 2 лекция
Недоступна
Статус сообщения
Статус сообщения
1
Задача
Kotlin SELF, 41 уровень, 2 лекция
Недоступна
Бейдж участника
Бейдж участника
1
Задача
Kotlin SELF, 41 уровень, 2 лекция
Недоступна
Бирка отчёта
Бирка отчёта
1
Задача
Kotlin SELF, 41 уровень, 2 лекция
Недоступна
Ключ заголовка
Ключ заголовка
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ