Null-безопасность: ошибка на миллиард долларов
Зачем это нужно
Открой любой чат разработчиков Android — и рано или поздно там появится скриншот с одной и той же строчкой:
java.lang.NullPointerException
Это самая частая ошибка в истории Java. Не самая сложная, не самая интересная — самая частая. Приложение падает не потому, что программист написал плохой алгоритм, а потому, что в какой-то переменной вместо значения оказалась пустота, и никто этого не проверил.
Kotlin создавался людьми, которые устали чинить именно эту ошибку. Поэтому в нём система типов знает про пустоту. Не библиотека, не соглашение в команде, не «мы договорились всегда проверять» — сам компилятор.
В этой главе мы разберём, как это устроено: почему String и String? — два разных типа, что означает каждый из знаков ?., ?:, !!, где безопасность заканчивается и почему NullPointerException в Kotlin всё-таки существует — но получить его случайно уже нельзя.
Все примеры этой главы прогнаны на настоящем компиляторе Kotlin 2.4.10. Тексты ошибок и вывод программ приведены дословно — ровно так, как их печатает компилятор. Ни одна строчка вывода не придумана.
- Зачем это нужно
- Ошибка на миллиард долларов
- Два типа вместо одного
- Что компилятор просто не даст написать
- Умное приведение: проверил — пользуйся
- Когда умное приведение отказывает
- Безопасный вызов
?. - Цепочки: где обрывается
?. - Элвис
?:— запасное значение - Элвис с
returnиthrow - Оператор
!!и его пустое сообщение - Пять способов получить NPE в Kotlin
?.letи когда он лучше проверкиlateinit: обещание заполнить позжеby lazy: посчитать при первом обращении- Платформенные типы: граница с Java
as?— приведение типа без падения- Коллекции:
List<String?>иList<String>? - Инструменты для коллекций с дырками
Map: нет ключа иnull-значениеrequireNotNull,checkNotNull,errorBoolean?— ловушка в условии- Дженерики и null
- Граница с внешним миром
- Ошибки компилятора этой главы
- Тренажёр
- Экзамен главы
- Куда дальше: проверенные ресурсы
Ошибка на миллиард долларов
История у проблемы конкретная, с именем и датой.
В 1965 году британский учёный Тони Хоар (Sir Charles Antony Richard Hoare) проектировал систему типов для языка ALGOL W. Ему нужно было как-то обозначить «ссылка, которая пока никуда не указывает». Он добавил специальное значение — null. Оно подходило к любому типу и означало «здесь ничего нет».
Решение было простым в реализации: компилятору достаточно проверить одно значение. Хоар потом объяснял, что взял его именно поэтому — потому что так было легче написать.
Спустя сорок с лишним лет, в марте 2009 года, на конференции QCon в Лондоне, Хоар вышел с докладом под названием Null References: The Billion Dollar Mistake — «Пустые ссылки: ошибка на миллиард долларов». Он публично назвал своё изобретение ошибкой и оценил ущерб от неё примерно в миллиард долларов — столько, по его прикидке, стоили миру за эти десятилетия сбои, уязвимости и падения из-за пустых ссылок.
Стоит понимать, кто это говорил. Хоар — не случайный человек в программировании:
- в 1959 году, будучи студентом в Московском государственном университете, придумал алгоритм сортировки Quicksort — он до сих пор лежит в основе стандартных библиотек;
- получил премию Тьюринга в 1980 году — высшую награду в информатике;
- разработал логику Хоара, язык CSP и ещё десяток вещей, которые сегодня считаются фундаментом.
Тони Хоар родился 11 января 1934 года и умер 5 марта 2026 года в Кембридже.
А идея, которую он назвал своей ошибкой, к тому моменту уже была исправлена — в языках, спроектированных позже. Kotlin один из них.
Слово «ошибка на миллиард долларов» — не преувеличение ради красного словца и не маркетинг. Это самооценка автора той самой идеи, произнесённая публично с трибуны конференции. Когда изобретатель механизма спустя сорок лет извиняется за него — это довольно сильный аргумент в пользу того, что механизм стоило переделать.
Два типа вместо одного
Вот главная идея, из которой следует всё остальное в главе.
В Java тип String означает: «здесь лежит строка или пустота». Одно и то же слово, два разных смысла, и различить их можно только в момент выполнения — обычно падением.
В Kotlin это два разных типа:
| Тип | Что внутри | Можно ли сразу .length |
|---|---|---|
String | всегда строка | да |
String? | строка или null | нет, компилятор не даст |
Знак вопроса — часть имени типа, а не украшение. String? читается как «строка-или-ничего».
Проверим самое базовое: положить null туда, где ждут обычную строку.
fun main() {
val nick: String = null
println(nick)
}
Программа даже не запустится. Компилятор говорит:
Null cannot be a value of a non-null type 'String'.
Дословно: «null не может быть значением non-null типа String». Обрати внимание на слово non-null — так называется обычный тип без вопроса. Kotlin считает его типом-без-пустоты и следит за этим.
Это не ошибка выполнения. Это ошибка компиляции — приложение вообще не будет собрано. Разница огромная: в Java такой код спокойно собрался бы и упал через неделю у пользователя.
Обратное направление разрешено и очень удобно: обычную строку можно передать туда, где ждут String?. Логика простая — «строка» это частный случай «строки-или-ничего». А вот назад, из String? в String, просто так не пройти: нужна проверка.
Что компилятор просто не даст написать
Второй базовый запрет — обратиться к содержимому nullable-значения напрямую.
fun main() {
val nick: String? = "olegg"
println(nick.length)
}
Внутри nick лежит вполне настоящая строка. Мы это видим глазами. Компилятор всё равно отказывается:
Only safe (?.) or non-null asserted (!!.) calls are allowed
on a nullable receiver of type 'String?'.
Переведём дословно: «На nullable-получателе типа String? разрешены только безопасные (?.) или non-null-утверждённые (!!.) вызовы».
Здесь спрятаны сразу три полезных слова:
- receiver (получатель) — то, что стоит слева от точки. В
nick.lengthполучатель — этоnick. - safe call (безопасный вызов) — оператор
?., о нём чуть ниже. - non-null asserted (утверждение непустоты) — оператор
!!, о нём тоже.
Компилятор буквально перечисляет, чем можно заменить точку. Это очень хорошее сообщение об ошибке: оно не только говорит «нельзя», но и подсказывает два выхода.
И заметь важное: компилятору всё равно, что внутри лежит строка. Он рассуждает не о значении, а о типе. Тип разрешает пустоту — значит, случай пустоты надо обработать. Даже если сегодня она никогда не наступает.
Когда впервые видишь это сообщение, первый порыв — поставить !! и идти дальше: строчка компилируется, ошибка исчезает. Это худшее, что можно сделать. Ниже мы разберём, почему !! — не решение проблемы, а способ отложить её до встречи с пользователем.
Умное приведение: проверил — пользуйся
Самый естественный способ работать с пустотой — обычная проверка через if. И тут Kotlin делает то, чего нет в Java.
fun main() {
val nick: String? = "olegg"
if (nick != null) println("длина: " + nick.length)
val empty: String? = null
if (empty != null) println("не напечатается") else println("empty равен null")
}
Вывод:
длина: 5
empty равен null
Смотри на строку if (nick != null) println(nick.length). Внутри if мы обратились к .length напрямую — без всяких вопросов и восклицаний. И компилятор не возразил.
Это называется умное приведение (smart cast). Компилятор рассуждает так: «внутри этой ветки nick точно не null — я сам проверил условие. Значит, здесь его тип уже не String?, а String». И дальше разрешает всё, что разрешено обычной строке.
В Java пришлось бы писать проверку, а потом всё равно обращаться к переменной прежнего типа — язык не запоминал факт проверки. Kotlin запоминает.
Умное приведение работает не только с if:
fun describe(v: Any?): String {
if (v == null) return "пусто"
// сюда управление доходит, только если v не null
return when (v) {
is Int -> "число " + (v + 1)
is String -> "строка длиной " + v.length
else -> "что-то ещё: " + v
}
}
fun main() {
println(describe(null))
println(describe(41))
println(describe("olegg"))
println(describe(3.14))
}
Вывод:
пусто
число 42
строка длиной 5
что-то ещё: 3.14
Здесь два умных приведения подряд. Сначала if (v == null) return — после этой строки компилятор знает, что v не пустой. Потом is Int — внутри ветки v уже не Any, а Int, поэтому v + 1 компилируется без всякого приведения типа.
Такой приём — проверить пустоту и сразу выйти — называют ранним возвратом. Он делает код плоским: нет вложенных if на пять уровней вглубь.
Когда умное приведение отказывает
А теперь случай, на котором спотыкаются все. Возьмём тот же самый if, но проверять будем не локальную переменную, а свойство класса.
class Wallet(var nick: String?)
fun main() {
val w = Wallet("olegg")
if (w.nick != null) println(w.nick.length)
}
Не компилируется:
Smart cast to 'String' is impossible, because 'nick'
is a mutable property that could be mutated concurrently.
«Умное приведение к String невозможно, потому что nick — изменяемое свойство, которое может быть изменено одновременно».
Разберём причину, она честная. nick объявлен через var — значит, его можно менять. Между строкой проверки и строкой использования другой поток мог присвоить туда null. Компилятор не может доказать, что этого не произойдёт, поэтому отказывается ручаться.
Умное приведение не работает в трёх типичных случаях:
- свойство объявлено через
var— как в примере выше; - свойство объявлено в другом модуле — компилятор не видит его реализацию целиком;
- свойство с пользовательским геттером — каждое обращение может вернуть новое значение.
Лечится это одной строкой — скопировать значение в локальную val:
class Wallet(var nick: String?)
fun main() {
val w = Wallet("olegg")
val nick = w.nick // копия здесь и сейчас
if (nick != null) println("длина: " + nick.length)
w.nick = null
val again = w.nick
println("после обнуления: " + (again?.length ?: 0))
}
Вывод:
длина: 5
после обнуления: 0
Локальная val не может измениться после присваивания — ни своим кодом, ни чужим потоком. Поэтому компилятор спокойно делает умное приведение.
Запомни этот приём, он понадобится в первый же рабочий день: увидел «Smart cast is impossible» — заведи локальную val из проверяемого свойства и работай с ней. Это не костыль и не обход проверки, а ровно то, чего компилятор просит: зафиксировать значение, которое ты проверяешь.
Безопасный вызов ?.
Оператор ?. — самый частый в главе. Правило у него одно:
Если слева
null— весь вызов возвращаетnull, а то, что справа, не выполняется вообще.
fun main() {
val a: String? = "olegg"
val b: String? = null
println("a?.length = " + a?.length)
println("b?.length = " + b?.length)
println("тип обоих выражений — Int?, поэтому во втором случае печатается слово null")
}
Вывод:
a?.length = 5
b?.length = null
тип обоих выражений — Int?, поэтому во втором случае печатается слово null
Обрати внимание на тип результата. a.length у обычной строки дал бы Int. А a?.length даёт Int? — потому что результат может оказаться пустым. Знак вопроса не исчезает после безопасного вызова, он переползает на результат.
Это очень важно и часто удивляет: ?. не «убирает» пустоту, он её переносит дальше по цепочке. Убирает её только элвис или проверка.
Цепочки: где обрывается ?.
Настоящая польза ?. видна на цепочках. Соберём три объекта, у которых пустота спрятана на разной глубине.
class City(val name: String?)
class Bank(val city: City?)
class Client(val bank: Bank?)
fun main() {
val a = Client(Bank(City("Самара")))
val b = Client(Bank(City(null)))
val c = Client(null)
println("a: " + a.bank?.city?.name?.length)
println("b: " + b.bank?.city?.name?.length)
println("c: " + c.bank?.city?.name?.length)
}
Вывод:
a: 6
b: null
c: null
Три случая, три разных места обрыва:
- a — все звенья на месте, цепочка дошла до конца, получили длину слова «Самара» — 6.
- b — клиент есть, банк есть, город есть, а вот
nameвнутри города пустой. Цепочка оборвалась на последнем звене. - c — банка нет вообще. Цепочка оборвалась на первом звене, и остальные три обращения даже не выполнялись.
Вот это последнее — ключевое свойство ?., которое стоит проговорить отдельно. Как только одно звено оказалось пустым, всё оставшееся выражение не вычисляется. Не «вычисляется и даёт null», а именно не вычисляется. Если бы там был вызов функции, которая пишет в базу, — она бы не сработала.
Без ?. тот же код на Java выглядел бы так:
if (client != null) {
if (client.getBank() != null) {
if (client.getBank().getCity() != null) {
if (client.getBank().getCity().getName() != null) {
length = client.getBank().getCity().getName().length();
}
}
}
}
Четыре уровня вложенности, каждый геттер вызывается по несколько раз. В Kotlin — одна строка.
Элвис ?: — запасное значение
Оператор ?: официально называется элвис-оператором. Название шуточное: если посмотреть на знаки боком, получается смайлик с причёской — отсылка к Элвису Пресли. Но работа у него совершенно серьёзная.
Если слева не
null— берём левое. Иначе берём правое.
fun nameOf(n: String?): String = n ?: "аноним"
fun main() {
println(nameOf(null))
println(nameOf("olegg"))
val len: Int = nameOf(null).length
println("тип результата уже String, а не String?: длина " + len)
}
Вывод:
аноним
olegg
тип результата уже String, а не String?: длина 6
Смотри на объявление функции: она возвращает String — без вопроса. Хотя на вход принимает String?. Элвис — это и есть та операция, которая снимает вопрос: после него пустоты в результате быть не может, потому что мы явно сказали, чем её заменить.
Именно поэтому связка ?. плюс ?: встречается в коде постоянно:
val length = user?.name?.length ?: 0
Читается слева направо одним предложением: «длина имени пользователя, а если чего-то из этого нет — ноль».
Важная деталь про порядок вычисления: правая часть элвиса ленивая.
fun expensive(): String {
println(" (дорогая функция посчиталась)")
return "запасное"
}
fun main() {
val have: String? = "есть"
val none: String? = null
println("левая часть не null: " + (have ?: expensive()))
println("левая часть null: " + (none ?: expensive()))
}
Вывод:
левая часть не null: есть
(дорогая функция посчиталась)
левая часть null: запасное
Строчка «дорогая функция посчиталась» напечаталась один раз, хотя expensive() написана в коде дважды. В первом случае левая часть оказалась непустой — и правая просто не вычислялась.
Это значит, что справа от элвиса можно спокойно ставить тяжёлые вещи: запрос в базу, чтение файла, расчёт. Они сработают только тогда, когда действительно понадобятся.
Элвис с return и throw
У элвиса есть приём, который отличает новичка от человека, писавшего на Kotlin: справа от него можно поставить не значение, а выход из функции.
fun lengthOr(n: String?): Int {
val safe = n ?: return -1
return safe.length
}
fun mustHave(n: String?): String = n ?: throw IllegalArgumentException("имя обязательно")
fun main() {
println(lengthOr(null))
println(lengthOr("olegg"))
try {
mustHave(null)
} catch (e: Exception) {
println(e::class.simpleName + ": " + e.message)
}
}
Вывод:
-1
5
IllegalArgumentException: имя обязательно
Как это вообще компилируется? В Kotlin return и throw — выражения типа Nothing. Nothing — специальный тип «сюда управление не дойдёт»; он считается подтипом любого типа, поэтому его разрешено поставить в любое место, где ждут значение.
Практический смысл — в строке:
val safe = n ?: return -1
После неё переменная safe имеет тип String без вопроса. Дальше по функции про пустоту можно вообще не думать: если бы она была, мы бы сюда не дошли.
Это самый аккуратный способ проверять входные данные в начале функции. Три-четыре такие строки подряд — и весь остальной код работает с гарантированно валидными значениями.
| Форма | Когда применять |
|---|---|
?: значение | есть разумное значение по умолчанию: 0, "", emptyList(), «аноним» |
?: return что-то | функция не может работать без этого значения, но падать не должна |
?: throw Исключение(...) | пустота здесь означает ошибку в программе, дальше идти нельзя |
?: error("текст") | то же, что throw IllegalStateException("текст"), только короче |
Правило выбора простое: если пустота — нормальная ситуация, ставь значение. Если пустота означает, что кто-то нарушил договорённость, — бросай исключение и делай это как можно раньше, у самой границы.
Оператор !! и его пустое сообщение
!! читается «not-null assertion» — утверждение непустоты. Смысл: «я, программист, ручаюсь, что здесь не пусто. Если ошибся — роняй программу».
fun main() {
val nick: String? = null
try {
println(nick!!.length)
} catch (e: NullPointerException) {
println("класс: " + e::class.qualifiedName)
println("сообщение: " + e.message)
}
}
Вывод:
класс: java.lang.NullPointerException
сообщение: null
Посмотри на вторую строку внимательно. Сообщение исключения — null. То есть его нет вообще. Никакого текста, никакой подсказки о том, что именно оказалось пустым.
Это и есть главная беда !!. Когда такое падение прилетит из продакшена, у тебя на руках будет только номер строки в стектрейсе — и всё. Если в строке два !!, придётся гадать, какой из них сработал.
Для сравнения — вот что печатает та же JVM, когда null приходит естественным путём, без !!:
Cannot invoke "String.length()" because "missing" is null
Тут есть и метод, и имя переменной. Это так называемые helpful NullPointerException, они включены в JVM по умолчанию начиная с Java 15. И !! их обнуляет: оператор бросает исключение сам, руками, без всякого текста.
Практическое правило по !!: каждый такой оператор в коде — это место, где ты отключил проверку компилятора. Иногда это оправдано. Но если !! появился потому, что «иначе не компилировалось» — это не решение, а отложенное падение. На ревью такую строку почти всегда просят переписать через ?: или requireNotNull с внятным текстом.
Когда !! всё-таки уместен:
- ты только что сам положил значение и знаешь наверняка, что оно там;
- значение гарантировано жизненным циклом фреймворка (например, вьюха после
onCreateView), и переписывать архитектуру ради одной строки не хочется; - быстрый прототип, который живёт один вечер.
Во всех этих случаях лучше вместо !! написать requireNotNull(x) { "почему тут не должно быть пусто" } — падение будет тем же, но с объяснением.
Пять способов получить NPE в Kotlin
Часто говорят «в Kotlin нет NullPointerException». Это неправда, и лучше знать точную формулировку: в Kotlin нельзя получить NPE случайно. Каждый способ требует, чтобы ты что-то написал явно.
Проверим все пять на живом компиляторе.
class Holder { lateinit var v: String; fun read() = v.length }
open class Base { open val len: Int = 0; init { compute() }; open fun compute() {} }
class Leaky : Base() {
private val text: String? = null
override val len: Int get() = text!!.length
override fun compute() {}
}
fun main() {
val results = mutableListOf<String>()
results.add(runCatching { val x: String? = null; x!!.length }
.exceptionOrNull()?.let { it::class.simpleName } ?: "нет")
results.add(runCatching { Holder().read() }
.exceptionOrNull()?.let { it::class.simpleName } ?: "нет")
results.add(runCatching { Leaky().len }
.exceptionOrNull()?.let { it::class.simpleName } ?: "нет")
results.add(runCatching { System.getProperty("нет.такого").length }
.exceptionOrNull()?.let { it::class.simpleName } ?: "нет")
results.add(runCatching { throw NullPointerException("написали руками") }
.exceptionOrNull()?.let { it::class.simpleName } ?: "нет")
results.forEachIndexed { i, r -> println((i + 1).toString() + ": " + r) }
}
Вывод:
1: NullPointerException
2: UninitializedPropertyAccessException
3: NullPointerException
4: NullPointerException
5: NullPointerException
Разберём каждый:
- Оператор
!!— ты сам написал два восклицательных знака. Явнее некуда. lateinitдо присваивания — обещал заполнить позже и не заполнил. Исключение даже другое:UninitializedPropertyAccessException, и текст у него внятный.- Обращение к
thisв конструкторе — редкий и коварный случай: базовый класс в своёмinitвызывает метод, переопределённый в наследнике, а поля наследника ещё не проинициализированы. - Значение из Java — платформенный тип, компилятор не проверял. Разберём отдельным разделом.
- Явный
throw NullPointerException— ты сам бросил, вопросов нет.
Сравни это с Java, где NPE может прилететь буквально из любого обращения через точку. Разница не в том, что исключение исчезло, а в том, что список мест, откуда оно может прийти, стал коротким и обозримым.
?.let и когда он лучше проверки
let — обычная функция стандартной библиотеки. Она берёт объект, передаёт его в блок под именем it и возвращает результат блока. В связке с ?. получается очень удобная конструкция: блок выполнится, только если значение не пустое.
fun main() {
val a: String? = "olegg"
val b: String? = null
a?.let { println("let сработал, длина " + it.length) }
b?.let { println("этого не будет") }
val res = b?.let { it.length } ?: 0
println("res = " + res)
println("a?.let вернул: " + a?.let { it.uppercase() })
println("b?.let вернул: " + b?.let { it.uppercase() })
}
Вывод:
let сработал, длина 5
res = 0
a?.let вернул: OLEGG
b?.let вернул: null
Заметь: строка «этого не будет» не напечаталась вообще — блок не запускался. А b?.let { ... } в последней строке вернул null, потому что блок не выполнился и возвращать было нечего.
Когда ?.let лучше обычного if:
- когда проверяемое значение — не локальная переменная, а свойство класса или результат вызова. Умное приведение там не работает, а
letработает всегда: он получает значение по факту, один раз; - когда результат блока нужен как значение —
letвозвращает то, что вычислил блок,ifв такой роли выглядит громоздко; - когда цепочка длинная и не хочется заводить промежуточную переменную.
Родственники let в стандартной библиотеке:
| Функция | Как зовут объект внутри | Что возвращает | Типичное применение с ?. |
|---|---|---|---|
let | it | результат блока | преобразовать непустое значение во что-то другое |
run | this | результат блока | то же, но когда внутри много обращений к полям объекта |
also | it | сам объект | что-то сделать «по дороге»: залогировать, проверить |
apply | this | сам объект | донастроить объект и вернуть его же |
Все четыре сочетаются с ?. одинаково: если слева пусто, блок не выполняется.
Одна ловушка, о которой стоит знать заранее. Вложенные let быстро превращаются в кашу:
// так лучше не писать
user?.let { u ->
u.profile?.let { p ->
p.city?.let { c ->
println(c.name)
}
}
}
// то же самое одной строкой
println(user?.profile?.city?.name ?: "город не указан")
let хорош, когда он один. Три вложенных — знак, что нужна цепочка ?. или ранний возврат через элвис.
lateinit: обещание заполнить позже
Бывает ситуация, когда значение точно будет, но не в момент создания объекта. Классика Android: вьюхи существуют только после onCreate, а поле объявляется раньше.
Писать var view: View? = null и потом ставить !! при каждом обращении — мучение. Для этого есть lateinit.
class Screen {
lateinit var title: String
fun ready() = ::title.isInitialized
fun show() = println("заголовок: " + title)
}
fun main() {
val s = Screen()
println("инициализирована? " + s.ready())
try { s.show() } catch (e: Exception) {
println("класс: " + e::class.qualifiedName)
println("сообщение: " + e.message)
}
s.title = "Кошелёк"
println("после присваивания: " + s.ready())
s.show()
}
Вывод:
инициализирована? false
класс: kotlin.UninitializedPropertyAccessException
сообщение: lateinit property title has not been initialized
заголовок: Кошелёк
после присваивания: true
Разберём, что мы получили.
Тип поля — String без вопроса. Обращаться к нему можно напрямую, без ?. и !!. Компилятор поверил обещанию.
Если обратиться до присваивания — исключение. Но какое! UninitializedPropertyAccessException с текстом lateinit property title has not been initialized. Здесь есть имя свойства. Сравни с пустым сообщением у !! — разница при разборе падения огромная.
Проверить, заполнено ли поле, можно через ::title.isInitialized. Важная тонкость: эта проверка доступна только изнутри класса. Снаружи компилятор скажет:
Backing field of 'var title: String' is not accessible at this point.
Поэтому в примере пришлось завести метод ready().
Ограничения у lateinit строгие:
- только
var, неval; - только non-null тип;
- нельзя для примитивов —
Int,Long,Boolean,Doubleи прочих.
Последнее проверим отдельно:
class Bad {
lateinit var count: Int
}
fun main() { println(Bad()) }
'lateinit' modifier is not allowed on properties of primitive types.
Причина техническая: механизм lateinit отличает «заполнено» от «не заполнено» по тому, лежит ли внутри null. У примитивов вроде Int такого состояния просто нет — там всегда какое-то число.
by lazy: посчитать при первом обращении
У lateinit есть двоюродный брат, который решает похожую задачу, но без риска исключения.
class Config {
val token: String by lazy {
println(" (считаем значение — один раз)")
"секрет-из-хранилища"
}
}
fun main() {
val c = Config()
println("объект создан, значение ещё не считано")
println("первое обращение: " + c.token)
println("второе обращение: " + c.token)
}
Вывод:
объект создан, значение ещё не считано
(считаем значение — один раз)
первое обращение: секрет-из-хранилища
второе обращение: секрет-из-хранилища
Строчка «считаем значение» напечаталась ровно один раз — при первом обращении. Второе обращение взяло готовый результат.
lateinit var | val ... by lazy | |
|---|---|---|
| кто задаёт значение | внешний код присваиванием | блок, написанный тут же |
| когда вычисляется | когда присвоят | при первом чтении |
| если не заполнено | исключение | не бывает такой ситуации |
| изменяемость | var, можно переприсвоить | val, только чтение |
| примитивы | нельзя | можно |
Правило выбора: если значение приходит извне (фреймворк, инъекция зависимостей, ответ сервера) — lateinit. Если объект может вычислить его сам, просто не хочет делать это сразу — by lazy.
by lazy в этом смысле безопаснее: там физически нет момента, когда значения ещё нет, а обратиться уже можно.
Платформенные типы: граница с Java
Дошли до места, где вся стройная система даёт трещину. Kotlin работает на JVM и вызывает библиотеки, написанные на Java. А в Java никакой информации о пустоте в типах нет.
Что делать компилятору, когда Java-метод объявлен как String getProperty(String key)? Считать результат String? Тогда мы солжём — метод возвращает null, если свойства нет. Считать String?? Тогда каждый вызов любой Java-библиотеки придётся обвешивать вопросами, и работа с Java превратится в пытку.
Kotlin выбрал третий путь — платформенный тип, который записывается String!. Смысл: «я не знаю, может тут быть пустота или нет. Решай сам».
fun main() {
val v = System.getProperty("java.version")
println("java.version = " + v)
val missing = System.getProperty("такого.свойства.нет")
println("отсутствующее свойство: " + missing)
try {
println("длина отсутствующего: " + missing.length)
} catch (e: NullPointerException) {
println("платформенный тип пропустил компиляцию, но упал в рантайме")
println("сообщение: " + e.message)
}
}
Вывод:
java.version = 17.0.20
отсутствующее свойство: null
платформенный тип пропустил компиляцию, но упал в рантайме
сообщение: Cannot invoke "String.length()" because "missing" is null
Смотри, что произошло. Строка missing.length скомпилировалась — без ?., без !!, без единого предупреждения. Компилятор не стал возражать, потому что тип пришёл из Java и гарантий по нему нет. И упала она уже в момент выполнения.
Заодно обрати внимание на текст исключения: because "missing" is null — тут есть имя переменной. Это тот самый helpful NPE от JVM, о котором шла речь в разделе про !!. Пустота пришла естественным путём, поэтому машина смогла объяснить, что именно оказалось пустым.
Практическое правило простое:
// плохо: компилятор поверил, гарантий нет
val version: String = System.getProperty("java.version")
// хорошо: сразу объявили честный тип
val version: String? = System.getProperty("java.version")
val safe = version ?: "неизвестно"
Всё, что приходит из Java-библиотеки, стоит сразу записывать в переменную с явным типом String? (или каким там нужно). Одна строка — и дальше по коду снова работают все проверки компилятора.
Многие популярные Java-библиотеки размечены аннотациями @Nullable и @NotNull. Если они есть, Kotlin читает их и делает нормальный String? или String вместо платформенного типа. Так что чем современнее библиотека, тем меньше мест, где гарантии теряются. Но проверять руками стоит всё равно — аннотации ставят не везде.
as? — приведение типа без падения
Про пустоту есть ещё один оператор, который часто забывают. Обычное приведение типа as падает, если тип не подошёл. Безопасное as? вместо падения даёт null.
fun main() {
val any: Any = "строка"
println("as? Int дал: " + (any as? Int))
println("as? String дал: " + (any as? String))
try { any as Int } catch (e: Exception) {
println("обычный as бросил " + e::class.simpleName)
}
println("с элвисом: " + ((any as? Int) ?: -1))
}
Вывод:
as? Int дал: null
as? String дал: строка
обычный as бросил ClassCastException
с элвисом: -1
Связка as? плюс ?: — стандартный способ разобрать данные неизвестного типа: «попробуй привести, а если не вышло — вот значение по умолчанию». Пригодится, когда будем разбирать JSON и ответы сервера.
Коллекции: List<String?> и List<String>?
Вот место, где путаются даже те, кто уже неплохо знает язык. Вопрос может стоять в двух разных местах, и это два совершенно разных смысла:
| Запись | Что означает |
|---|---|
List<String?> | список точно есть, но внутри могут быть дырки |
List<String>? | самого списка может не быть, но если есть — внутри всё заполнено |
List<String?>? | и списка может не быть, и внутри дырки |
Прочти таблицу дважды. Вопрос относится к тому, что стоит слева от него.
fun main() {
val withHoles: List<String?> = listOf("a", null, "c")
val maybeList: List<String>? = null
println("размер списка с дырками: " + withHoles.size)
println("размер возможно-отсутствующего: " + maybeList?.size)
println("filterNotNull: " + withHoles.filterNotNull())
println("mapNotNull длины: " + withHoles.mapNotNull { it?.length })
println("orEmpty: " + maybeList.orEmpty())
println("firstOrNull пустого: " + emptyList<String>().firstOrNull())
println("сумма длин: " + withHoles.sumOf { it?.length ?: 0 })
}
Вывод:
размер списка с дырками: 3
размер возможно-отсутствующего: null
filterNotNull: [a, c]
mapNotNull длины: [1, 1]
orEmpty: []
firstOrNull пустого: null
сумма длин: 2
Обрати внимание на первые две строки. У withHoles мы взяли .size напрямую — список есть, вопрос стоял внутри. У maybeList пришлось писать ?.size — самого списка может не быть.
Инструменты для коллекций с дырками
В стандартной библиотеке есть готовый набор функций ровно под эту задачу. Разберём те, что из примера выше.
filterNotNull() — выбрасывает пустоты и, что важнее, меняет тип. Был List<String?>, стал List<String>. После него внутри списка гарантированно нет дырок, и работать с элементами можно напрямую.
mapNotNull { } — делает две вещи сразу: преобразует элементы и выбрасывает те, для которых блок вернул null. В примере из трёх элементов остались два числа.
orEmpty() — для случая «списка может не быть». Возвращает сам список или пустой, если его нет. Дальше можно спокойно вызывать .map, .filter, .forEach — на пустом списке они просто ничего не сделают. Есть и для строк: String?.orEmpty() даёт "".
firstOrNull() — безопасная версия first(). Обычный first() на пустом списке бросает NoSuchElementException, а firstOrNull() вернёт null, который можно поймать элвисом. Такая же пара есть у last, single, max, min.
| Задача | Инструмент |
|---|---|
| убрать дырки из списка | list.filterNotNull() |
| преобразовать и убрать неудачные | list.mapNotNull { ... } |
| работать со списком, которого может не быть | list.orEmpty() |
| взять первый элемент, если он есть | list.firstOrNull() |
| посчитать сумму, считая дырки нулями | list.sumOf { it?.length ?: 0 } |
| проверить, что список есть и непуст | if (!list.isNullOrEmpty()) |
isNullOrEmpty() — отдельная приятная мелочь: она вызывается на nullable-получателе без всякого ?. и покрывает сразу два случая, «списка нет» и «список пуст». То же есть у строк.
Map: нет ключа и null-значение
Отдельная история — словари. Оператор [] у Map всегда возвращает nullable-тип, даже если значения в словаре объявлены без вопроса.
fun main() {
val rates = mapOf("USD" to 92, "EUR" to 100)
println("rates[\"USD\"] = " + rates["USD"])
println("rates[\"GBP\"] = " + rates["GBP"])
println("getOrDefault: " + rates.getOrDefault("GBP", 0))
println("getOrElse: " + rates.getOrElse("GBP") { -1 })
try { println(rates.getValue("GBP")) } catch (e: Exception) {
println("getValue бросил " + e::class.simpleName + ": " + e.message)
}
}
Вывод:
rates["USD"] = 92
rates["GBP"] = null
getOrDefault: 0
getOrElse: -1
getValue бросил NoSuchElementException: Key GBP is missing in the map.
Четыре разных способа спросить у словаря значение, и все ведут себя по-разному:
rates["GBP"]— вернётnull, тип результатаInt?;getOrDefault("GBP", 0)— вернёт готовое значение по умолчанию;getOrElse("GBP") { -1 }— то же, но значение вычисляется блоком и только при промахе;getValue("GBP")— броситNoSuchElementExceptionс внятным текстом.
А теперь коварный случай. Что если значения в словаре сами могут быть пустыми?
fun main() {
val m: Map<String, Int?> = mapOf("a" to 1, "b" to null)
println("m[b] = " + m["b"] + ", m[z] = " + m["z"])
println("различить можно только через containsKey: b есть? " + m.containsKey("b")
+ ", z есть? " + m.containsKey("z"))
println("сумма не-null значений: " + m.values.filterNotNull().sum())
}
Вывод:
m[b] = null, m[z] = null
различить можно только через containsKey: b есть? true, z есть? false
сумма не-null значений: 1
Оператор [] в обоих случаях дал null, хотя ситуации разные: ключ b в словаре есть, просто его значение пустое, а ключа z нет вообще. Различить их можно только через containsKey.
Практический вывод: Map со значениями типа Int? — почти всегда признак того, что модель данных стоит переделать. Если пустота значима, лучше завести отдельный тип, чем полагаться на containsKey по всему коду.
requireNotNull, checkNotNull, error
Когда пустота означает ошибку, падать надо сразу и с объяснением. В стандартной библиотеке для этого есть три функции.
fun main() {
val n: String? = null
try { requireNotNull(n) { "ник обязателен" } } catch (e: Exception) {
println(e::class.simpleName + ": " + e.message)
}
try { checkNotNull(n) } catch (e: Exception) {
println(e::class.simpleName + ": " + e.message)
}
try { n ?: error("значение обязательно") } catch (e: Exception) {
println(e::class.simpleName + ": " + e.message)
}
val ok: String? = "olegg"
val safe: String = requireNotNull(ok)
println("после requireNotNull тип уже String: " + safe.length)
}
Вывод:
IllegalArgumentException: ник обязателен
IllegalStateException: Required value was null.
IllegalStateException: значение обязательно
после requireNotNull тип уже String: 5
Три функции, три разных класса исключения — и это не случайность:
requireNotNullбросаетIllegalArgumentException— «неправильный аргумент». Применяется на входе в функцию, когда виноват вызывающий код.checkNotNullбросаетIllegalStateException— «неправильное состояние». Применяется в середине, когда объект оказался не в том состоянии, в котором должен быть.error("текст")— короткая запись дляthrow IllegalStateException("текст").
Обрати внимание на последнюю строку примера: requireNotNull не просто проверяет, но и возвращает значение с уже снятым вопросом. Поэтому его можно писать прямо в присваивании — и дальше работать с обычным типом.
И сравни сообщения. У requireNotNull с блоком — «ник обязателен», твой собственный текст. У checkNotNull без блока — стандартное «Required value was null.». Блок с сообщением стоит писать всегда: через полгода именно он объяснит, что пошло не так.
Вот почему эта тройка лучше, чем !!:
| Способ | Класс исключения | Сообщение |
|---|---|---|
x!! | NullPointerException | нет вообще |
checkNotNull(x) | IllegalStateException | Required value was null. |
requireNotNull(x) { "..." } | IllegalArgumentException | твой текст |
Падение одинаковое во всех трёх случаях. Разница только в том, сколько времени займёт разбор.
Boolean? — ловушка в условии
Маленький, но очень коварный случай. Что делать с логическим значением, которое может быть пустым?
fun main() {
val flag: Boolean? = null
println("flag == true: " + (flag == true))
println("flag != false: " + (flag != false))
println("flag ?: false: " + (flag ?: false))
val yes: Boolean? = true
if (yes == true) println("сравнение с true — обычный приём для Boolean?")
}
Вывод:
flag == true: false
flag != false: true
flag ?: false: false
сравнение с true — обычный приём для Boolean?
Смотри на вторую строку: flag != false дало true, хотя внутри flag пусто. Логично — null действительно не равен false. Но если написать такое в условии по невнимательности, программа пойдёт по ветке «да» там, где данных вообще нет.
Прямо в if подставить Boolean? нельзя — компилятор не даст. Значит, нужно явно решить, чем считать пустоту:
if (flag == true) { ... } // пустота считается «нет»
if (flag ?: false) { ... } // то же самое, другими словами
if (flag != false) { ... } // пустота считается «да» — почти всегда не то, что нужно
Первая форма — самая частая в реальном коде. Выглядит она странно (сравнивать булево с true!), но у Kotlin здесь свой смысл: это не тавтология, а способ схлопнуть три состояния — «да», «нет», «неизвестно» — в два.
Дженерики и null
Ещё одно место, где вопрос ведёт себя не так, как ожидаешь. Обобщённый параметр T по умолчанию уже допускает пустоту.
fun <T> firstOr(list: List<T>, fallback: T): T = list.firstOrNull() ?: fallback
fun <T : Any> onlyNotNull(v: T?): T = v ?: error("значение обязательно")
fun main() {
println(firstOr(listOf(1, 2), 0))
println(firstOr(emptyList<String>(), "пусто"))
println(onlyNotNull("есть"))
try { onlyNotNull<String>(null) } catch (e: Exception) {
println(e::class.simpleName + ": " + e.message)
}
}
Вывод:
1
пусто
есть
IllegalStateException: значение обязательно
Ключевая строка здесь вторая: fun <T : Any> onlyNotNull(...). Запись T : Any означает «T обязан быть непустым типом». Дело в том, что Any в Kotlin — корень иерархии non-null типов, а над ним есть ещё Any?, куда входит вообще всё.
Без ограничения объявление fun <T> f(v: T?): T не имело бы смысла: T мог бы сам оказаться nullable, и вопрос ничего бы не добавлял.
Запомни короткое правило: <T> — это на самом деле <T : Any?>. Если тебе нужно запретить пустоту в обобщённой функции, пиши <T : Any> явно.
Граница с внешним миром
Соберём всё вместе на практическом вопросе: где в реальном приложении вообще берётся пустота?
Ответ: на границах. Внутри своего кода ты сам решаешь, каким типам разрешить пустоту, и обычно разрешаешь очень немногим. А снаружи приходит вот что:
- Ответ сервера. В JSON поле может отсутствовать, быть
nullили прийти не того типа. Библиотеки разбора превращают это в nullable-поля. - База данных. Колонка объявлена как
NULL— значит, читается какString?. - Поля формы. Пользователь мог ничего не ввести.
- Java-библиотеки. Платформенные типы, разбирали выше.
- Настройки и переменные окружения. Ключа может не быть.
Правильная стратегия называется «проверить один раз на границе»:
// приходит с сервера — тут пустота законна
data class UserDto(val id: Long?, val name: String?, val city: String?)
// внутри приложения — тут пустоты уже нет
data class User(val id: Long, val name: String, val city: String)
fun UserDto.toUser(): User? {
val id = id ?: return null // без id пользователь бессмысленен
val name = name ?: return null // без имени тоже
return User(id = id, name = name, city = city ?: "не указан")
}
Смысл в том, что грязный тип живёт ровно на границе, а весь остальной код работает с чистым. Три элвиса в одной функции — и дальше по приложению никто не думает про пустоту.
Это же объясняет, почему !! по всему коду — плохой признак. Он означает, что проверку на границе не сделали, и теперь каждый второй метод вынужден иметь с этим дело.
Когда будешь писать первый экран, который получает данные из сети, вспомни этот раздел. Соблазн взять DTO прямо из ответа и таскать его по всему приложению очень велик — он экономит один класс. Расплата приходит через месяц: пустота из одного поля JSON расползается по десятку экранов, и в каждом стоит свой ?:.
Ошибки компилятора этой главы
Соберём в одном месте все сообщения, которые мы получили живьём. С этими текстами ты столкнёшься в первую же неделю, и полезно узнавать их с одного взгляда.
| Сообщение | Что произошло | Как починить |
|---|---|---|
Null cannot be a value of a non-null type 'String' | пытаешься положить null в тип без вопроса | добавь ? в тип, если пустота законна |
Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver | обращаешься через точку к nullable-значению | поставь ?., а результат добей элвисом |
Smart cast to 'String' is impossible, because 'nick' is a mutable property | проверяешь var-свойство класса | скопируй в локальную val и проверяй её |
'lateinit' modifier is not allowed on properties of primitive types | lateinit на Int, Boolean и подобных | возьми значение по умолчанию или by lazy |
Backing field of 'var title: String' is not accessible at this point | ::title.isInitialized снаружи класса | сделай метод внутри класса, который вернёт эту проверку |
А это — исключения времени выполнения из той же темы:
| Исключение | Откуда | Что подсказывает |
|---|---|---|
NullPointerException, message = null | оператор !! | ничего, только строку в стектрейсе |
UninitializedPropertyAccessException | lateinit до присваивания | имя свойства: lateinit property title has not been initialized |
NullPointerException: Cannot invoke ... because "missing" is null | пустота из Java | имя переменной и метод |
IllegalArgumentException | requireNotNull | твой текст из блока |
IllegalStateException: Required value was null. | checkNotNull без блока | стандартный текст |
NoSuchElementException: Key GBP is missing in the map. | map.getValue | какого ключа не хватило |
ClassCastException | обычный as | какой тип во что не превратился |
Тренажёр
Конструкции этой главы короткие, и печатать их придётся постоянно. Прогони их через пальцы — знаки ?., ?: и !! набираются неудобно, пока не привыкнешь.
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
val nick: String? = null
Экзамен главы
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Null safety — официальная документация Kotlin — первоисточник по всему, что в этой главе. Английский, но короткий и с примерами.
- Kotlin Playground — тот самый компилятор, на котором прогнаны все примеры главы. Можно менять код прямо в браузере и смотреть, что скажет компилятор.
- Scope functions: let, run, also, apply — подробный разбор четвёрки из раздела про
?.let, с таблицей выбора. - Calling Java from Kotlin — про платформенные типы — раздел документации ровно про ту границу, где гарантии теряются.
- Доклад Тони Хоара «Null References: The Billion Dollar Mistake» — запись выступления на QCon London 2009. Стоит послушать хотя бы первые десять минут.
- Kotlin API: filterNotNull, mapNotNull, orEmpty — справочник по коллекциям, где лежат все функции из раздела про дырки в списках.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.