← назад к разделу

NullPointerException — самая известная ошибка Java: значение оказалось null там, где его никто не ждал, и выяснилось это в проде. Kotlin решает проблему радикально: возможность null записана в типе, и компилятор не даст обратиться к значению, не разобравшись с null. Это главное, ради чего команды переходят на Kotlin.

Обязательно

Два типа вместо одного

var a: String = "text"
// a = null            // не компилируется

var b: String? = "text"
b = null               // ок: тип допускает null

String и String? — разные типы. У non-null типа null не бывает вообще; у nullable (?) — компилятор требует обработать null перед использованием:

// b.length            // не компилируется: b может быть null
val len = if (b != null) b.length else 0   // ок: проверка, дальше smart cast

После явной проверки b != null компилятор сам уточняет тип до String — это тот же smart cast, что и с is.

Работает это, пока b — локальная переменная. Объявите такой же var свойством класса — и компилятор откажется: «Smart cast to 'String' is impossible, because 'b' is a mutable property that could have been changed by this time». Логика у него простая: между проверкой и использованием свойство мог поменять кто угодно, гарантии нет. Выход — снять локальную копию val local = b и проверять её либо написать b?.let { ... } (про let — чуть ниже).

Операторы работы с null

Достать город из адреса клиента — три уровня if (x != null) подряд. Безопасный вызов ?. сворачивает эту лестницу в одну строку: «вызови, если не null, иначе верни null»:

val city: String? = user?.address?.city

Цепочка не упадёт: если любой шаг null — результат null. Сравните с лестницей if-ов или цепочкой Optional.map в Java.

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

val port = config.port ?: 5432
val user = repo.find(id) ?: throw NotFoundException(id)

Справа от ?: может стоять и throw, и return — удобно для ранних выходов. Это не отдельное правило, а следствие типа Nothing: им помечено выражение, которое управление не возвращает вообще. Nothing подходит под любой ожидаемый тип, поэтому компилятор спокойно принимает его на месте «значения справа». Тот же Nothing встретится в TODO() и в sealed-иерархиях.

let для действий над не-null значением:

order.trackingNumber?.let { notifyCarrier(it) }

Блок выполнится только если значение не null; внутри it — уже non-null.

!! — «я уверен, падай если нет». Превращает T? в T, бросая NPE при null. Это осознанный аварийный люк: каждый !! в коде — место, где вы вернули себе java-поведение. В прикладном коде почти всегда есть способ лучше.

Практическая мелочь про !!: исключение бросается прямо на нём, а в сообщении нет имени переменной. Поэтому строка вида a!!.b!!.c превращает разбор лога в гадание — который из двух сработал, по трассе не видно. Если уж ставите — по одному !! на строку.

Чем заменить !!

«Почти всегда есть способ лучше» — фраза, после которой должен идти список. Вот он.

requireNotNull и checkNotNull — то же падение, но с внятным сообщением и с правильным типом исключения:

fun ship(orderId: Long) {
    val order = requireNotNull(repo.find(orderId)) { "заказ $orderId не найден" }
    val address = checkNotNull(order.address) { "у заказа $orderId нет адреса доставки" }
    carrier.send(order, address)
}

Разница между ними — в том, чья это ошибка, и это ровно та же разница, что у require и check для обычных проверок. requireNotNull бросает IllegalArgumentException и означает «аргумент передали неправильно, виноват вызывающий». checkNotNull бросает IllegalStateException и означает «состояние объекта не то, виноваты мы». Обе возвращают значение уже non-null, поэтому подставляются прямо в присваивание. И обе принимают сообщение, которое считается лениво — строка собирается только при падении, поэтому подставлять в него данные не жалко.

Почему это лучше !!: в логе видно, что именно было null и с какими данными, а тип исключения сразу говорит, куда смотреть. !! даёт NullPointerException без имени переменной.

Элвис с ранним выходом — когда падать не нужно:

val order = repo.find(orderId) ?: return
val order2 = repo.find(orderId) ?: throw OrderNotFound(orderId)
val name = user.nickname ?: user.login ?: "гость"

?.let { } ?: run { } — «если не null, то так, иначе эдак». Вторая по частоте идиома после элвиса:

val message = order.trackingNumber?.let { "посылка $it в пути" }
    ?: run {
        log.warn("нет трек-номера у заказа ${order.id}")
        "номер появится позже"
    }

