JavaRush /Курси /Kotlin SELF /Передумови та перевірка стану: require і check

Передумови та перевірка стану: require і check

Kotlin SELF
Рівень 31 , Лекція 4
Відкрита

1. Fail-fast і різниця між require та check

Навіщо взагалі «падати раніше»: ідея fail-fast

Якщо ви лише починаєте, може здаватися, що хороша програма має бути ввічливою й терплячою: «ну, користувач увів нісенітницю — давайте тихо підставимо 0, порожній рядок і якось поїдемо далі». На практиці така «доброта» часто обертається накопиченням помилок. Програма ніби працює, але дані поступово перетворюються на сміття — і зрештою ламається все… просто значно пізніше й набагато менш зрозуміло.

Підхід fail-fast (буквально «падай швидко») каже таке: якщо далі логіка не має сенсу, краще зупинитися одразу й пояснити, що саме пішло не так. Kotlin безпосередньо підтримує цей стиль стандартними функціями-передумовами. Вони автоматично викидають винятки з очевидним типом і зрозумілим повідомленням.

Щоб склалася цілісна картинка, ось невелика схема (вона саме «про зміст», а не про синтаксис):

flowchart TD
    A[Отримали дані] --> B{Дані придатні?}
    B -- Ні --> C[Fail-fast: require/check -> виняток]
    B -- Так --> D[Продовжуємо логіку]
    D --> E{Стан коректний?}
    E -- Ні --> F[Fail-fast: check -> виняток]
    E -- Так --> G[Результат передбачуваний]

Найприємніший бонус fail-fast у навчальному проєкті: ви перестаєте гадати «чому воно дивно рахує» й починаєте отримувати відповіді рівня «Age must be >= 0, got -5». Дивовижно, наскільки це зменшує бажання жбурнути ноутбук у вікно (перевірено: вікно ні в чому не винне).

require vs check: схоже, але сенс різний

Зовні require(condition) і check(condition) виглядають майже однаково: ви передаєте умову й (зазвичай) повідомлення. Але сенс у них різний — і це важливо. Сенс перетворюється на тип винятку, а тип винятку — це підказка і для вас, і для людини, яка читатиме код після вас.

require() використовують, щоб перевіряти коректність вхідних аргументів, а check() — щоб перевіряти коректність стану об’єкта або змінної. Якщо порушується require(), вилітає IllegalArgumentException, а якщо порушується check()IllegalStateException.

Зведемо це в таблицю (її справді варто тримати в голові):

Запитання, яке ви ставите Що використати Типова думка Виняток
«Мені передали погані дані, я не можу продовжувати»
require(...)
«Аргумент неправильний»
IllegalArgumentException
«Дані нормальні, але об’єкт зараз у неможливому/забороненому стані»
check(...)
«Стан неправильний»
IllegalStateException

Дуже груба, але така, що добре запам’ятовується, аналогія: require — це фейс-контроль на вході в клуб («у кедах не можна»), а check — охорона всередині («у басейн не можна з тостером»). І якщо вам здається, що «і там, і там не можна», ви маєте рацію. Просто причини цього «не можна» різні.

2. require: захищаємо вхід

Коли ви створюєте об’єкт, хочеться, щоб він одразу був коректним. Для початківця це одна з ключових ідей ООП: якщо об’єкт існує, йому можна довіряти. У Kotlin типовий спосіб забезпечити це — поставити перевірки в init { ... } або прямо в логіці конструктора.

Мінімальний приклад require у init

Почнімо з максимально простого прикладу, щоб «набити руку»:

class Age(val value: Int) {
    init {
        require(value >= 0) { "Вік має бути >= 0, отримано $value" }
    }
}

fun main() {
    println(Age(10).value) // 10
}

Тут ми зафіксували правило: вік не може бути від’ємним. Важливо, що ми не намагаємося «полагодити» значення, а забороняємо створювати некоректний об’єкт. І це нормально: якщо вам прилетіло -10, це не «майже вік», а помилка на вході.

Приклад із навчального проєкту: трекер витрат

Продовжімо практичним прикладом: консольним трекером витрат. Раніше ми могли зберігати витрату як набір полів, а тепер хочемо, щоб об’єкт Expense не міг існувати в «сміттєвому» вигляді.

Зробімо невеликий клас:

