1. Вступ
Коли ми пишемо функцію, зазвичай мислимо так: «Вона отримує параметри й повертає результат». Але реальне життя швидко втручається й приносить свої «подарунки»: «уведення — не число», «нічого не знайдено», «операцію неможливо виконати», «файл не відкрився», «стан зламано». І саме тут починається доросла частина програмування: потрібно домовитися, як функція повідомляє про помилку.
Ця домовленість і називається контрактом помилок: які ситуації вважаються помилками, які з них очікувані та в якій формі їх отримує код, що викликає.
Контракт важливий тому, що без нього код починає жити «за настроєм». В одному місці ви повертаєте null, в іншому — кидаєте виняток, у третьому — друкуєте println("Помилка") і йдете далі, ніби нічого не сталося. Така поведінка особливо небезпечна у великих програмах: помилка або «ховається», або спливає надто пізно — ще й у дивному місці.
Корисна думка, яку варто зафіксувати: помилка — це не «просто впало». Помилка — це ситуація, коли функція не може виконати обіцянку свого контракту.
2. Основні способи сигналізувати про помилку
Коли ви обираєте стратегію оброблення помилок, у вас є два основні інструменти: винятки і помилка як значення.
Виняток
Виняток перериває звичайний потік виконання: «я не можу продовжувати — нехай хтось зовні вирішує». Це потужний механізм, але він схожий на пожежну сигналізацію: чудово, коли справді пожежа, але дивно, коли так повідомляють про те, що закінчився цукор.
«Помилка як значення»
«Помилка як значення» означає, що функція повертає не лише «успіх», а й «неуспіх» звичайним поверненим значенням. Ми вже робили це через sealed class з варіантами Ok і Error: код, що викликає, зобовʼязаний подивитися, що повернулося, і обробити це.
Корисно уявляти різницю так:
flowchart TD
A[Виклик функції] --> B{Що сталося?}
B -->|Успіх| C[Повернули значення]
B -->|Очікувана проблема| D[Повернули Error як значення]
B -->|Аварія або логічна неможливість| E[Кинули виняток]
І ось тут ключ: вибір між D і E — це не «як простіше написати», а який сенс має ситуація.
3. Fail-fast перевірки: require(...) і check(...)
Kotlin дає дуже зручні інструменти для fail-fast поведінки: require(...) і check(...). Зовні вони схожі, але відрізняються за змістом.
require(...): проблема у вхідних даних
Поширена ситуація: функція отримує аргументи, і ви заздалегідь знаєте, що деякі значення — безглузді. Наприклад, «відсоток від загального» за total = 0, або «сума витрати» менша за нуль. Це не штатна гілка сценарію, а некоректний виклик функції.
У таких місцях ідеально підходить require(...): він перевіряє передумову і, якщо її не виконано, кидає виняток про неправильний аргумент.
Мініприклад:
fun percent(part: Int, total: Int): Int {
require(total > 0) { "total must be > 0, got $total" }
require(part >= 0) { "part must be >= 0, got $part" }
return part * 100 / total
}
fun main() {
println(percent(2, 5)) // 40
}
Зверніть увагу на стиль повідомлення: ми пишемо не «ой» і не «помилка», а формулюємо очікування та фактичне значення: got .... Це економить години налагодження, особливо коли ви забудете, що писали цю функцію, і повернетеся до неї за місяць (тобто завтра).
Важливо зрозуміти філософію require: це не «оброблення помилки», це заборона продовжувати, тому що вхідні дані не задовольняють контракт. Тобто ми не намагаємося «якось пережити» некоректні аргументи — ми чесно кажемо: «так не можна».
check(...): проблема у стані
Якщо require(...) — про «код, що викликає, передав погані аргументи», то check(...) — про «усередині програми стан такий, що цього не має відбуватися». Це вже ближче до бага: десь у логіці порушено інваріант.
Kotlin чітко розділяє ці випадки: check(...) кидає виняток про некоректний стан.
Уявімо шматочок нашого практичного проєкту (умовно назвемо його ExpenseTracker), де в нас є «поточна відкрита сесія» (наприклад, активний бюджетний місяць). Якщо код намагається додати витрату, а активний період не вибрано, то це не «користувач винен аргументами функції addExpense(...)». Це стан застосунку не готовий.
class Session(var activeMonth: String?)
fun addExpense(session: Session, title: String) {
check(session.activeMonth != null) { "Active month is not selected" }
println("Added expense '$title' for month ${session.activeMonth}") // приклад
}
fun main() {
val s = Session(activeMonth = "2026-01")
addExpense(s, "Coffee") // Added expense 'Coffee' for month 2026-01
}
Якщо activeMonth не вибрано, продовжувати «ніби все нормально» небезпечно. Краще впасти одразу й голосно, щоб розробник (тобто ви) побачив проблему під час тестування й налагодження, а не отримав «тихий неправильний звіт» у продакшені.
4. Очікувані помилки та результат Ok/Error
Іноді ситуація неприємна, але очікувана: користувач увів неіснуючу команду, попросив видалити витрату за id, якого немає, або запросив звіт за категорією, якої ще не існує.
Це не схоже на «аварію». Радше це «нормальна гілка життя застосунку»: користувач може помилитися, дані можуть бути відсутні — і це не привід влаштовувати феєрверк винятків.
У таких випадках зручніше повертати результат у вигляді явного значення, наприклад:
sealed class CmdResult<out T> {
data class Ok<T>(val value: T) : CmdResult<T>()
data class Error(val message: String) : CmdResult<Nothing>()
}
Чому це зручно?
Тому що код, що викликає, бачить контракт прямо в типі: функція може повернути помилку, і ви зобовʼязані її обробити. Це майже як дорожній знак «Обережно, поворот»: можна проігнорувати, але потім не дивуйтеся.
Мініприклад: «знайти витрату за id». Якщо не знайшли — це не аварія, це очікувано.
data class Expense(val id: Int, val title: String)
fun findExpense(expenses: List<Expense>, id: Int): CmdResult<Expense> {
val found = expenses.find { it.id == id }
?: return CmdResult.Error("Expense not found: id=$id")
return CmdResult.Ok(found)
}
fun main() {
val items = listOf(Expense(1, "Coffee"))
println(findExpense(items, 2)) // Error(message=Expense not found: id=2)
}
Тут важлива ідея: «не знайшли» — це не виняток, бо відсутність елемента часто є частиною сценарію. У консольному застосунку ви зазвичай перетворите Error на зрозумілий текст для користувача й продовжите цикл команд.
Чому null — не універсальна «помилка»
Дуже хочеться зробити так: «якщо щось пішло не так — поверну null». Це спокусливо, бо швидко й не треба оголошувати нові типи. Але в цього підходу є ціна: null майже нічого не говорить про причину.
null добре пасує до ситуації «значення немає» або «не знайдено», коли причина не важлива або й так очевидна. Наприклад, Map[key] повертає V?, і це нормально: «за ключем може не бути значення».
Але щойно вам важливо розрізняти причини, null починає шкодити. Уявіть два випадки:
- користувач увів порожній рядок
- користувач увів "12a"
В обох випадках toIntOrNull() поверне null. А користувачу (і вам під час налагодження) корисно розуміти різницю.
Контракт «мʼякий, без причини» (інколи нормально):
fun parseAmountOrNull(text: String): Int? = text.toIntOrNull()
fun main() {
println(parseAmountOrNull("12a")) // null
}
Контракт «явна помилка з повідомленням» (часто краще для CLI):
fun parseAmount(text: String): CmdResult<Int> {
val trimmed = text.trim()
if (trimmed.isEmpty()) return CmdResult.Error("Amount is empty")
val n = trimmed.toIntOrNull() ?: return CmdResult.Error("Amount is not a number: '$text'")
if (n <= 0) return CmdResult.Error("Amount must be > 0, got $n")
return CmdResult.Ok(n)
}
Тут ми не використовуємо винятки, тому що помилки очікувані: користувач уводить дані вручну, а руки інколи живуть своїм життям.
5. Межа відповідальності: шари застосунку
Зараз буде важливий архітектурний момент, без якого тема помилок перетворюється на суперечку про смаки. У великому проєкті межа між винятками та «помилкою як значенням» часто збігається з межею між шарами.
У нашому курсі ми вже подумки ділили програму на частини: CLI (уведення/виведення), domain (логіка команд), storage (зберігання). Тож вибір форми помилок логічно привʼязати до конкретного місця.
Дивіться на цю таблицю як на «домовленість команди розробників» (навіть якщо команда — це ви і ваш кіт):
| Шар | Що робить | Типові проблеми | Як частіше виражати |
|---|---|---|---|
| CLI | читає рядки, друкує відповіді | «користувач увів нісенітницю» | помилка як значення (Ok/Error), інколи null |
| Domain | правила предметної області (команди, перевірки) | «немає такої витрати», «не можна видалити останній елемент» | помилка як значення (Ok/Error), require/check для внутрішніх гарантій |
| Storage | файли, БД, мережа | «не відкрився файл», «немає прав», «зламаний формат» | часто винятки, бо це I/O та аварії середовища |
Чому storage часто лишають на винятках? Тому що «файл не відкрився» — це не завжди очікувана гілка логіки предметної області. Це збій оточення. Ви все одно маєте вирішити, що робити (повідомити користувачу, завершити програму, запропонувати шлях), але всередині низькорівневого коду простіше й чесніше кинути виняток і обробити його на межі сценарію.
А от у domain-шарі часто зручніше повертати Ok/Error, бо це «правила гри» застосунку: користувач може попросити видалити неіснуючий id — і це очікувано.
6. Практика: команда remove та її контракт
Давайте зберемо невеликий фрагмент, який показує контракт «наживо». Уявімо, що CLI-шар уже розібрав id (як число), а в domain-шарі ми виконуємо видалення витрати.
Сценарій такий: якщо id не знайдено — це не виняток, а Error. Якщо список порожній і ми намагаємося щось видалити — теж Error. Але якщо нам передали відʼємний id, це вже схоже на баг у коді вище за стеком (CLI мав провалідувати), тож тут можна використати require.
data class Expense(val id: Int, val title: String)
fun removeExpense(expenses: MutableList<Expense>, id: Int): CmdResult<Expense> {
require(id > 0) { "id must be > 0, got $id" }
val index = expenses.indexOfFirst { it.id == id }
if (index == -1) return CmdResult.Error("No expense with id=$id")
val removed = expenses.removeAt(index)
return CmdResult.Ok(removed)
}
fun main() {
val expenses = mutableListOf(Expense(1, "Coffee"))
println(removeExpense(expenses, 2)) // Error(message=No expense with id=2)
}
Що тут важливо.
Некоректний аргумент (id <= 0) вважається порушенням контракту виклику й оформлений як fail-fast через require(...). А очікувана ситуація «id не знайдено» повертається як Error, бо це штатна гілка сценарію.
7. Як обирати підхід: практична логіка
На практиці рішення зручно ухвалювати не за принципом «я люблю винятки» або «я люблю sealed-класи», а за кількома приземленими питаннями.
- Якщо ситуація очікувана і трапляється регулярно (наприклад, користувач увів неправильну команду), то виняток перетворюється на шум. Вам доведеться в кожному місці використання писати try/catch, а код почне виглядати як серіал «Спіймай мене, якщо зможеш». Для очікуваних гілок майже завжди краще підходить «помилка як значення».
- Якщо після проблеми ви можете продовжити роботу (наприклад, показати повідомлення й попросити повторити введення), то «помилка як значення» природніша: ви лишаєтеся у звичайному потоці керування, просто переходите на іншу гілку.
- Якщо ж ситуація аварійна або свідчить про порушення логіки програми, то продовжувати небезпечно. Тут виняток (або require/check/error) виправданий: він змушує нас не робити вигляд, що все добре.
- І нарешті, якщо ви перебуваєте на межі із «середовищем» (файли, мережа, права доступу), то виняток часто виявляється чеснішим: це збій, який важко вбудувати як звичайну гілку предметної логіки. У таких місцях зазвичай ловлять виняток на верхньому рівні сценарію й перетворюють його на зрозуміле повідомлення для користувача.
8. Дві крайнощі й чому вони шкідливі
Є дві крайнощі — і обидві регулярно трапляються в новачків.
Перша крайність — «винятки для всього». Тоді будь-який чх користувача перетворюється на джунглі try/catch. У якийсь момент ви ловите Exception (бо інакше вже неможливо жити), і це перетворюється на універсальний мішок, куди падають і реальні аварії, і очікувані помилки введення. Налагодження такого коду зазвичай виглядає як археологічні розкопки: ви знайшли stack trace, але не впевнені, це баг чи користувач натиснув не ту клавішу.
Друга крайність — «жодних винятків узагалі, тільки Error(message)». Це теж небезпечно: ви можете випадково замести під килим ситуацію, у якій програма вже не має продовжувати (наприклад, порушено інваріант даних). Тоді замість чесного падіння ви отримаєте «тихий неправильний стан», а далі звіти будуть неправильними, дані — кривими, а користувач почне підозрювати, що у вашому застосунку живе полтергейст.
Зрілий підхід — змішаний, але узгоджений: на одному рівні ви обираєте одну політику й дотримуєтеся її.
Що буде далі
Сьогоднішня лекція — про сенс і домовленості. Ми поки свідомо працюємо з простими інструментами: require/check і власний Ok/Error.
Уже в наступних лекціях ми навчимося усвідомлено писати try/catch як вираз (тобто «повернути значення з try або з catch») і побачимо більш стандартні способи пакувати помилки в значення. Kotlin справді підтримує стиль, де try повертає значення, і це зручно, коли ви обираєте політику «у разі помилки повернути значення за замовчуванням або повідомлення».
Фундамент лишається тим самим: спочатку ви вирішуєте, що вважати помилкою і який вигляд вона має мати для коду, що викликає, і лише потім обираєте синтаксис.
9. Типові помилки
Помилка № 1: перетворювати очікувані ситуації на винятки.
Якщо користувач увів невідому команду, це не «програма зламалася», а «користувач попросив те, чого немає». Коли ви оформлюєте такі ситуації винятками, код швидко заростає try/catch, а справжні аварії губляться серед «помилок-як-гілок». Ліки прості: усе, що очікувано в сценарії, повертайте як Ok/Error і обробляйте звичайним when.
Помилка № 2: використовувати null як універсальну помилку.
null чудово каже «значення немає», але майже не каже «чому». У результаті ви змушені або додумувати причину, або друкувати загальне «не вдалося». Якщо причина важлива користувачу (а в CLI вона майже завжди важлива), краще повернути Error("зрозуміле повідомлення"), ніж null.
Помилка № 3: застосовувати require/check не за змістом.
Іноді новачки використовують require(false) просто «щоб упасти». Формально працює, але сенс губиться. require має виражати передумову для аргументів, а check — інваріант стану. Це робить код самодокументованим: за одним рядком видно, хто «відповідальний» за коректність (код, що викликає, чи внутрішня логіка).
Помилка № 4: «глушити» помилку й робити вигляд, що все нормально.
Найпідступніша помилка — коли програма продовжує роботу після проблеми, хоча вже не має. Наприклад, видалення неіснуючого id ви «мовчки» ігноруєте, і користувач думає, що видалення пройшло. Правильніше повертати Error з повідомленням або завершувати сценарій, якщо продовження беззмістовне.
Помилка № 5: змішувати різні контракти в одній функції.
Коли одна й та сама функція інколи кидає виняток, інколи повертає Error, інколи повертає null, код, що викликає, перетворюється на детектива: «а що мені перевіряти цього разу?». Намагайтеся, щоб у функції був один ясний контракт: або вона сигналізує проблеми значеннями (Ok/Error), або падає винятком (і це має бути виправдано змістом), або повертає T? у строго обмежених випадках «не знайдено» або «значення немає».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