let даёт блок для не-null случая, run — блок для случая null, и оба возвращают значение, поэтому вся конструкция это одно выражение. Ловушка здесь неочевидная и стоит отдельного абзаца: элвис срабатывает не только когда значение было null, но и когда блок let вернул null.

val label = order.trackingNumber?.let { formatOrNull(it) } ?: "нет номера"

Если formatOrNull вернёт null для существующего трек-номера, результатом будет «нет номера» — то есть ветка для отсутствующего значения сработает при наличии значения. Когда такое различие важно, идиому не используют, а пишут обычный if (x == null) ... else ...: он различает эти случаи честно.

Готовые функции против null в строках и коллекциях. Половина проверок вообще не нужна, потому что для частых случаев в стандартной библиотеке есть готовое:

ВместоПишутЧто делает
if (list == null) emptyList() else listlist.orEmpty()пустая коллекция вместо null; есть и для строк, и для отображений, и для массивов
if (s == null \|\| s.isEmpty())s.isNullOrEmpty()null или пустая
if (s == null \|\| s.trim().isEmpty())s.isNullOrBlank()null, пустая или только пробелы
цикл с проверкой каждого элементаitems.filterNotNull()List<T?> превращается в List<T>
if (map == null) ... else map[k]map.orEmpty()[k]то же для отображений
x?.toString() ?: ""x.toString() на nullableу nullable-типов есть своя toString(), дающая строку «null» — знать полезно, применять осторожно

orEmpty() особенно полезна на границе с Java, где коллекция вполне может прийти null: одна вызов превращает «может быть null» в «точно есть, возможно пустая», и дальше весь код работает без проверок.

Откуда NPE всё-таки берётся

Совсем он не исчезает, но список мест короткий и известен заранее:

  • явный throw NullPointerException() — вы попросили сами;
  • !! на значении, которое оказалось null;
  • граница с Java: обращение к значению, пришедшему из Java-кода и оказавшемуся null (об этом ниже), а ещё вызов Kotlin-функции из Java с null в non-null-параметре — туда компилятор вставляет проверку Intrinsics.checkNotNullParameter, и падает именно она;
  • незаконченная инициализация: this «утёк» из конструктора раньше, чем заполнились свойства. Классический случай — конструктор родителя зовёт open-метод, а реализация в наследнике лезет в свойство, которого ещё нет.

Первые два вы пишете руками, вторые два — про границы. Чего в этом списке нет — так это «NPE прилетел из середины бизнес-логики»; ради этого всё и затевалось.

Nullability в коллекциях и дженериках

Здесь Kotlin требует внимания к тому, где именно стоит вопросительный знак, и у Java-разработчика это первое место, где он ошибается.

Три разных типа

val a: List<String?>      // список есть, элементы могут быть null
val b: List<String>?      // самого списка может не быть, элементы — точно строки
val c: List<String?>?     // может не быть ни списка, ни элементов

Читается это справа налево от каждого вопроса: вопрос после String относится к элементу, вопрос после List<...> — к самой коллекции.

Практическая разница в том, где нужна проверка:

a.forEach { println(it?.length) }        // список перебирается, элемент проверяем
b?.forEach { println(it.length) }        // проверяем список, элементы уже non-null
c?.forEach { println(it?.length) }       // проверяем и то и то
val clean: List<String> = a.filterNotNull()
val safe: List<String> = b.orEmpty()

Где это встречается: List<String?> приходит из разбора данных, где элемент может отсутствовать (строка таблицы, ячейка отчёта); List<String>? — из Java-кода и из тех мест, где пустой ответ обозначают отсутствием коллекции. Практическое правило для своего кода: коллекцию наружу не отдают как null — отдают пустую, и тогда List<String>? в своём API не появляется вовсе. А вот List<String?> иногда оправдан: он честно говорит, что дырки в данных бывают.

Отдельная ловушка на границе с Java: сигнатура List<String> из Java-кода без аннотаций приходит как платформенный тип, и там оба уровня не проверены — null может оказаться и вместо списка, и внутри него.

Дженерики допускают null по умолчанию

Ловушка, которая живёт во всех утилитных функциях:

fun <T> firstOrFail(items: List<T>): T = items.firstOrNull() ?: error("пусто")

fun <T> describe(x: T): String = x.toString()      // не скомпилируется

Причина в том, что у необъявленного параметра типа верхняя граница — Any?, а не Any. То есть T уже включает возможность null, и компилятор не даст вызвать метод у x без проверки. Именно поэтому обобщённая функция, написанная «как в Java», не компилируется, и это выглядит как придирка на пустом месте.

