JavaRush /Курсы /Kotlin SELF /Предусловия и fail-fast: require/check и guard clauses

Предусловия и fail-fast: require/check и guard clauses

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

1. Почему иногда лучше упасть сразу, чем притвориться, что всё нормально

Если вы когда-нибудь видели баг, который проявляется «раз в три недели по четвергам, но только если Луна в Козероге», то вы уже знакомы с главной проблемой: программа где-то давно пошла не туда, но продолжила жить, делая вид, что всё нормально. Fail-fast — это привычка останавливать выполнение сразу, когда стало ясно: дальше логика не имеет смысла. Это как пожарная сигнализация: она мешает спать, зато хорошо объясняет, что поспать уже не получится.

В наших утилитах ввода/парсинга/валидации мы уже выбрали мягкую стратегию: на ожидаемые ошибки данных возвращаем null. Но есть и другой класс проблем: ошибка использования функции. Например, вы написали валидатор диапазона, а кто-то (часто вы же) случайно передал min=100, max=10. Это не «пользователь ввёл не то», это «программист перепутал границы». И тут null уже плохо помогает: он делает ошибку тихой.

Давайте зафиксируем разницу:

Ситуация Пример Нормальная реакция в нашем дне
Ожидаемая проблема данных (пользовательский ввод) Ввели "qwerty" вместо числа Вернуть null, сценарий объяснит и решит, что делать
Нарушение предусловий (ошибка программиста) Передали диапазон min > max Fail-fast через require/check

Именно для второго случая в Kotlin есть «предусловия‑функции» require() и check() — они автоматически бросают исключение и останавливают выполнение, когда условие не выполнено.

2. Предусловия: что это такое

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

Предусловие удобно формулировать так, будто вы пишете короткую записку будущему себе: «Эта функция работает только если …». И если условие не соблюдено — пусть программа упадёт сразу, громко и с понятным сообщением. Это неприятно, но очень честно: «я не могу продолжать, потому что входные условия неправильные».

Два типа предусловий: аргументы и состояние

Часто предусловия делятся на два вида, и Kotlin специально даёт под них две разные функции. Эта граница не математически идеальна, но в реальной жизни очень помогает держать стиль в голове и не превращать проверки в кашу.

Предусловия по аргументам — это когда функция получила параметры, и вы хотите убедиться, что они корректны: например, min <= max, «скидка от 0 до 100», «размер > 0». Для этого обычно используется require(...), и при нарушении он бросает IllegalArgumentException.

Предусловия по состоянию — это когда аргументы вроде нормальные, но внутри программы есть некое состояние, и функция предполагает, что оно уже подготовлено: «сессия начата», «настройки загружены», «сначала вызови init». Для этого обычно используется check(...), и при нарушении он бросает IllegalStateException.

3. require: проверяем аргументы и падаем с IllegalArgumentException

require(condition) { message } — это стандартный способ сказать: «Вы вызываете функцию неправильно, потому что аргументы не подходят». Kotlin в таком случае бросает IllegalArgumentException. Это именно та ситуация, где ошибка должна быть максимально заметной: если аргументы неверны, значит, проблема в коде, а не в пользователе.

Важно: require — не инструмент для «пользователь ввёл буквы вместо цифр». Для этого у нас уже есть …OrNull и обработка на уровне сценария. require — это инструмент «программист передал ерунду».

Валидатор диапазона с защитой от min > max

Мы уже писали валидаторы вроде «проверить, что число в диапазоне». Теперь усилим их: добавим предусловие, что сам диапазон задан корректно.

fun validateIntInRangeOrNull(x: Int, min: Int, max: Int): Int? {
    require(min <= max) { "Ожидали min <= max, но получили min=$min, max=$max" }

    if (x < min) return null
    if (x > max) return null
    return x
}

Обратите внимание на тонкую, но важную философию: если x «не попал» — это нормальная ситуация данных, поэтому null. А если диапазон задан абсурдно — это не «данные», это «кто-то (возможно, я) ошибся в коде», поэтому fail-fast.

