1. Зачем нужны ?. и ?: и почему одного if мало
Когда вы только узнали про null, первая реакция обычно такая: «Окей, буду везде писать if (x != null)». Это нормальный старт, но довольно быстро вы почувствуете боль: вложенные if разрастаются, логика расползается по коду, а чтение программы становится похоже на прохождение лабиринта (и в конце вас всё равно может ждать минотавр).
Kotlin предлагает более выразительные конструкции: короткие операторы, которые делают обработку null частью выражения. Это важно, потому что многие задачи — это не «сделай действие или не делай», а «получи значение, если оно есть, иначе используй запасной вариант». Именно для этого существуют ?. и ?:.
Чтобы держать картинку в голове, вот мини-таблица:
| Оператор | Как читается | Идея | Что возвращает |
|---|---|---|---|
|
«если не null — сделай, иначе верни null» | безопасный доступ | чаще всего T? |
|
«если слева null — возьми справа» | значение по умолчанию / запасное поведение | часто снимает nullable |
|
«я клянусь, что тут не null» | принудительный доступ | T, но риск падения |
Дальше разберём каждый инструмент спокойно и на примерах.
2. Safe call ?.: безопасный доступ без падений
Оператор ?. обычно любят за то, что он делает код короче и безопаснее. Но главное — он делает поведение явным: вы прямо в строке кода показываете, что значение может отсутствовать, и вы готовы к такому сценарию. Это как пристегнуться ремнём безопасности: вы не планируете аварию, но жить становится спокойнее.
?. на свойствах: .length без риска
Самый простой пример — длина строки. Если строка nullable, то прямой доступ запрещён:
fun main() {
val nickname: String? = null
println(nickname.length) // так нельзя: nickname может быть null
val len: Int? = nickname?.length
println(len) // null
}
Здесь важно заметить одну вещь: результат nickname?.length имеет тип Int?, потому что операция могла «не выполниться» (если слева null, длину вычислять нечему).
И это супер-логично: safe call не «придумывает» длину из воздуха. Он честно говорит: «Либо я верну длину, либо null».
?. на методах: безопасный вызов uppercase()
Тот же принцип с методами:
fun main() {
val nickname: String? = "neo"
val upper: String? = nickname?.uppercase()
println(upper) // NEO
}
Если nickname будет null, то upper тоже станет null, и программа не упадёт.
Цепочки safe call: «домино из ?.»
Обычно ?. используют не один раз, а цепочкой: «возьми строку → обрежь пробелы → приведи к верхнему регистру → возьми длину». И на каждом шаге значение может стать null (если на входе было null).
fun main() {
val raw: String? = " Kotlin "
val len: Int? = raw
?.trim()
?.uppercase()
?.length
println(len) // 6
}
Читается приятно: пока значение есть — идём дальше; как только значения нет — вся цепочка превращается в null.
Мини-схема: что делает ?.
Чтобы закрепить, представим работу ?. как маленькую блок-схему:
flowchart TD
A["Есть значение x: T?"] --> B{ x == null? }
B -->|да| C["Результат = null"]
B -->|нет| D["Выполняем x.member / x.func()"]
D --> E["Результат = значение"]
Да, оператор выглядит как два символа, но за ним стоит очень конкретная логика.
3. Оператор ?:
Оператор ?: (Elvis, т.к. напоминает прическу Элвиса Пресли) позволяет заменить null на нормальное значение (или на нормальное действие).
Elvis как «дефолтное значение»
Самая частая форма:
fun main() {
val nickname: String? = null
val len: Int = nickname?.length ?: 0
println(len) // 0
}
Обратите внимание на тип: слева nickname?.length даёт Int?, но Elvis подставляет 0, поэтому итог — обычный Int.
Это один из ключевых паттернов: ?. часто «порождает nullable», а ?: часто «снимает nullable».
Elvis и строковые сообщения: нормальный UX в консоли
В наших консольных программах важно не только «не упасть», но и говорить с пользователем по-человечески.
fun main() {
val nickname: String? = null
val label: String = nickname?.uppercase() ?: "ANONYMOUS"
println("Hello, $label!") // Hello, ANONYMOUS!
}
Если значение есть — красиво форматируем. Если нет — показываем запасной текст.
Elvis как ранний выход
Иногда null — это не «давай подставим 0», а «значит, дальше работать нельзя». Тогда вместо дефолта мы делаем ранний выход из функции.
Kotlin позволяет использовать return вместе с Elvis:
fun parsePositiveInt(text: String): Int? {
val x: Int = text.toIntOrNull() ?: return null
if (x <= 0) return null
return x
}
fun main() {
println(parsePositiveInt("42")) // 42
println(parsePositiveInt("oops")) // null
println(parsePositiveInt("-5")) // null
}
Идея такая: мы «локализуем проблему» сразу. Если не распарсилось — возвращаем null и не строим вложенные if.
4. Поверь мне, брат
Оператор !! выглядит как простой способ «заткнуть компилятор»: он превращает T? в T. Но делает это очень прямолинейно: если значение оказалось null, программа падает во время выполнения (обычно это KotlinNullPointerException).
Важная мысль: !! не делает ваш код безопаснее. Он делает ваш код компилируемым, а безопасность перекладывает на вашу уверенность.
Пример: компилируется, но падает
fun main() {
val nickname: String? = null
val len: Int = nickname!!.length
println(len) // сюда программа не дойдёт
}
Если запустить этот код, вы получите падение программы. То есть !! — это не «обработка null», это «я гарантирую не null».
Когда !! вообще бывает уместен
Без фанатизма: !! можно рассматривать как аварийную кнопку «разбить в случае пожара». Иногда он уместен, когда вы абсолютно уверены, что значения не может быть null, и если оно вдруг null — это действительно баг, который лучше обнаружить сразу.
Но на практике, когда новичок ставит !!, это часто означает: «Я не понял, как правильно обработать null». Поэтому базовое правило простое: сначала ищем решение через ?., ?: или проверку if, и только потом (в редких случаях) рассматриваем !!.
4. Анкета пользователя: опциональные поля и обработка null
Давайте продолжим развивать одно и то же консольное приложение: простую «анкету пользователя». Раньше мы читали строки, делали trim(), парсили числа через toIntOrNull(). Теперь добавим опциональные поля (которые могут быть пропущены), и применим ?., ?: и аккуратные паттерны.
Договоримся о смысле: пустая строка vs null
Очень частая путаница: пользователь нажал Enter и ввёл пустую строку "". Это не null. Но для нашей логики «ничего не ввёл» часто означает «значения нет». Поэтому мы можем сами превратить пустую строку в null.
Сделаем маленькую функцию-помощник:
fun emptyToNull(text: String): String? {
val trimmed = text.trim()
return if (trimmed.isEmpty()) null else trimmed
}
Читаем никнейм как String? и форматируем вывод
fun emptyToNull(text: String): String? {
val trimmed = text.trim()
return if (trimmed.isEmpty()) null else trimmed
}
fun main() {
println("Введите никнейм (можно пусто):")
val nicknameInput = readln()
val nickname: String? = emptyToNull(nicknameInput)
val label = nickname?.uppercase() ?: "ANONYMOUS"
println("Ваш ярлык: $label") // если пусто: "Ваш ярлык: ANONYMOUS"
}
Здесь сразу два инструмента: ?.uppercase() и ?: "ANONYMOUS".
Возраст: toIntOrNull() даёт Int?, и это нормально
Теперь спросим возраст, но сделаем его необязательным (например, пользователь не хочет делиться).
fun emptyToNull(text: String): String? {
val trimmed = text.trim()
return if (trimmed.isEmpty()) null else trimmed
}
fun main() {
println("Введите возраст (можно пусто):")
val ageText: String? = emptyToNull(readln())
val age: Int? = ageText?.toIntOrNull()
println("age = $age") // например: age = 25 или age = null
}
Очень важный момент: мы не можем сделать ageText.toIntOrNull(), потому что ageText nullable. Поэтому либо if, либо ?.. Мы выбрали ?..
Красивый вывод: формируем фразу, а не «null»
Теперь сделаем нормальное сообщение для пользователя:
fun emptyToNull(text: String): String? {
val trimmed = text.trim()
return if (trimmed.isEmpty()) null else trimmed
}
fun main() {
println("Введите возраст (можно пусто):")
val ageText: String? = emptyToNull(readln())
val age: Int? = ageText?.toIntOrNull()
val ageLabel: String = age?.toString() ?: "не указан"
println("Возраст: $ageLabel") // например: "Возраст: не указан"
}
Тут мы сделали ещё один «мостик»: age — Int?, а Elvis превращает это в текст, который можно вывести без стыда.
Паттерн «обработай null сразу»: избегаем вложенности
Представим, что мы хотим посчитать «год рождения приблизительно» (условно 2026 - age). Важно: число 2026 здесь просто выбранный год для примера. Если возраста нет, считать бессмысленно.
Сделаем функцию, которая возвращает строку-результат:
fun estimateBirthYear(ageText: String?): String {
val age: Int = ageText?.toIntOrNull() ?: return "Год рождения: неизвестен"
val year = 2026 - age
return "Год рождения примерно: $year"
}
fun main() {
println(estimateBirthYear("20")) // Год рождения примерно: 2006
println(estimateBirthYear(null)) // Год рождения: неизвестен
println(estimateBirthYear("abc")) // Год рождения: неизвестен
}
Здесь Elvis используется не с дефолтным значением, а с дефолтным поведением: ранний возврат.
Сборка анкеты целиком
Соберём упрощённую версию анкеты: имя обязательно, никнейм и возраст — нет.
fun emptyToNull(text: String): String? {
val trimmed = text.trim()
return if (trimmed.isEmpty()) null else trimmed
}
fun main() {
println("Имя (обязательно):")
val name: String = readln().trim()
println("Никнейм (можно пусто):")
val nickname: String? = emptyToNull(readln())
println("Возраст (можно пусто):")
val ageText: String? = emptyToNull(readln())
val age: Int? = ageText?.toIntOrNull()
val shownNickname = nickname?.uppercase() ?: "ANONYMOUS"
val shownAge = age?.toString() ?: "не указан"
println("Профиль: $name / $shownNickname / возраст: $shownAge")
// Пример: "Профиль: Alice / ANONYMOUS / возраст: не указан"
}
Здесь у нас аккуратная «труба обработки»:
readln() → trim() → (возможно) emptyToNull() → (возможно) ?.toIntOrNull() → форматирование через ?:.
Это и есть нормальный стиль работы с nullable: без перегибов, но и без игнорирования.
5. Типичные ошибки при работе с ?., ?: и !!
Ошибка №1: ожидать, что ?. подставляет значение по умолчанию.
Новички иногда читают safe call так: «если null, то как-нибудь само починится». Нет: ?. всего лишь гарантирует, что программа не упадёт на обращении к свойству/методу. Если слева null, результат выражения тоже будет null. Если вам нужен дефолт, это уже задача Elvis ?:, и дефолт должен быть осмысленным, а не «лишь бы компилировалось».
Ошибка №2: цепочка ?. становится длинной и перестаёт читаться.
Технически можно написать пять-шесть safe call подряд, и оно даже будет работать. Но мозг человека не компилятор: ему сложно держать в голове длинные цепочки, особенно если там ещё и ?: в конце. В таких случаях лучше сделать промежуточные val и дать им понятные имена, чтобы код читался как шаги, а не как заклинание.
Ошибка №3: использовать ?: 0 или ?: "" без смысла и тем самым маскировать проблему.
Elvis очень соблазнителен: поставил ?: 0 — и всё «работает». Но иногда это скрывает ошибку ввода или ломает логику. Например, возраст 0 может быть валидным числом в математике, но для анкеты это скорее странность. Лучше выбирать такие дефолты, которые имеют смысл для вашей предметной области: «не указан», «неизвестно», «—», либо ранний return, если без значения дальнейшая логика бессмысленна.
Ошибка №4: лечить все ошибки компилятора через !!.
!! действительно быстро «чинит» сообщение компилятора, потому что превращает T? в T. Но цена этого — риск падения в рантайме, причём часто в месте, где вы этого не ожидаете (особенно если значение пришло из ввода). На вашем этапе гораздо правильнее воспринимать !! как редкий инструмент для ситуаций, где падение при null — осознанный контракт, а не как универсальный молоток для всех гвоздей.
Ошибка №5: путать null и пустую строку и из-за этого странно комбинировать ?. и ?:.
Если вы не договорились, что означает «пользователь ничего не ввёл», вы будете получать сюрпризы. Пустая строка "" — это значение, и ?. к нему вообще не относится (она не null). Поэтому часто полезно явно нормализовать ввод, превращая «пусто после trim()» в null. Тогда ?. и ?: начинают работать ровно так, как вы ожидаете, и программа становится предсказуемой.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