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

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

Kotlin SELF
11 уровень , 4 лекция
Открыта

1. Базовые случаи smart cast

Когда вы только начинаете писать код, кажется, что компилятор — вредный преподаватель: «нельзя вызвать .length, потому что String?». Но на самом деле компилятор умеет быть очень дружелюбным: если вы докажете ему, что значения null нет, он временно будет считать переменную не-null и позволит писать обычный код без ?..

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

Представьте, что String? — это коробка, в которой может лежать строка, а может лежать пустота. Smart cast — это момент, когда вы заглянули в коробку, увидели строку и говорите: «Окей, пока я не закрывал глаза и никто коробку не трогал — будем считать, что строка точно есть».

После if (x != null)

Самый приятный сценарий — вы проверили переменную на null, и внутри ветки if компилятор разрешил обращаться к ней как к обычной, не-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, а дальше работаете с не-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 ценит понятный код и охотно подыгрывает ему.

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