Утилита чтения числа в диапазоне

Давайте накинем ещё одну функцию уровня «сценарий», которая комбинирует чтение и валидацию. Здесь require особенно полезен, потому что мы передаём границы диапазона не пользователем, а программистом.

fun readIntOrNull(prompt: String): Int? {
    print(prompt)
    val s = readln()
    return s.toIntOrNull()
}

fun readIntInRangeOrNull(prompt: String, min: Int, max: Int): Int? {
    require(min <= max) { "Ожидали min <= max, но получили min=$min, max=$max" }

    val x = readIntOrNull(prompt) ?: return null
    return validateIntInRangeOrNull(x, min, max)
}

Если кто-то вызовет readIntInRangeOrNull("Возраст: ", 150, 0), программа упадёт сразу — и это хорошо. Потому что иначе вы получите «странные» сценарии, где всё время возвращается null, и вы начинаете подозревать пользователя, клавиатуру и ретроградный Меркурий.

4. check: проверяем состояние и падаем с IllegalStateException

check(condition) { message } — это способ сказать: «Я ожидаю, что внутри программы сейчас корректное состояние, иначе это логическая ошибка». Kotlin в этом случае бросает IllegalStateException. Если require чаще ловит «плохой вызов снаружи», то check ловит «мы сами себя загнали в угол».

В консольных программах новичкового уровня состояние часто выглядит как одна-две переменные: «настройки заданы», «имя пользователя уже введено», «режим выбран». Это уже достаточно, чтобы увидеть пользу check.

Пример: сессия должна быть начата

Сделаем очень учебный, но показательный пример. Представим, что у нас есть мини‑программа, где нужно сначала «начать сессию», а потом задавать вопросы.

var sessionStarted: Boolean = false

fun startSession() {
    sessionStarted = true
}

fun askUserName(): String {
    check(sessionStarted) { "Сессия не начата. Сначала вызовите startSession()." }

    print("Ваше имя: ")
    return readln()
}

Здесь пользователь не виноват вообще. Он даже не знает, что такое startSession(). Это наш внутренний протокол. Если мы нарушили порядок вызовов — это логическая ошибка, и лучше увидеть её немедленно.

Почему тут не require

Можно было бы написать require(sessionStarted), и технически оно тоже упадёт. Но смысл будет хуже: IllegalArgumentException намекает «ты дал плохие аргументы», а проблема не в аргументах, а в том, что «программа находится в неправильном состоянии». check выражает это точнее, а точность — это половина хорошей диагностики.

Сообщения в require/check: пишем так, чтобы себе же было не стыдно

Сообщение в require/check — это не «украшение», а половина пользы. Если вы напишете просто require(min <= max), то при падении вы получите исключение, но без контекста: что именно было передано? В маленьких программах это ещё можно догадаться, а в реальных — вы будете благодарны себе за подробности.

Хорошее сообщение обычно содержит две части: «что ожидали» и «что получили». Это звучит как занудство, но это занудство, которое экономит часы.

fun validateIntInRangeOrNull(x: Int, min: Int, max: Int): Int? {
    require(min <= max) { "Ожидали min <= max, но получили min=$min, max=$max" }

    if (x < min) return null
    if (x > max) return null
    return x
}

Если оно упадёт — вы сразу увидите конкретные числа. Это особенно важно, когда диапазоны собираются не «в лоб», а вычисляются где-то выше (пусть даже из других переменных).

5. Guard clauses: как перестать писать пирамиду из if

Представьте типичный код новичка (и не только новичка, если честно): проверка условия, внутри ещё проверка, внутри ещё… Получается «пирамида», которая визуально уезжает вправо и вызывает головную боль. Guard clauses (охранные проверки) — это стиль, где вы делаете проверки в начале и выходите из функции сразу, если что-то не так. Код становится линейным: читается сверху вниз как список препятствий на пути к основной логике.

