1. Зачем нужны nullable‑цепочки
Когда вы только начинаете работать с null, самый естественный ход — писать проверки if (x != null) и внутри делать нужную работу. Это нормально: так думали миллионы программистов до нас, и некоторые до сих пор так думают (иногда даже успешно). Но как только появляется второй шаг обработки, третий, четвёртый — if начинают “расти вниз”, и код превращается в лестницу, которую хочется подпереть табуреткой.
Представьте типичный ввод: пользователь ввёл строку → мы её обрезали → попытались распарсить число → проверили диапазон → посчитали результат. Если это писать через вложенные if, мы получим много уровней отступов и постоянные “а что, если тут null?”. Nullable‑цепочки позволяют записать ту же логику линейно: “вот шаг, вот шаг, вот шаг, а если на каком-то шаге нет значения — вот fallback”.
Наша цель сегодня — научиться двум базовым связкам:
- x?.let { ... } — выполнить блок только если x не null, причём внутри блока it уже не nullable.
- x?.let { ... } ?: ... — линейная запись “если получилось — берём результат, иначе — запасной сценарий”.
И да, Kotlin прямо позиционирует let как одну из функций, которые помогают “упростить выражения” и держать обработку рядом с данными.
2. ?.let { ... }: обрабатываем только если есть значение
let — это scope‑функция, в которой объект доступен как it, а результатом является последнее выражение блока. Важная деталь именно для nullable‑работы: когда вы пишете x?.let { ... }, блок выполнится только если x != null. А внутри let Kotlin уже понимает, что it — не null, и разрешает вызывать методы и свойства без дополнительных проверок.
Начнём с очень простого примера: читаем число, если получилось — печатаем квадрат. Если не получилось — вообще ничего не делаем.
fun main() {
val raw = readln()
val n: Int? = raw.toIntOrNull()
n?.let { value ->
val sq = value * value
println("square = $sq") // если ввели 7 -> square = 49
}
}
Обратите внимание: здесь мы не решаем, что делать в случае null. Это может быть ок, когда действие действительно необязательное (например, отладочная печать, “попробуем — не выйдет, ну и ладно”). Но в реальных сценариях чаще нужно обязательно иметь результат — и тогда одной ?.let мало, нужна связка с Elvis ?:.
Ещё один пример: есть строка, хотим привести её к “нормальному виду” (trim + lowercase), но только если строка вообще существует.
fun main() {
val maybeText: String? = readln().takeIf { it.isNotBlank() }
val normalized: String? = maybeText?.let { it.trim().lowercase() }
println(normalized) // если ввели " HeLLo " -> hello
// если ввели пусто/пробелы -> null
}
Здесь normalized тоже nullable. И это логично: если входа нет, то и нормализовать нечего. Но если дальше по коду вам нужен не nullable текст (например, для отчёта), пора “закрывать” null в конкретное поведение.
3. Шаблон ?.let { ... } ?: fallback
В обычном if-else вы явно прописываете две ветки. Elvis ?: делает то же самое, но в виде выражения: слева “основной вариант”, справа “запасной”. А ?.let удобно ставится слева, чтобы основной вариант вычислялся только при наличии значения.
Смысл шаблона:
- слева: “если x не null — вычисли результат”
- справа: “иначе — верни/вычисли fallback”
Пример: читаем число. Если ввели корректно — удваиваем. Если нет — берём 0.
fun main() {
val raw = readln()
val n: Int? = raw.toIntOrNull()
val doubled: Int = n?.let { it * 2 } ?: 0
println("doubled = $doubled") // ввели 21 -> doubled = 42
// ввели "котик" -> doubled = 0
}
Ключевой момент: doubled — не nullable, потому что справа от ?: мы дали конкретное значение 0. То есть мы “закрыли” неопределённость.
Очень частая практическая история: вы хотите считать пустую строку “как отсутствие”, а не как реальный ввод. Тогда хорошо работает связка takeIf → ?.let → ?:.
fun main() {
val token: String? = readln()
.trim()
.takeIf { it.isNotEmpty() }
val lower: String = token?.let { it.lowercase() } ?: "<empty>"
println(lower) // ввели " Kotlin " -> kotlin
// ввели "" -> <empty>
}
Здесь takeIf превращает “плохое значение” в null, а ?: превращает null в понятный дефолт.
Чтобы не держать всё это в голове как “волшебное заклинание”, зафиксируем мини‑таблицу:
| Запись | Когда выполняется блок | Что возвращается | Типичный смысл |
|---|---|---|---|
|
только если x != null | результат блока, но nullable‑цепочка может продолжиться | “попробовать обработать, если есть” |
|
блок — если есть x, иначе fallback | всегда значение (обычно non‑null) | “нормальный результат или запасной” |
4. Elvis ?: как “контракт”: результат обязателен
Elvis‑оператор ?: многие сначала воспринимают как “ну это типа как null заменить на 0”. И да, это один из вариантов. Но более взрослый взгляд — это инструмент, который помогает зафиксировать контракт: “в этом месте программа обязана получить значение”.
Например, вы пишете функцию readTopN(): Int, и она должна вернуть нормальное число (не nullable), потому что дальше вы хотите использовать topN без проверок. Тогда внутри вы можете работать с Int?, но на выходе — закрыть null дефолтом.
fun readTopN(): Int {
val raw = readln().trim()
val n: Int? = raw.toIntOrNull()
return n?.takeIf { it > 0 } ?: 5
}
fun main() {
val topN = readTopN()
println("topN = $topN") // ввели 3 -> topN = 3
// ввели -10 -> topN = 5
// ввели "abc" -> topN = 5
}
В этом примере важна деталь: takeIf { it > 0 } тоже может вернуть null, если число не подходит. И это хорошо: мы честно говорим “не подходит — значит, значения как будто нет”, а потом Elvis выбирает дефолт.
Небольшой, но полезный факт: Elvis в Kotlin часто используют не только для значений, но и чтобы сделать ранний return или throw — потому что справа может стоять любое выражение. Мы глубоко в исключения сейчас не полезем, но “ранний выход” нам сегодня пригодится.
5. run { ... } как многострочный fallback
Иногда справа от ?: недостаточно написать 0 или "<empty>". Иногда вы хотите: вывести сообщение пользователю, выбрать значение по умолчанию, возможно, что-то подготовить — и только потом вернуть результат. И вот тут многие новички пытаются написать что-то странное вроде:
// так не надо, это пример "как можно запутаться"
val x = n ?: println("bad") // println возвращает Unit, и всё начинает ломаться
Чтобы справа от Elvis сделать несколько действий, используйте run { ... }. Это обычная функция, которая выполняет блок и возвращает последнее выражение. То есть она идеально подходит как “контейнер” для многострочного fallback.
fun main() {
val n: Int? = readln().toIntOrNull()
val value: Int = n ?: run {
println("Это не число, возьму значение по умолчанию") // сообщение
10 // последнее выражение = результат
}
println("value = $value") // ввели 5 -> value = 5
// ввели "hi" -> сообщение + value = 10
}
Обратите внимание: run { ... } здесь — не “scope‑функция объекта”, а просто блок‑выражение. Мы не обращаемся к this, нам это не нужно. Нам нужно “несколько строк кода, которые вычисляют значение”.
Этот же приём отлично работает для “раннего выхода” из main, если пользователь не дал обязательные данные.
fun main() {
val textOrNull: String? = readln().trim().takeIf { it.isNotBlank() }
val text: String = textOrNull ?: run {
println("Пустой ввод — анализировать нечего. Завершаюсь.")
return
}
println("Будем анализировать: $text") // если дошли сюда, text точно не пустой
}
Заметьте, как читабельно получается: слева мы описали “нормальный путь”, справа — “что делаем, если данных нет”. Без вложенных if, без дополнительного уровня отступов.
6. Заготовка для консольного Text Analyzer
Сейчас мы сделаем маленький, но очень практичный шаг в сторону будущего Text Analyzer: научимся читать обязательный текст и опциональные настройки так, чтобы main был похож на сценарий, а не на “комбинат проверок”. Мы пока не считаем частоты и не строим top‑N — это будет следующая лекция. Но мы подготовим вход так, чтобы потом было легко добавить пайплайн.
Сначала сделаем утилиту: прочитать непустую строку или вернуть null. Она маленькая, но уже снимает половину боли.
fun readNonBlankLineOrNull(prompt: String): String? {
print(prompt)
return readln().trim().takeIf { it.isNotBlank() }
}
fun main() {
val text: String = readNonBlankLineOrNull("Введите текст для анализа: ") ?: run {
println("Пусто. Значит, сегодня без анализа.")
return
}
println("Ок, текст принят: \"$text\"")
}
Теперь добавим “настройку”: пользователь может ввести topN, а может оставить пустым. Если пусто или мусор — берём дефолт, например 5. Здесь нам пригодится ?.let и Elvis.
fun readIntOrNull(prompt: String): Int? {
print(prompt)
return readln().trim().toIntOrNull()
}
fun main() {
val text: String = readNonBlankLineOrNull("Введите текст для анализа: ") ?: run {
println("Пусто. Значит, сегодня без анализа.")
return
}
val topN: Int = readIntOrNull("Сколько слов показать (Enter = 5): ")
?.takeIf { it > 0 }
?: 5
println("Текст: \"$text\"") // пример: Текст: "kotlin is great"
println("topN = $topN") // пример: topN = 5
}
И вот здесь важно почувствовать “вкус” подхода: topN на выходе не nullable, значит дальше, в следующих лекциях, вы будете спокойно использовать topN без if (topN != null).
Если вы сейчас подумали “а можно короче?”, то да, можно. Но наша цель — не минимальная длина строки, а читаемый сценарий.
7. Где закрывать null, а где улучшать читаемость
Очень типичная ошибка новичка — случайно протащить nullable слишком далеко, а потом удивляться, что Kotlin “везде просит ?.”. На самом деле Kotlin не вредничает: он честно напоминает, что вы сами не определились, что значит “нет значения”.
Есть два здоровых варианта.
- Вариант 1. Вы осознанно храните T? дальше, потому что “отсутствие значения” — нормальное состояние. Например, в настройках: пользователь может не указать фильтр, и тогда фильтра нет. В этом случае nullable — часть контракта, и это нормально.
- Вариант 2. Вы “закрываете” nullable в конкретное поведение: дефолтное значение, сообщение + return, или “фейлимся” (но это уже ближе к require/check и исключениям — мы сегодня аккуратны).
Практическая подсказка: если функция по смыслу должна “дать результат для продолжения сценария”, старайтесь возвращать не nullable, а все nullable‑штуки прятать внутри. Например:
fun readPositiveIntOrDefault(prompt: String, default: Int): Int {
print(prompt)
val n: Int? = readln().trim().toIntOrNull()
return n?.takeIf { it > 0 } ?: default
}
fun main() {
val topN = readPositiveIntOrDefault("Top N (Enter = 5): ", default = 5)
println("topN = $topN") // Enter -> topN = 5
}
Nullable‑цепочки — классные, пока они остаются цепочками из 2–4 шагов. Но Kotlin позволяет написать и 14 шагов в одну строку. И вот тут начинаются “криптограммы”: код формально правильный, но его страшно трогать.
Есть простой критерий: если вы внутри let начинаете писать мини‑алгоритм или появляется второй вложенный let — лучше остановиться и сделать код чуть более “плоским”.
Например, так читать тяжело (особенно новичку):
fun main() {
val result = readln().trim().takeIf { it.isNotBlank() }
?.let { it.toIntOrNull() }
?.let { it * 10 }
?: 0
println(result)
}
Да, это работает. Но глазами не сразу понятно, что происходит на каждом шаге. Лучше дать значениям имена:
fun main() {
val raw: String = readln()
val token: String? = raw.trim().takeIf { it.isNotBlank() }
val n: Int? = token?.toIntOrNull()
val result: Int = n?.let { value -> value * 10 } ?: 0
println(result) // ввели "7" -> 70
}
Этот вариант длиннее на пару строк, зато его можно объяснять человеку голосом, не запинаясь.
Ещё один маленький лайфхак: если в цепочке есть it, и вы чувствуете, что “it уже не тот” — просто назовите параметр.
val message: String = readIntOrNull("Введите возраст: ")
?.let { age -> "Вам $age лет" }
?: "Возраст не распознан"
println(message)
С age читать легче, чем с “пятым it за день”.
8. Типичные ошибки
Ошибка №1: написать x?.let { ... } и забыть, что результат всё ещё может быть null.
Это происходит постоянно: человек радуется, что убрал if, а потом удивляется, что дальше снова нужны ?. и ?:. Лекарство простое: если вам дальше нужен не nullable результат, сразу закрывайте цепочку Elvis‑оператором и выбирайте конкретное поведение.
Ошибка №2: использовать !! там, где нужен Elvis.
!! — это “я клянусь, тут точно не null, честное программистское”. Иногда это оправдано, но в пользовательском вводе почти никогда. Если значение может отсутствовать — лучше ?: с дефолтом или ?: run { ...; return }. Тогда программа ведёт себя предсказуемо, а не падает при первом же “котик” вместо числа.
Ошибка №3: пытаться сделать fallback многострочным без run { ... } и случайно получить Unit вместо значения.
Когда вы справа от ?: вызываете println(...), вы должны помнить, что println возвращает Unit. Поэтому выражение типа val x = n ?: println(...) либо не скомпилируется, либо приведёт к странным типам. Если нужно и напечатать, и вернуть значение — оборачивайте в run { ... }.
Ошибка №4: превращать цепочки в “волшебный конвейер”, который никто не понимает.
Scope‑функции и nullable‑цепочки — инструмент читаемости, а не соревнование по минификации. Если цепочка стала длинной, если it повторяется и путается, если вы сами не уверены, какой тип сейчас “течёт” по трубе — остановитесь, введите промежуточные val или вынесите шаг в именованную функцию. Код должен быть понятен не только компилятору, но и вам завтра утром.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