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 list | list.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в стектрейсе без вашего кода.
Что почитать дальше
- Классы: data, sealed, object — следующий шаг маршрута.
- Kotlin и Spring Boot — nullability в контроллерах и DTO.
- Валидация запросов — раздел целиком: тип гарантирует, что значение есть, но не что оно осмысленно.