Перейти к основному содержимому
Мобилка02ГЛАВА 02Null-безопасность: ошибка на миллиард долларовStringString??.?. ?: !!

Null-безопасность: ошибка на миллиард долларов

Прочитано0%
Квизы0/5
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

Открой любой чат разработчиков Android — и рано или поздно там появится скриншот с одной и той же строчкой:

java.lang.NullPointerException

Это самая частая ошибка в истории Java. Не самая сложная, не самая интересная — самая частая. Приложение падает не потому, что программист написал плохой алгоритм, а потому, что в какой-то переменной вместо значения оказалась пустота, и никто этого не проверил.

Kotlin создавался людьми, которые устали чинить именно эту ошибку. Поэтому в нём система типов знает про пустоту. Не библиотека, не соглашение в команде, не «мы договорились всегда проверять» — сам компилятор.

В этой главе мы разберём, как это устроено: почему String и String? — два разных типа, что означает каждый из знаков ?., ?:, !!, где безопасность заканчивается и почему NullPointerException в Kotlin всё-таки существует — но получить его случайно уже нельзя.

Важно

Все примеры этой главы прогнаны на настоящем компиляторе Kotlin 2.4.10. Тексты ошибок и вывод программ приведены дословно — ровно так, как их печатает компилятор. Ни одна строчка вывода не придумана.

Ошибка на миллиард долларов​

История у проблемы конкретная, с именем и датой.

В 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? читается как «строка-или-ничего».

ДВА РАЗНЫХ ТИПА, А НЕ ОДИНStringвнутри всегда есть текст"olegg" "" " "null — не помещаетсяnick.length — можно сразуString?внутри текст ИЛИ пустота"olegg" "" " "null — помещается тожеnick.length — компилятор не дастзнак ? в типе — это не «необязательно», а «здесь может лежать пустота»String можно передать туда, где ждут String?, а обратно — только через проверку
String и String? — не «одно и то же, только второе необязательное». Это разные типы с разным набором допустимых значений. Из String можно сразу брать длину, из 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. Компилятор не может доказать, что этого не произойдёт, поэтому отказывается ручаться.

Умное приведение не работает в трёх типичных случаях:

  1. свойство объявлено через var — как в примере выше;
  2. свойство объявлено в другом модуле — компилятор не видит его реализацию целиком;
  3. свойство с пользовательским геттером — каждое обращение может вернуть новое значение.

Лечится это одной строкой — скопировать значение в локальную 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 — одна строка.

Отвечено вопросов: 0 из 4
Чем отличается тип String от типа String??
Какой тип у выражения a?.length, если a объявлен как String??
В цепочке a?.b?.c?.d первое звено оказалось null. Что произойдёт с остальными?
Почему компилятор отказывается делать умное приведение для var-свойства класса?
Отвечено верно: 0 из 4

Элвис ?: — запасное значение​

Оператор ?: официально называется элвис-оператором. Название шуточное: если посмотреть на знаки боком, получается смайлик с причёской — отсылка к Элвису Пресли. Но работа у него совершенно серьёзная.

Если слева не 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() написана в коде дважды. В первом случае левая часть оказалась непустой — и правая просто не вычислялась.

Это значит, что справа от элвиса можно спокойно ставить тяжёлые вещи: запрос в базу, чтение файла, расчёт. Они сработают только тогда, когда действительно понадобятся.

ЧЕТЫРЕ СПОСОБА РАБОТАТЬ С ПУСТОТОЙ?.вызывает, только если слева не nullесли слева null:вернёт null?:подставляет запасное значениеесли слева null:вернёт то, что справа?.letвыполняет блок с непустым itесли слева null:блок не выполнится!!клянётся, что null тут не будетесли слева null:бросит NPE
Четыре оператора и главное различие между ними — что происходит, когда слева оказалась пустота. Три верхних дают значение, нижний бросает исключение. Выбирая между ними, ты выбираешь именно поведение в этой строке.

Элвис с 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

Разберём каждый:

  1. Оператор !! — ты сам написал два восклицательных знака. Явнее некуда.
  2. lateinit до присваивания — обещал заполнить позже и не заполнил. Исключение даже другое: UninitializedPropertyAccessException, и текст у него внятный.
  3. Обращение к this в конструкторе — редкий и коварный случай: базовый класс в своём init вызывает метод, переопределённый в наследнике, а поля наследника ещё не проинициализированы.
  4. Значение из Java — платформенный тип, компилятор не проверял. Разберём отдельным разделом.
  5. Явный throw NullPointerException — ты сам бросил, вопросов нет.
ОТКУДА NPE БЕРЁТСЯ ДАЖЕ В KOTLIN1оператор !!NullPointerException2lateinit до присваиванияUninitializedProperty…3this в конструктореNullPointerException4значение из JavaNullPointerException5throw NullPointerException — написали рукамивсе пять проверены живьём на Kotlin 2.4.10ни одного случайного — каждый пишется явнокомпилятор убирает не сам NPE, а возможность получить его нечаянно
Все пять источников NullPointerException в Kotlin, проверенные на компиляторе 2.4.10. Общее у них одно: ни один не возникает молча. За каждым стоит либо оператор, который ты написал, либо граница с кодом без проверок.

