Введение: «а если всё-таки NPE?»

«Kotlin — null safe, значит NPE в нём невозможны?» — вопрос, который звучит на каждом втором собеседовании по Kotlin. И правильный ответ на него — «нет, не невозможны». Отсюда и начинается настоящий разбор.

Null safety — одна из главных причин перехода на Kotlin: вместо миллиардной ошибки в рантайме проверка переезжает в компилятор. Но у фичи есть границы: оператор !!, платформенные типы из Java-кода и проблемы инициализации — всё это реальные источники NPE. Интервьюер, который копнул глубже первого вопроса, обязательно до них доберётся.

Разберём по порядку: типы, инструменты, границы null safety и формулировки для уверенного ответа.

Nullable и non-nullable типы

Основа null safety — система типов. В Java любая ссылка может быть null. В Kotlin тип делится на два: String — не может быть null, String? — может:

Example.kt
var a: String = "abc"
// a = null — ошибка компиляции: null не может быть значением типа String

var b: String? = "abc"
b = null // ок

Ключевое слово — гарантия компилятора. Пока переменная объявлена как String, компилятор следит, что ей никогда не присвоят null, и позволяет обращаться к её членам напрямую — a.length безопасен. А вот b.length уже не скомпилируется: компилятор заставит обработать nullable-значение.

Именно это отличие от Java — главный пункт ответа: в Kotlin nullability — часть типа, а не случайность рантайма. Проверка происходит на этапе компиляции, а не в момент падения приложения. Официальная документация по null safety прямо называет это способом «поймать проблемы, связанные с null, на этапе компиляции, а не в рантайме».

Следствие, о котором стоит сказать вслух: nullable-тип — это не «какой-то тип с вопросом», а явная декларация намерения. String? говорит читателю кода: здесь может быть null, и это предусмотрено. Non-null тип говорит: значение гарантировано.

Инструменты: ?., ?: и !!

Когда переменная nullable, её нужно разыменовать безопасно. Первый способ — проверка через if с умным приведением типов:

Example.kt
val b: String? = null
val l = if (b != null) b.length else -1

После проверки b != null компилятор сам приводит String? к String — это называется smart cast. Но для коротких выражений проверка громоздкая, поэтому есть операторы.

Безопасный вызов ?. — вместо NPE возвращает null:

Example.kt
val a: String? = "Kotlin"
val b: String? = null

println(a?.length) // 6
println(b?.length) // null, а не NPE

Тип результата b?.length — Int?, то есть тоже nullable. ?. отлично работает в цепочках: bob?.department?.head?.name вернёт null, если любой из элементов цепочки равен null.

Элвис-оператор ?: — значение по умолчанию, если слева null:

Example.kt
val l = b?.length ?: 0

Правая часть вычисляется только если слева null. А поскольку в Kotlin throw и return — выражения, их можно ставить справа:

Example.kt
fun findName(): String? {
    val name = getParent() ?: return null
    val title = getTitle() ?: throw IllegalArgumentException("title expected")
    return title
}

Это идиоматичный способ проверки аргументов — элвис с throw работает как валидатор.

Оператор !! — снять nullable принудительно:

Example.kt
val text: String? = null
val length = text!!.length // NullPointerException

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

Частая ошибка кандидата: «ну у нас в проекте всё с !!». Это красный флаг для интервьюера: значит, null safety используется как декорация, а не как инструмент. Правильная идиома — ?. и ?: везде, где значение может быть null, и явная обработка на границе системы.

Smart cast, let и безопасные касты

Кроме операторов, в Kotlin есть ещё три инструмента, которые любят проверять.

Smart cast — компилятор сам приводит тип там, где null-проверка очевидна:

Example.kt
if (b != null && b.length > 0) {
    println("Длина: ${b.length}") // b уже String
}

Условие может быть сложным, но компилятор обязан убедиться, что значение не изменилось между проверкой и использованием. Для var, который меняется из другого места, smart cast не сработает — и это тоже вопрос с собеседований.

