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 цінні саме тим, що після повідомлення ви справді виходите, а не продовжуєте програмувати в режимі «а раптом пощастить».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