Важно: guard clauses — это не отдельная конструкция языка. Это просто привычка писать if (...) return ... вместо вложенных блоков.

Было: вложенность и лесенка вправо

Допустим, мы хотим прочитать ширину и высоту прямоугольника (1..100) и посчитать площадь. Вот версия, от которой глаза начинают искать кнопку «Undo»:

fun rectangleAreaScenario() {
    val w = readIntOrNull("Ширина: ")
    if (w != null) {
        val h = readIntOrNull("Высота: ")
        if (h != null) {
            if (w in 1..100) {
                if (h in 1..100) {
                    println("Площадь = ${w * h}")
                } else {
                    println("Высота вне диапазона.")
                }
            } else {
                println("Ширина вне диапазона.")
            }
        } else {
            println("Высота не число.")
        }
    } else {
        println("Ширина не число.")
    }
}

Код вроде правильный, но читать его — как разбирать матрёшку: пока не откроешь все уровни, не увидишь, где же там площадь.

Стало: guard clauses и ранний выход

Теперь перепишем так, чтобы основной смысл («посчитать площадь») был виден быстро, а проверки не создавали пирамиду.

fun rectangleAreaScenario() {
    val w = readIntOrNull("Ширина (1..100): ") ?: run {
        println("Ширина должна быть целым числом.")
        return
    }
    if (w !in 1..100) {
        println("Ширина вне диапазона 1..100.")
        return
    }

    val h = readIntOrNull("Высота (1..100): ") ?: run {
        println("Высота должна быть целым числом.")
        return
    }
    if (h !in 1..100) {
        println("Высота вне диапазона 1..100.")
        return
    }

    println("Площадь = ${w * h}") // например: Площадь = 12
}

Заметьте, как приятно стало читать: если что-то не так — мы сразу выходим и объясняем. Если дошли до конца — значит, всё хорошо, и считаем площадь.

Как guard clauses сочетаются с …OrNull и require/check

Тут легко запутаться, поэтому зафиксируем мысль в виде маленькой схемы. Мы используем null и guard clauses, когда «не получилось получить корректное значение». Мы используем require/check, когда «функция вызвана/использована неправильно».

flowchart TD
    A[Начало функции] --> B{Проблема ожидаемая?}
    B -->|Пользовательский ввод, формат, диапазон| C[Вернуть null / выйти через guard clause]
    B -->|Нарушены предусловия функции| D[require/check -> исключение]
    C --> E[Сценарий решает, что печатать]
    D --> F[Сразу видно баг в коде]

И да: fail-fast не отменяет аккуратную обработку null. Это два инструмента для разных типов проблем.

6. Встраиваем fail-fast в мини‑API: аккуратно, но уверенно

Сейчас мы сделаем маленький шаг в сторону более «взрослого» кода: наши утилиты будут не только возвращать null на ожидаемые проблемы, но и защищаться от неправильного использования. Мы по-прежнему остаёмся в пределах дня: без циклов повторного ввода и без сложной архитектуры — просто дисциплина.

Соберём мини‑набор функций (упрощённый, но связный) и используем его в main().

Нормальная композиция: чтение + валидация + guard clauses

fun readIntOrNull(prompt: String): Int? {
    print(prompt)
    return readln().toIntOrNull()
}

fun validateIntInRangeOrNull(x: Int, min: Int, max: Int): Int? {
    require(min <= max) { "Ожидали min <= max, но получили min=$min, max=$max" }

    if (x < min) return null
    if (x > max) return null
    return x
}

fun readIntInRangeOrNull(prompt: String, min: Int, max: Int): Int? {
    require(min <= max) { "Ожидали min <= max, но получили min=$min, max=$max" }

    val x = readIntOrNull(prompt) ?: return null
    return validateIntInRangeOrNull(x, min, max)
}