let — выполнить блок только если значение не null:

Example.kt
user?.let { user ->
    showProfile(user) // выполнится только если user != null
}

Комбинация ?.let — идиома «если не null, сделай что-то» без вложенных проверок.

Безопасный каст as? — вместо ClassCastException возвращает null:

Example.kt
val a: Any = "Hello"
val aInt: Int? = a as? Int // null, исключения нет
val aString: String? = a as? String // "Hello"

Для коллекций есть filterNotNull() — отфильтровать List<String?> до List<String> одним вызовом.

Когда NPE всё-таки случится

Самая важная часть для собеседования: null safety не отменяет NPE, а сужает его причины до списка. Документация перечисляет их явно:

  • Оператор !! — принудительное снятие nullability на null-значении.
  • Явный throw NullPointerException() — ну тут всё честно.
  • Проблемы инициализации — когда объект используется до полной инициализации: «утечка this» из конструктора или вызов open-метода из конструктора суперкласса, когда состояние наследника ещё не готово.
  • Java-интероп — доступ к членам null-значения платформенного типа; добавление null в generic-коллекции; другие внешние причины.

К этому списку примыкает lateinit. Свойство с lateinit объявляется non-null, но инициализируется позже. Если прочитать его до инициализации — будет не NPE, а отдельное UninitializedPropertyAccessException. Это тоже спрашивают, и разницу стоит проговаривать точно.

  • !! на null-значении — NPE по вашему явному решению.
  • Платформенный тип из Java-кода вернул null — компилятор не мог это проверить.
  • «Утечка this» или open-метод в конструкторе суперкласса — проблема инициализации.
  • lateinit прочитан до инициализации — UninitializedPropertyAccessException.

Платформенные типы: граница с Java

Отдельный разговор — интероп. Java-код не знает про nullability, поэтому при вызове из Kotlin типы без аннотаций становятся платформенными: String! — «String, но я не знаю, может ли быть null».

Example.kt
// Java: public String getName() — может вернуть null
val name: String = javaObject.name // компилируется! но если null — NPE в рантайме

Компилятор позволяет присвоить платформенное значение и в String, и в String? — выбор за вами. Ошибка возникает, когда выбрали String, а Java вернул null: NPE произойдёт уже в Kotlin-коде, при обращении к значению.

Правило для прод-кода: проверять nullability на границе — там, где Kotlin вызывает Java. Аннотации вроде @Nullable/@NotNull (или @NonNull из androidx) частично решают проблему: типы с аннотациями компилятор трактует правильно, а вот без них — только платформенный !. Плюс generics: Java может положить null в MutableList<String>, хотя для Kotlin это non-null тип — отсюда ещё один источник проблем с дженериками, который перечислен в документации.

Как отвечать про платформенные типы

«Платформенный тип — это тип из Java без информации о nullability. Компилятор не проверяет его, поэтому ошибку можно допустить на нашей стороне: если Java вернул null, а мы объявили non-null тип, NPE случится в рантайме. Поэтому на границе с Java я проверяю значения явно».

Итоги

Коротко по главному:

  • Система типов — String не может быть null, String? может; проверка на этапе компиляции.
  • Инструменты — ?. для безопасного доступа, ?: для значения по умолчанию, !! для явного снятия, as? для безопасных кастов, smart cast и let для работы с nullable.
  • Границы — NPE возможен: !!, платформенные типы из Java, проблемы инициализации и явный throw.

Главное, что нужно запомнить

Null safety не убирает NPE — он сужает его до четырёх задокументированных причин. Уверенно называете их и умеете работать с платформенными типами — тема закрыта.

Если на собеседовании вопросы про null safety заканчиваются на «ну, это же оператор с вопросиком», а про платформенные типы вы слышите впервые — не откладывайте разбор на потом. Напишите в Telegram, разберём вашу ситуацию и составим план, как закрыть пробелы в Kotlin до следующего интервью.