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 цінні саме тим, що після повідомлення ви справді виходите, а не продовжуєте програмувати в режимі «а раптом пощастить».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