Запретить null у параметра типа можно только явной границей:

fun <T : Any> describe(x: T): String = x.toString()   // теперь ок

Три следствия, которые стоит держать в голове:

  • T и T? внутри обобщённой функции — разные типы, даже если на месте T подставят nullable. Возвращать null из функции с результатом T нельзя, а из T? — можно.
  • Границу ставят почти всегда, когда внутри что-то вызывают у значения. Если функция только передаёт значение дальше, граница не нужна и лишняя: <T> позволит вызывать её и с nullable-типом.
  • Ошибка компилятора здесь выглядит не про null. Сообщение про то, что у типа с верхней границей Any? нет такого метода, читается загадочно, пока не знаешь причины. Правило простое: не компилируется обобщённая функция — проверь границу.

lateinit: поля, которые заполнят позже

lateinit var dataSource: DataSource

Для полей, которые инициализируются после конструктора (DI-фреймворком, в setUp теста). Обращение до инициализации даёт не NPE, а UninitializedPropertyAccessException — имя стоит запомнить, потому что именно оно попадётся в логе. Проверить состояние можно явно: ::dataSource.isInitialized.

Ограничений у lateinit три, и все три встретятся в первый же день. Он бывает только у var. Он не работает с nullable-типом: lateinit var x: String? не скомпилируется — для «может быть null» есть сам ?. И он не работает с примитивными типами — Int, Long, Double и прочими. В Spring-коде на Kotlin lateinit встречается, но конструкторная инъекция делает его почти ненужным.

Platform types: граница с Java

Java-типы не знают про nullability, поэтому значение из Java-кода приходит в Kotlin как platform type (String! в сообщениях компилятора): компилятор не проверяет его на null — ответственность на вас, как в Java.

val name = javaUser.getName()   // тип String! — может быть null, компилятор молчит

Что с этим делать:

  • сразу фиксировать тип явно: val name: String? = javaUser.getName() — и дальше компилятор следит;
  • в своём Java-коде ставить аннотации @Nullable/@NotNull — Kotlin их читает и превращает platform type в честный nullable/non-null;
  • у Spring и популярных библиотек такие аннотации уже расставлены — их API приходит в Kotlin с правильной nullability. Одно условие: чтобы компилятор считал нарушение ошибкой, а не предупреждением, нужен флаг -Xjsr305=strict. Проекты со start.spring.io приходят с ним из коробки; в сборку, собранную руками, флаг придётся дописать — как именно, показано в статье про Spring.

Что будет, если зафиксировать тип неправильно

Совет «фиксируйте тип явно» не закончен без второй половины: а если зафиксировали как non-null, а оттуда пришёл null?

val name: String = javaUser.getName()   // объявили non-null
println(name.length)

Ответ: упадёт, причём в неожиданном месте. Компилятор Kotlin вставляет проверку в точке присваивания — там, где платформенный тип превращается в объявленный non-null. Падает не name.length, а сама строка присваивания, и исключение это NullPointerException с сообщением про то, что значение не может быть null.

Тот же механизм работает на входе в функции. Каждая публичная функция с non-null параметрами получает от компилятора проверки этих параметров в самом начале тела:

fun greet(name: String) = "привет, $name"

Из Kotlin вызвать её с null невозможно — не скомпилируется. А из Java, из отражения, из библиотеки, которая создаёт объекты в обход конструктора (сериализаторы, отображение строк базы данных), null прилетает свободно — и падает вставленная проверка. Отсюда и стектрейс, в котором нет вашего кода: верхняя строка указывает на служебный метод проверки, следующая — на первую строку greet, а виноват вызывающий, которого в Kotlin-коде нет вовсе.

Как это читать и что с этим делать:

  • Увидели NullPointerException с упоминанием параметра и служебного метода проверки — ищите вызывающего за пределами Kotlin. Это не ошибка в теле функции, это нарушенный контракт на входе.
  • Проверки стоят только на публичной границе. Внутренние и приватные функции их не получают: компилятор считает, что внутрь модуля null попасть не может, потому что за этим следит он сам.
  • Поэтому на границе с Java тип фиксируют как nullable, а не как non-null, если нет гарантии. val name: String? = javaUser.getName() плюс элвис или requireNotNull с понятным сообщением — это и есть правильная фиксация. Объявить non-null «потому что так удобнее» означает обменять предупреждение компилятора на падение в рантайме с невнятным стектрейсом.
  • Сериализация — отдельный источник. Библиотека, создающая объект без вызова конструктора, обходит и проверки: поле, объявленное non-null, может оказаться null, и обнаружится это при первом обращении, а не при создании. Поэтому у типов, приходящих извне, nullability описывают честно, а обязательность проверяют валидацией.

