JavaRush /Курсы /Kotlin SELF /Nullable‑цепочки без каскадов if: ?.let, Elvis ?:, run {}...

Nullable‑цепочки без каскадов if: ?.let, Elvis ?:, run {}

Kotlin SELF
28 уровень , 1 лекция
Открыта

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?.let { ... }
только если x != null результат блока, но nullable‑цепочка может продолжиться “попробовать обработать, если есть”
x?.let { ... } ?: fallback
блок — если есть 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 или вынесите шаг в именованную функцию. Код должен быть понятен не только компилятору, но и вам завтра утром.

1
Задача
Kotlin SELF, 28 уровень, 1 лекция
Недоступна
Чистый ввод
Чистый ввод
1
Задача
Kotlin SELF, 28 уровень, 1 лекция
Недоступна
Умная скидка
Умная скидка
1
Задача
Kotlin SELF, 28 уровень, 1 лекция
Недоступна
Имя на бейдже
Имя на бейдже
1
Задача
Kotlin SELF, 28 уровень, 1 лекция
Недоступна
Эхо с повторами
Эхо с повторами
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