Сравни это с Java, где NPE может прилететь буквально из любого обращения через точку. Разница не в том, что исключение исчезло, а в том, что список мест, откуда оно может прийти, стал коротким и обозримым.

Отвечено вопросов: 0 из 4
Что напечатает val n: String? = null; println(n?.length ?: -1)?
Какое сообщение будет у исключения, брошенного оператором !!?
Функция объявлена как fun f(x: String?): String = x ?: "по умолчанию". Почему возвращаемый тип без вопроса?
Что делает конструкция val safe = n ?: return -1?
Отвечено верно: 0 из 4

?.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 в стандартной библиотеке:

ФункцияКак зовут объект внутриЧто возвращаетТипичное применение с ?.
letitрезультат блокапреобразовать непустое значение во что-то другое
runthisрезультат блокато же, но когда внутри много обращений к полям объекта
alsoitсам объектчто-то сделать «по дороге»: залогировать, проверить
applythisсам объектдонастроить объект и вернуть его же

Все четыре сочетаются с ?. одинаково: если слева пусто, блок не выполняется.

Одна ловушка, о которой стоит знать заранее. Вложенные 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 varval ... 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, о котором шла речь в разделе про !!. Пустота пришла естественным путём, поэтому машина смогла объяснить, что именно оказалось пустым.

ГРАНИЦА С JAVA: ТИП БЕЗ ГАРАНТИЙJavaString getProperty()может вернуть nullи нигде это не написаноString!платформенный типкомпилятор молчитпроверку не требуеттвой кодпишешь String — падаетпишешь String? — безопасновыбор за тобой, не за нимна этой границе гарантия исчезает: тип пришёл из мира, где null разрешён вездеправило простое — всё, что пришло снаружи, сразу объявляй как nullableCannot invoke "String.length()" because "missing" is null
Граница между Java и Kotlin. Java-метод не несёт информации о пустоте, поэтому Kotlin даёт результату платформенный тип String! и перекладывает решение на тебя. Написал String — берёшь риск на себя, написал String? — компилятор снова начинает проверять.

Практическое правило простое:

// плохо: компилятор поверил, гарантий нет
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 по всему коду.

Отвечено вопросов: 0 из 4
Чем List<String?> отличается от List<String>??
Почему lateinit нельзя применить к полю типа Int?
Java-метод вернул значение. Какой у него тип с точки зрения Kotlin?
map["ключ"] вернул null. Что это означает?
Отвечено верно: 0 из 4

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)IllegalStateExceptionRequired 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> явно.

Граница с внешним миром​

Соберём всё вместе на практическом вопросе: где в реальном приложении вообще берётся пустота?

Ответ: на границах. Внутри своего кода ты сам решаешь, каким типам разрешить пустоту, и обычно разрешаешь очень немногим. А снаружи приходит вот что:

  1. Ответ сервера. В JSON поле может отсутствовать, быть null или прийти не того типа. Библиотеки разбора превращают это в nullable-поля.
  2. База данных. Колонка объявлена как NULL — значит, читается как String?.
  3. Поля формы. Пользователь мог ничего не ввести.
  4. Java-библиотеки. Платформенные типы, разбирали выше.
  5. Настройки и переменные окружения. Ключа может не быть.

Правильная стратегия называется «проверить один раз на границе»:

// приходит с сервера — тут пустота законна
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 расползается по десятку экранов, и в каждом стоит свой ?:.

Отвечено вопросов: 0 из 4
Чем отличаются requireNotNull и checkNotNull?
Что возвращает requireNotNull(ok), если ok имеет тип String? и не пуст?
Что означает ограничение <T : Any> у обобщённой функции?
Где в приложении разумнее всего снимать пустоту с данных?
Отвечено верно: 0 из 4

Ошибки компилятора этой главы​

Соберём в одном месте все сообщения, которые мы получили живьём. С этими текстами ты столкнёшься в первую же неделю, и полезно узнавать их с одного взгляда.

СообщениеЧто произошлоКак починить
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 typeslateinit на Int, Boolean и подобныхвозьми значение по умолчанию или by lazy
Backing field of 'var title: String' is not accessible at this point::title.isInitialized снаружи классасделай метод внутри класса, который вернёт эту проверку

А это — исключения времени выполнения из той же темы:

ИсключениеОткудаЧто подсказывает
NullPointerException, message = nullоператор !!ничего, только строку в стектрейсе
UninitializedPropertyAccessExceptionlateinit до присваиванияимя свойства: lateinit property title has not been initialized
NullPointerException: Cannot invoke ... because "missing" is nullпустота из Javaимя переменной и метод
IllegalArgumentExceptionrequireNotNullтвой текст из блока
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 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоУрок 13: Null Safety – операторы ?. !!, NullPointerException (NPE) // Котлин курс с нуляKotlin & Android с нуля – Иван Ветров
ВидеоАлександр Денисов — Так ли страшен Null, как его малюют?Innopolis City
Видеоlateinit - это зло и «костыль» Kotlin. Dagger 2 всему винойAndroid Broadcast. Все об Андроид разработке
ВидеоNULL и его обработка в «мобильных языках» (Kotlin, Swift, Dart) — Mad Brains ТехноMad Brains
ВидеоNull References: The Billion Dollar Mistake — доклад Тони ХоараJoseCan

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.