Правило: чем меньше platform types гуляет по коду, тем больше пользы от null-safety. На границе с Java — фиксируйте типы явно. И выбирайте между ? и падением в этом же месте сознательно, а не по тому, какой вариант короче.

Дополнительно: при первом чтении можно пропустить

Глубже: обработка ошибок по-котлински: исключения, Result и sealed-результатрасширенное

Null-safety закрывает один класс ошибок; остальные в Kotlin обрабатывают тремя способами, и выбор между ними это мост к разделу про обработку ошибок на сайте.

Исключения остались, но все непроверяемые: компилятор не заставит ловить IOException, и это осознанно. Для ошибок инфраструктуры (база недоступна, партнёр не ответил) и для нарушений, после которых продолжать нельзя, исключения остаются правильным инструментом, а границы, где они превращаются в ответ клиенту, те же, что в Java: обработчик на краю, о чём статьи про модель ошибок. require, check и error дают короткие проверки предусловий с понятными сообщениями.

Result и runCatching. runCatching { parse(input) } возвращает Result<T>, обёртку с успехом или исключением, и цепочки map, getOrElse, recover читаются как обработка значения. Две ловушки. runCatching ловит всё, включая CancellationException корутин, и проглоченная отмена это зависшая корутина, о чём статья про корутины; в suspend-коде после runCatching отмену пробрасывают явно. И Result как возвращаемый тип публичного API смешивает ожидаемые исходы и аварии в одном мешке: вызывающий не знает, каких ошибок ждать.

Sealed-результат для ожидаемых исходов домена, о котором раздел в статье про классы: PaymentResult с Success, Declined, GatewayTimeout заставляет when обработать каждый случай, и компилятор напомнит о новом варианте. Это ответ на вопрос «исключение или значение»: то, что бизнес считает нормальным исходом (отказ платежа), это вариант sealed; то, что бизнес не ожидает (база упала), это исключение.

Правило для сервиса на Spring: доменные исходы через sealed, инфраструктура через исключения, Result только внутри функции для локальной обработки, и одна точка перевода всего этого в HTTP-ответ на границе.

Коротко

  • Возможность null — часть типа: String не бывает null, String? — требует обработки перед использованием. Инструменты: ?. — безопасный вызов цепочкой, ?: — значение по умолчанию или ранний выход, let — действие над не-null, !! — аварийный люк с NPE.
  • После проверки != null работает smart cast — явные касты не нужны; на var-свойстве класса он не сработает, там нужна локальная копия или let.
  • Значения из Java приходят как platform types — компилятор их не проверяет; фиксируйте тип явно или размечайте Java-код аннотациями nullability.
  • NPE не исчезает совсем: остаются явный throw NullPointerException(), !!, граница с Java и обращение к this, утёкшему из конструктора. Из середины бизнес-логики он уходит.
  • Ошибки: исключения непроверяемые и остаются для инфраструктуры, sealed-результат для ожидаемых исходов домена с исчерпывающим when, runCatching только локально и с пробросом CancellationException.
  • Вместо !! — requireNotNull (виноват вызывающий) и checkNotNull (виновато состояние) с ленивым сообщением, элвис с return или throw, а для коллекций и строк orEmpty(), isNullOrBlank(), filterNotNull().
  • Идиома ?.let { } ?: run { } покрывает «если не null — так, иначе эдак», но элвис срабатывает и когда блок let вернул null: где это различие важно, пишут обычный if.
  • В коллекциях вопрос относится к тому, после чего стоит: List<String?> — дырки в элементах, List<String>? — может не быть списка; своё API отдаёт пустую коллекцию, а не null.
  • У параметра типа верхняя граница Any?, поэтому fun <T> f(x: T) допускает null и не даёт вызвать методы; запрет — только явной границей <T : Any>.
  • Non-null проверяется в рантайме на публичной границе: компилятор вставляет проверки параметров, поэтому null из Java или из сериализации даёт NullPointerException в стектрейсе без вашего кода.

Что почитать дальше