Null-безопасность
Kotlin убирает NullPointerException не проверками в рантайме, а системой типов: String и String? — два разных типа, и компилятор просто не пропускает значение второго туда, где ожидается первый. Никакой обёртки при этом не выделяется: в байткоде String? — это та же самая ссылка на java.lang.String, разница живёт только на этапе компиляции. Отсюда сразу два следствия. Первое: ? — это не подсказка линтера и не флажок на переменной, а часть типа, поэтому «отключить» проверку нельзя, её можно только удовлетворить. Второе: у ссылочных типов nullability бесплатна, а вот Int? обязан уметь хранить null, чего 32 бита примитива не умеют, — и потому всегда упаковывается в Integer (подробности — в теме Упаковка и равенство).
Инструменты темы делятся ровно по этой логике. ?. и ?: обрабатывают отсутствие: safe call обрывает выражение в null, Элвис подставляет запасное значение или досрочный выход. Smart cast доказывает отсутствие отсутствия — и работает только там, где компилятор может доказать, что значение не изменилось между проверкой и использованием (на var-свойстве класса, на изменяемом свойстве чужого объекта и на свойстве с пользовательским getter он честно отказывает). !! ничего не доказывает и ничего не преобразует — это утверждение, которое бросает NullPointerException, если вы ошиблись. А lateinit вообще выводит поле из-под проверки инициализации, обменивая её на UninitializedPropertyAccessException при раннем чтении.
⚠️ И здесь же — главная честная дыра. Значения, приходящие из Java, несут платформенный тип (T!): у неаннотированного Java-кода нет информации о nullability, компилятор не знает, что перед ним, и потому приостанавливает проверку — разрешает и String, и String?. Проверки в точке вызова нет; null тихо проскакивает в non-null тип и падает позже, у первого же использования, часто в совсем другом файле. Закрывается это аннотациями @Nullable/@NotNull на стороне Java или явным объявлением типа как nullable на границе — см. Взаимодействие с Java. Разбор по слоям — ниже.
Карта темы
- Nullable-типы — почему
StringиString?разные типы, что это стоит в рантайме, чем это отличается отOptionalи откуда берутся платформенные типыT!. - Safe call и оператор Элвиса — как
?.обрывает цепочку вnull, почему правая часть?:вычисляется лениво и как превратить Элвис в досрочный выход. - Not-null assertion —
!!как утверждение, а не преобразование, где он оправдан и почему им нельзя затыкать компилятор. - Умные приведения — что именно компилятор должен доказать, три случая, где он отказывает, и как это чинится локальной
val. - lateinit — свойство без инициализатора, но с non-null типом,
UninitializedPropertyAccessExceptionвместо NPE и чем это отличается отby lazy. - Тип Nothing — нижний тип без единого значения, почему
throw— это выражение и какNothingделает?: throwзаконным.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
Считать ? подсказкой линтера, а не частью типа | Это ошибка компиляции, а не предупреждение: String? не подставится туда, где ждут String |
Считать String и String? одним типом с флагом | Это разные типы; менять их местами компилятор не даст — но в рантайме представление одно и то же |
Думать, что String? компилируется в обёртку вроде Optional | Обёртки нет: та же ссылка, вся разница — на этапе компиляции. А ссылка на сам Optional вдобавок может быть null |
| Ждать, что компилятор считает неаннотированный тип Java nullable | Он даёт платформенный тип T! и не требует проверки — null проскакивает в non-null тип молча |
| Доверять non-null типу Java-getter без проверки на границе | NPE всплывёт далеко от вызова Java — у первого использования, часто в чужом файле |
Считать, что ?. перехватывает исключение | ?. ничего не ловит: он просто не вызывает член и сразу даёт null |
Ждать, что правая часть ?: вычисляется жадно | Она вычисляется, только если левая равна null — дорогой fallback не сработает зря |
Думать, что после обрыва звена остаток a?.b?.c всё же выполнится | Цепочка останавливается на первом null; всё выражение сразу равно null |
Называть !! преобразованием T? в T | Это утверждение: при null он бросает NullPointerException прямо на месте |
Ставить !!, чтобы «согласиться» с компилятором | Вы не убрали null, а лишь перенесли падение в рантайм — и потеряли подсказку компилятора |
Думать, что smart cast работает на любом var, включая свойство класса | Изменяемое свойство недоказуемо стабильно — компилятор откажет, даже если проверка на null рядом |
| Считать, что свойство с пользовательским getter можно smart-cast-ить | Каждый вызов getter может вернуть новое значение — приведение запрещено |
Хвататься за !! вместо копирования значения в локальную val | Гонка превращается в NPE вместо того, чтобы исчезнуть |
Вешать lateinit на val или на примитивный Int | Не компилируется: lateinit разрешён только на non-null var ссылочного типа |
Ждать, что раннее чтение lateinit отдаст null | Оно бросает UninitializedPropertyAccessException — и это не NPE |
Путать lateinit с by lazy | by lazy — это val, который вычисляет себя сам при первом чтении; lateinit ждёт, что значение вам присвоят снаружи |
Путать Nothing с Unit | У Unit есть ровно одно значение; у Nothing — ни одного, выражение такого типа не завершается нормально |
Называть Nothing надтипом любого типа | Наоборот: это подтип любого типа, потому ?: throw и подходит на место значения |
Значение для собеседований
Null-безопасность спрашивают почти на каждом собеседовании по Kotlin, и спрашивают не ради синтаксиса — ?. знают все. Проверяют границу гарантии: понимаете ли вы, что компилятор обещает ровно то, что может доказать, и умеете ли назвать места, где доказательства нет. Кандидат, который на вопрос «где в Kotlin всё же случаются NPE» отвечает «только там, где я сам напишу !!», уже проиграл: он не видит платформенных типов и будет доверять чужому Java API. Кандидат, который начинает с «String и String? — разные типы, а из Java приходит T!, у которого проверка приостановлена», сразу читается как человек, работавший с реальным кодом.
Что обычно проверяют:
- Что именно помечает
?и в какой момент это проверяется. - Чем
?.отличается отtry/catchи почему?:ленив справа. - Что делает
!!на самом деле и где он оправдан. - Условие срабатывания smart cast и три случая, где компилятор отказывает.
- Разницу
lateinitиby lazy, и какое исключение бросает раннее чтение. - Что такое
Nothing, почему это подтип любого типа и как это связано с?: throw. - Где утверждение «в Kotlin нет NPE» ломается — и как закрыть границу с Java.
Типичный неверный ответ: «неаннотированный тип из Java компилятор считает nullable и требует проверки». Ровно наоборот: он даёт платформенный тип T!, для которого проверка приостановлена — вы вольны присвоить его и в String, и в String?, и компилятор промолчит. Именно поэтому такой NPE всплывает не на строке вызова Java, а позже, у первого обращения к значению, — и кандидаты, уверенные в «нулевой» модели, ищут причину не там. Второй классический провал — назвать !! преобразованием: !! ничего не преобразует, он бросает NullPointerException, и единственная его законная роль — сделать падение громким и локальным там, где вы можете доказать non-null, а компилятор — нет.