class Expense(
    val amountCents: Int,
    val category: String,
    val note: String
) {
    init {
        require(amountCents > 0) { "amountCents має бути > 0, отримано $amountCents" }
        require(category.isNotBlank()) { "category не має бути порожнім" }
        require(note.length <= 100) { "note має бути <= 100 символів, отримано ${note.length}" }
    }
}

Зверніть увагу на стиль: повідомлення має пояснювати, що очікували, і що отримали. Це не про естетику — це про швидкість налагодження.

Тепер створення:

fun main() {
    val e = Expense(amountCents = 1999, category = "Food", note = "Pizza")
    println("${e.category}: ${e.amountCents} центів") // Food: 1999 центів
}

Якщо хтось спробує створити Expense(amountCents = 0, ...), об’єкт не створиться — і це правильно. Нульова витрата найчастіше означає, що ми десь не розпарсили число або переплутали валюту.

require підходить і для звичайних методів

Передумови потрібні не лише під час створення об’єкта. Якщо у вас є метод, який приймає аргументи ззовні, ви теж можете захистити вхід за допомогою require. Це якраз про аргументи, без яких функція не може працювати.

Уявімо, що в нас є сервісний метод для форматування суми:

fun formatCents(amountCents: Int): String {
    require(amountCents >= 0) { "amountCents має бути >= 0, отримано $amountCents" }
    val dollars = amountCents / 100
    val cents = amountCents % 100
    return "$dollars.${cents.toString().padStart(2, '0')}"
}

fun main() {
    println(formatCents(1999)) // 19.99
}

Так, можна було б «мовчки» перетворити -5 на 0. Але тоді ви втратите сигнал про те, що десь у вас помилка.

3. check: захищаємо стан об’єкта

Тепер перейдемо до check. Він потрібен тоді, коли вхідні дані нормальні, але сам об’єкт зараз не має виконувати операцію. Таке часто трапляється в об’єктах із «режимом роботи»: сесію може бути закрито, файл — уже збережено й «sealed», звіт — уже фіналізовано.

check() — це перевірка коректності стану об’єкта або змінної; у разі провалу він викидає IllegalStateException.

Приклад: «закритий» трекер витрат

Додамо в наш проєкт клас, який зберігає список витрат. Ми ще не обговорювали колекції об’єктів глибоко, тож зробимо максимально просто: список плюс прапорець closed.

class ExpenseTracker {
    private val expenses: MutableList<Expense> = mutableListOf()
    private var closed: Boolean = false

    fun close() {
        check(!closed) { "Трекер уже закрито" }
        closed = true
    }

    fun add(expense: Expense) {
        check(!closed) { "Не можна додати витрату: трекер закрито" }
        expenses.add(expense)
    }

    fun totalCents(): Int {
        var sum = 0
        for (e in expenses) sum += e.amountCents
        return sum
    }
}

Подивіться, що ми зробили: вхід у add(expense) як такий коректний (об’єкт Expense уже гарантовано валідний). Але додавати його не можна, якщо трекер закрито. Це і є перевірка стану.

Як це виглядає під час використання

fun main() {
    val tracker = ExpenseTracker()
    tracker.add(Expense(500, "Coffee", "Latte"))
    tracker.close()

    println(tracker.totalCents()) // 500

    // tracker.add(Expense(100, "Snack", "Bar"))
    // Якщо розкоментувати: IllegalStateException з check(...)
}

Чим це добре: помилка повідомляє, що проблема не у витраті й не у введенні, а в життєвому циклі об’єкта. Такий баг зазвичай лікується не «підстав інше число», а «не викликай метод у цьому стані».

4. Повідомлення та обробка винятків

Повідомлення в require/check: як зробити виняток корисним

Коли початківці пишуть require(x > 0) без повідомлення, здається, що «і так усе зрозуміло». Але за два тижні (або за дві години, якщо день був важкий) ви побачите в консолі щось на кшталт «Failed requirement.» і намагатиметеся згадати, який саме requirement і де саме він failed.

Kotlin дозволяє задавати повідомлення як рядок або як лямбду { ... }. Лямбда зручна тим, що обчислюється лише тоді, коли перевірка провалилася (тобто в нормальному випадку не витрачає ресурси).

Хороше повідомлення — це майже завжди формат: очікували → отримали.