Обратите внимание: мы сознательно дублируем require(min <= max) в двух функциях. Да, это повтор. Но это «страховка на месте вызова»: если вы решите поменять одну функцию или использовать другую отдельно, каждая будет защищена. На практике позже можно будет рефакторить, но сейчас важнее ясность.

Мини‑сценарий в main(): площадь

fun main() {
    val w = readIntInRangeOrNull("Ширина (1..100): ", 1, 100) ?: run {
        println("Некорректная ширина.")
        return
    }
    val h = readIntInRangeOrNull("Высота (1..100): ", 1, 100) ?: run {
        println("Некорректная высота.")
        return
    }

    val area = w * h
    println("Площадь = $area") // например: Площадь = 12
}

Это не супер‑приложение века, но оно идеально показывает стиль: утилиты «тихие», сценарий «говорящий», предусловия защищают нас от глупых вызовов.

Пример check: инициализация обязательна

Добавим к этому мини‑пример состояния. Представим, что мы хотим один раз настроить границы, а потом читать значения по этим границам. Мы не делаем здесь «настоящую конфигурацию», просто показываем идею check.

var configuredMin: Int? = null
var configuredMax: Int? = null

fun configureLimits(min: Int, max: Int) {
    require(min <= max) { "Ожидали min <= max, но получили min=$min, max=$max" }
    configuredMin = min
    configuredMax = max
}

fun readConfiguredIntOrNull(prompt: String): Int? {
    check(configuredMin != null && configuredMax != null) {
        "Лимиты не настроены. Сначала вызовите configureLimits(min, max)."
    }

    val min = configuredMin!!
    val max = configuredMax!!
    return readIntInRangeOrNull(prompt, min, max)
}

Да, тут есть !!, и в идеальном мире мы бы его избегали. Но для текущего уровня важно другое: check гарантирует, что configuredMin и configuredMax уже заданы, иначе программа упадёт сразу с понятным сообщением. В учебных примерах это нормальная иллюстрация «состояния», а позже мы научимся проектировать так, чтобы !! требовался реже.

7. Типичные ошибки

Ошибка №1: использовать require/check для обычного неверного ввода пользователя.
Очень хочется написать require(x in 1..100), когда пользователь ввёл 200. Но тогда программа будет падать от любого «неправильного» ввода, и пользователь почувствует себя тестировщиком ваших нервов. Для ввода у нас стиль другой: …OrNull и обработка на уровне сценария.

Ошибка №2: «проглатывать» нарушение предусловий через null.
Если ваша функция должна вызываться только с корректными параметрами (например, min <= max), возвращать null — плохая идея. Вы превратите явную ошибку в тихую, и потом будете долго искать, почему «всё время возвращается null». Такие ситуации должны падать сразу через require или check.

Ошибка №3: писать require(condition) без сообщения.
Технически это работает, но диагностически — почти бесполезно. Сообщение должно объяснять ожидание и показывать фактические значения. Через неделю вы уже не вспомните, что именно проверяли, а вот min=$min, max=$max вспомнить поможет.

Ошибка №4: путать require и check и из-за этого терять смысл исключения.
Если вы проверяете корректность аргументов — это обычно require и IllegalArgumentException. Если вы проверяете внутреннее состояние — это обычно check и IllegalStateException. Kotlin прямо позиционирует эти функции как разные по смыслу и по типу исключения.

Ошибка №5: строить пирамиду из if вместо guard clauses и потом бояться трогать код.
Вложенные if быстро превращают функцию в лабиринт, где легко забыть, какая ветка к чему относится. Guard clauses с ранними return делают код линейным: «если не так — выйди, иначе работай дальше». Это улучшает читаемость и снижает шанс логических ошибок при правках.

Ошибка №6: делать проверки, но продолжать выполнение «на авось».
Иногда пишут что-то вроде if (x == null) println("Ошибка"), но не делают return, и дальше код работает с null или с некорректным значением. Guard clauses ценны именно тем, что после сообщения вы реально выходите, а не продолжаете программировать в режиме «ну вдруг повезёт».

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