Null safety: ?., ?:, !!, lateinit и платформенные типы

«Kotlin избавляет от NullPointerException» — фраза, после которой хороший интервьюер обязательно спросит: «а в каких случаях NPE всё-таки прилетит?»

Null safety в Kotlin — это разделение типов на T и T? на уровне системы типов. Компилятор не даёт обратиться к nullable-значению без явной проверки, поэтому большинство NPE ловится ещё до запуска.

Вопрос 1: «Расскажите про операторы null safety»

Что проверяет интервьюер

Не перечисление символов, а умение выбрать оператор осознанно — особенно отношение к !!.

Развёрнутый ответ

ОператорЧто делаетКогда уместен
?.вызов, если не null, иначе выражение равно nullцепочки обращений
?: (Elvis)значение по умолчанию для nullподстановка запасного значения или ранний выход
!!бросает NPE, если nullпочти никогда; только там, где null означает баг в коде
as?безопасное приведение, null вместо ClassCastExceptionработа с типами из внешних источников
?.let { }выполнить блок только для не-null значениякогда нужно тело из нескольких строк
data class User(val profile: Profile?)
data class Profile(val city: String?)

fun cityOf(user: User?): String =
    user?.profile?.city ?: "не указан"

// Elvis отлично сочетается с return и throw
fun requireCity(user: User?): String {
    val city = user?.profile?.city ?: return "неизвестно"
    return city.uppercase()
}

// безопасное приведение
val text = payload as? String ?: "не строка"

Про !! стоит сказать отдельно и правильным тоном: это не «плохо», это явное утверждение разработчика «здесь null невозможен». Если утверждение неверно, приложение падает сразу и в понятном месте, а не через десять экранов. Но в 90 % случаев !! — признак того, что тип стоило спроектировать иначе.

Вопрос 2: «Можно ли всё-таки получить NPE в Kotlin?»

Ответ: да, и способов несколько — этим вопросом отделяют выученное от понятого.

  1. Оператор !! — самый очевидный.
  2. Платформенные типы из Java. Когда Kotlin вызывает Java-код, компилятор не знает, вернётся ли null, и присваивает результату «платформенный тип» String!. Проверок он не требует, но NPE в рантайме возможен. Спасение — аннотации @Nullable/@NonNull в Java-коде и явное объявление типа как nullable на стороне Kotlin.
  3. lateinit до инициализацииUninitializedPropertyAccessException, наследник рантайм-исключения того же класса проблем.
  4. Утечка this из конструктора: открытый метод, вызванный в конструкторе базового класса, отработает раньше, чем инициализируются поля наследника, и увидит их равными null.
// Java-метод, о nullability которого компилятор ничего не знает
val name: String = javaApi.getUserName()   // платформенный тип, проверки нет
println(name.length)                       // NPE, если Java вернула null

// защищаемся явным типом
val safeName: String? = javaApi.getUserName()
println(safeName?.length ?: 0)

Вопрос 3: «lateinit или nullable — что выбрать?»

lateinit var говорит компилятору: «значение появится позже, но к моменту чтения оно точно будет». Это удобно в Android, где binding и внедрённые зависимости появляются не в конструкторе, а в onCreate или через DI.

class MainActivity : AppCompatActivity() {

    @Inject lateinit var analytics: Analytics
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
    }

    private fun log() {
        if (::analytics.isInitialized) analytics.track("open")
    }
}

Ограничения, о которых спрашивают: lateinit нельзя применить к val, к примитивным типам (Int, Boolean) и к nullable-типу. Проверить состояние можно через ::property.isInitialized.

Правило выбора простое: если отсутствие значения — это нормальная ситуация в логике, тип должен быть nullable. Если отсутствие значения означает ошибку в коде (забыли внедрить зависимость, обратились до onCreate), уместен lateinit.

Типичные ошибки кандидатов

  • Расставляют !! «чтобы компилятор замолчал» — и переносят проблему из компиляции в рантайм пользователя.
  • Не знают про платформенные типы и уверенно заявляют, что NPE в Kotlin невозможен.
  • Не могут объяснить, почему smart cast иногда «не срабатывает». Причина: компилятор гарантирует неизменность только для val и локальных переменных. Для var-свойства класса (особенно открытого или из другого модуля) значение теоретически может измениться между проверкой и использованием — поэтому нужна локальная копия или ?.let.
class Screen {
    var title: String? = null

    fun show() {
        if (title != null) {
            // println(title.length)  // ошибка: smart cast невозможен для var-свойства
        }
        title?.let { println(it.length) }   // так работает
    }
}

Как ответить кратко (20–30 секунд)

«Kotlin разделяет T и T? на уровне типов, поэтому большинство NPE ловится компилятором. Основные инструменты: ?. для цепочек, Elvis ?: для значения по умолчанию или раннего выхода, as? для безопасного приведения, ?.let для блока. !! использую редко — это явное утверждение «здесь null невозможен». NPE всё-таки возможен: через !!, через платформенные типы из Java, через обращение к неинициализированному lateinit. Выбор между lateinit и nullable делаю по смыслу: отсутствие значения — нормальная ситуация, значит nullable; признак бага — значит lateinit

Проверьте себя
1. Kotlin вызывает Java-метод String getName(), у которого нет аннотаций nullability. Какой тип получит результат и чем это опасно?
AString? — компилятор потребует проверку, опасности нет
BString — компилятор гарантирует не-null, Java-код проверяется автоматически
Cплатформенный тип String! — проверка не требуется, но NPE в рантайме возможен
DAny — потребуется явное приведение типа
2. Для какого свойства нельзя использовать lateinit?
Avar binding: ActivityMainBinding
Bvar count: Int
Cvar repository: UserRepository
Dvar adapter: ItemsAdapter
3. Почему smart cast не срабатывает для var-свойства класса после проверки на null?
Asmart cast работает только внутри функций-расширений
Bкомпилятор не может гарантировать, что значение не изменится между проверкой и использованием
Cvar-свойства всегда хранятся как платформенные типы
Dsmart cast отключён для всех свойств классов, включая val