fun parseAmountCents(text: String): Int {
    val trimmed = text.trim()
    val value = trimmed.toIntOrNull()
    require(value != null) { "Сума має бути цілим числом, отримано '$text'" }
    require(value > 0) { "Сума має бути > 0, отримано $value" }
    return value
}

fun main() {
    println(parseAmountCents(" 150 ")) // 150
}

Тут дві різні помилки: «це не число» і «це число, але неправильне». І повідомлення теж різні — це економить нерви під час налагодження.

А для check повідомлення має пояснювати, «чому цей стан заборонений»:

class Session {
    private var started: Boolean = false

    fun start() {
        check(!started) { "Сесію вже розпочато" }
        started = true
    }
}

Це майже ідеальний мінімальний приклад: аргументів немає, а операцію заборонено через стан.

Як акуратно подружити require/check із консольним main

Fail-fast добре працює всередині вашої моделі, але консольний застосунок не зобов’язаний «падати» гарним стек-трейсом від першої ж помилки користувача. Тому часто роблять так: усередині класів — require/check, а на рівні main — акуратне перехоплення й зрозуміле повідомлення для людини.

Це не суперечність. Це розділення обов’язків: модель каже «так не можна», а CLI вирішує «як пояснити це користувачу».

Зберімо невеликий приклад: користувач вводить суму, категорію, нотатку, а ми додаємо витрату.

fun main() {
    val tracker = ExpenseTracker()

    try {
        print("Сума (центи): ")
        val amount = parseAmountCents(readln())

        print("Категорія: ")
        val category = readln()

        print("Нотатка: ")
        val note = readln()

        tracker.add(Expense(amount, category, note))
        println("Додано!") // Додано!
        println("Разом = ${tracker.totalCents()} центів") // Разом = ... центів
    } catch (e: IllegalArgumentException) {
        println("Помилка введення: ${e.message}")
    } catch (e: IllegalStateException) {
        println("Помилка стану застосунку: ${e.message}")
    }
}

Тут добре видно користь різних винятків: IllegalArgumentException ми трактуємо як проблему введення, а IllegalStateException — як проблему порядку дій. Саме заради цього смислового розділення require і check взагалі існують.

5. Типові помилки під час використання require/check

Помилка № 1: використовувати check для користувацького введення.
Таке часто трапляється за звичкою: «ну я ж перевіряю умову — значить, check». Але сенс у check інший. Це про ситуацію «ми самі довели об’єкт до неможливого стану». Якщо користувач увів -5, це не «стан об’єкта поганий», а «аргумент поганий», тобто потрібен require. Kotlin якраз розділяє ці випадки й навіть викидає різні винятки: require дає IllegalArgumentException, а checkIllegalStateException.

Помилка № 2: робити повідомлення марними («bad input», «error», «oops»).
Коли ви пишете повідомлення в стилі «wrong value», ви виграєте рівно нуль секунд. Повідомлення має працювати як діагностика: що очікували й що отримали. Ідеальний формат: «X must be …, got …». Особливо корисно додавати фактичне значення та довжину рядка, якщо перевіряєте текст.

Помилка № 3: ставити перевірки надто пізно — уже після зміни стану.
Наприклад, метод встиг додати витрату до списку, потім перевірив, що трекер закрито, і впав. У підсумку він упав, але дані вже частково змінено. Тому перевірки require/check майже завжди ставлять на початку методу — до будь-яких змін. Так код стає передбачуваним: або операцію повністю виконано, або її взагалі не розпочато.

Помилка № 4: намагатися «полагодити все» нормалізацією там, де потрібна жорстка валідація.
Іноді хочеться замінити require(amount > 0) на «якщо менше нуля — зробимо нуль». Але так ви перетворюєте помилки на тихі баги. Нормалізація корисна для речей на кшталт trim() і приведення регістру, але для ключових чисел та ідентифікаторів частіше безпечніше fail-fast: не створювати об’єкт, якщо він за змістом не може існувати.

Помилка № 5: зберігати в об’єкті некоректний стан і сподіватися «потім виправимо».
Якщо ваш Expense може існувати з порожньою категорією, то рано чи пізно ви забудете це виправити — і він «доживе» до звіту, збереження або сортування. Правило «створили — значить, коректний» сильно спрощує мислення про код. Тому перевірки в init — не занудство, а інвестиція у спокійний сон.

1
Опитування
Конструктори та <code>init</code>, рівень 31, лекція 4
Недоступний
Конструктори та init
Конструктори та init
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