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 — це фейс-контроль на вході в клуб («у кедах не можна»), а 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, а check — IllegalStateException.
Помилка № 2: робити повідомлення марними («bad input», «error», «oops»).
Коли ви пишете повідомлення в стилі «wrong value», ви виграєте рівно нуль секунд. Повідомлення має працювати як діагностика: що очікували й що отримали. Ідеальний формат: «X must be …, got …». Особливо корисно додавати фактичне значення та довжину рядка, якщо перевіряєте текст.
Помилка № 3: ставити перевірки надто пізно — уже після зміни стану.
Наприклад, метод встиг додати витрату до списку, потім перевірив, що трекер закрито, і впав. У підсумку він упав, але дані вже частково змінено. Тому перевірки require/check майже завжди ставлять на початку методу — до будь-яких змін. Так код стає передбачуваним: або операцію повністю виконано, або її взагалі не розпочато.
Помилка № 4: намагатися «полагодити все» нормалізацією там, де потрібна жорстка валідація.
Іноді хочеться замінити require(amount > 0) на «якщо менше нуля — зробимо нуль». Але так ви перетворюєте помилки на тихі баги. Нормалізація корисна для речей на кшталт trim() і приведення регістру, але для ключових чисел та ідентифікаторів частіше безпечніше fail-fast: не створювати об’єкт, якщо він за змістом не може існувати.
Помилка № 5: зберігати в об’єкті некоректний стан і сподіватися «потім виправимо».
Якщо ваш Expense може існувати з порожньою категорією, то рано чи пізно ви забудете це виправити — і він «доживе» до звіту, збереження або сортування. Правило «створили — значить, коректний» сильно спрощує мислення про код. Тому перевірки в init — не занудство, а інвестиція у спокійний сон.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