1. Чому «технічний виняток» часто марний
Якщо ви лише починаєте, винятки часто здаються «червоними літерами, які заважають жити». Але проблема не в тому, що стався виняток. Проблема в іншому: його повідомлення часто нічого не каже про вашу задачу. Компʼютер чесно повідомляє: «я не зміг перетворити рядок на число». А ви сидите й думаєте: «гаразд… де саме, чому саме й що користувач увів?».
Уявіть, що у вас є консольний застосунок, у якому користувач вводить команди, наприклад:
add coffee 120
Якщо користувач уведе:
add coffee twelve
то типовим низькорівневим «вибухом» буде NumberFormatException. Проблема в тому, що повідомлення «не число» — правдиве, але надто загальне. У вашому застосунку важливіше інше: «сума витрати має бути числом». І бажано ще показати, яке саме значення прийшло.
Тобто ми хочемо, щоб помилка звучала як фраза доброзичливого викладача: «Сума витрати має бути цілим числом, але ви ввели twelve». А не як репліка компілятора (він теж доброзичливий, просто в нього такий характер): «NumberFormatException».
2. cause і ланцюжок причин
Коли ви перехоплюєте один виняток і вирішуєте кинути інший — зрозуміліший, — легко зробити класичну помилку: втратити першопричину. А першопричина (на JVM її зазвичай називають cause) — це «початкова аварія», з якої все й почалося.
У Kotlin/Java виняток — це обʼєкт, який може містити посилання на інший виняток: «я впав, бо впало ось це». Документація Kotlin підкреслює: ви можете створювати власні повідомлення й водночас зберігати оригінальний cause, щоб він потрапляв у stack trace.
Візуально це схоже на ланцюжок:
CommandParseException("Amount must be a number, got 'twelve'")
caused by NumberFormatException("For input string: \"twelve\"")
І цей ланцюжок — золото для налагодження. Навіть якщо користувачеві ви показуєте лише верхній рівень («сума має бути числом»), розробнику (тобто вам у майбутньому, коли доведеться лагодити баг о 2 годині ночі) дуже корисно знати точну першопричину.
Невеличка схема потоку (так, це той самий момент, коли блок-схема справді допомагає):
flowchart TD
A["Низькорівневий код: toInt()"] -->|кидає| B["NumberFormatException"]
B --> C["catch(e)"]
C --> D["throw CommandParseException(..., cause=e)"]
D --> E["верхній рівень ловить CommandParseException"]
E --> F["користувач бачить зрозуміле повідомлення"]
E --> G["розробник за потреби бачить ланцюжок cause"]
3. Як оголошувати власні винятки
Коли ми кажемо «власний виняток», це не означає «терміново побудувати ієрархію з 48 класів». Зазвичай достатньо 1–2 класів на рівень застосунку.
У Kotlin власний виняток — це звичайний клас, нащадок Exception (або іншого винятку). Документація Kotlin описує створення custom exceptions саме так.
Найпрактичніший шаблон для наших цілей — приймати message і необовʼязковий cause:
class CommandParseException(
message: String,
cause: Throwable? = null
) : Exception(message, cause)
Зверніть увагу на два моменти.
Перший: ми використовуємо Throwable?, а не Exception?. Це звичніше для світу JVM: cause може бути будь-яким «обʼєктом, який можна кинути».
Другий: значення за замовчуванням null дає змогу кидати виняток і без першопричини, якщо ми сформували його «з нуля».
Чому не варто робити object MyException : Exception(...)
Іноді новачок бачить, що Kotlin уміє object, і думає: «О! Зроблю один виняток на всіх, щоб не плодити обʼєкти». Це погана ідея. Винятки — це стан плюс stack trace. Документація Kotlin прямо радить не створювати винятки через object, а щоразу створювати новий екземпляр, щоб стан відображав реальний контекст.
4. Wrapping: спіймав, додав контекст, кинув далі
Закріпімо головний прийом цього рівня: ми перехоплюємо низькорівневу помилку й кидаємо нову, але вже «мовою нашого застосунку». Водночас обовʼязково передаємо cause.
Приклад: парсимо суму витрати та додаємо сенс
fun parseAmount(text: String): Int {
val prepared = text.trim()
try {
return prepared.toInt()
} catch (e: NumberFormatException) {
throw CommandParseException("Сума витрати має бути цілим числом, але ви ввели '$text'", e)
}
}
Тут важливо, що ми не ховаємо першопричину. Ми додали зрозуміле повідомлення, але e зберегли в cause.
Приклад: «обгортка без причини» — як втратити докази
Іноді пишуть так:
fun parseAmountBad(text: String): Int {
return try {
text.trim().toInt()
} catch (e: NumberFormatException) {
throw CommandParseException("Bad amount") // cause втрачено
}
}
Цей варіант неприємний тим, що якщо десь вище ви захочете зрозуміти, що саме прийшло і чому toInt() впав, у вас уже не буде початкового винятку та його деталей.
Приклад: коли контекст важливіший за «тип» винятку
Технічно NumberFormatException — це й так «про число». Але у вашому застосунку може бути кілька чисел: сума, ліміт, кількість, номер місяця, ID користувача. Тоді «не число» — марна підказка. Саме тому ми й робимо CommandParseException: щоб відокремити зміст — «помилка парсингу команди».
5. Міні-застосунок: трекер витрат і зрозумілі помилки
Зараз ми зберемо невеликий, але цілісний фрагмент нашого навчального застосунку. Нехай це буде найпростіший трекер витрат: він зберігає список витрат у памʼяті й приймає команди:
add <title> <amount>
list
exit
Ми не будемо ускладнювати архітектуру «шарами до стелі». Наша мета сьогодні не «ідеальна система», а практика: де саме обгортати помилку і як не втрачати cause.
Модель даних і сховище в памʼяті
У попередні дні в нас уже були класи й колекції обʼєктів, тож зробимо просту модель:
data class Expense(
val title: String,
val amount: Int
)
І список як сховище:
val expenses = mutableListOf<Expense>()
Власні винятки рівня застосунку
Ми заведемо два типи винятків: один для помилок введення команди, інший — для логічно неможливих ситуацій (хоча другий сьогодні майже не знадобиться, але корисно побачити різницю).
class CommandParseException(message: String, cause: Throwable? = null) : Exception(message, cause)
class AppStateException(message: String, cause: Throwable? = null) : Exception(message, cause)
Парсер команди: повертаємо структуру або кидаємо виняток
Щоб код було легше читати, зручно уявити розпарсену команду як тип. Ми вже вміємо sealed class, тож зробимо так:
sealed class Command {
data class Add(val title: String, val amount: Int) : Command()
data object ListAll : Command()
data object Exit : Command()
}
Тепер функція парсингу:
fun parseCommand(line: String): Command {
val parts = line.trim().split(" ").filter { it.isNotBlank() }
if (parts.isEmpty()) throw CommandParseException("Порожня команда")
return when (parts[0].lowercase()) {
"add" -> parseAdd(parts)
"list" -> Command.ListAll
"exit" -> Command.Exit
else -> throw CommandParseException("Невідома команда: '${parts[0]}'")
}
}
Тут ми кидаємо CommandParseException напряму у випадках, коли низькорівневої першопричини не було: ми самі виявили проблему.
А тепер найцікавіше — parseAdd.
parseAdd: обгортаємо NumberFormatException у зрозумілу помилку
fun parseAdd(parts: List<String>): Command.Add {
if (parts.size < 3) {
throw CommandParseException("Використання: add <title> <amount>")
}
val title = parts[1]
val amountText = parts[2]
val amount = try {
amountText.toInt()
} catch (e: NumberFormatException) {
throw CommandParseException("Сума має бути числом, але ви ввели '$amountText'", e)
}
return Command.Add(title = title, amount = amount)
}
Зауважте: ми могли б викликати нашу parseAmount(...), але зараз спеціально показуємо техніку прямо в місці використання, щоб було видно: «спіймав → кинув із cause».
Основний цикл: ловимо лише очікуване
Тепер main. За стилем, який ми вже обговорювали раніше, ми не хочемо ловити «все підряд». Ми перехоплюємо лише очікувані помилки парсингу.
fun main() {
val expenses = mutableListOf<Expense>()
while (true) {
print("> ")
val line = readln()
try {
when (val cmd = parseCommand(line)) {
is Command.Add -> {
expenses.add(Expense(cmd.title, cmd.amount))
println("Додано: ${cmd.title} (${cmd.amount})") // Додано: coffee (120)
}
Command.ListAll -> {
printExpenses(expenses)
}
Command.Exit -> return
}
} catch (e: CommandParseException) {
println("Помилка введення: ${e.message}") // наприклад: Помилка введення: Сума має бути числом, але ви ввели 'twelve'
// У навчальних цілях можна тимчасово друкувати першопричину:
// println("Причина: ${e.cause?.javaClass?.simpleName}") // Причина: NumberFormatException
}
}
}
printExpenses зробимо короткою й безпечною:
fun printExpenses(expenses: List<Expense>) {
if (expenses.isEmpty()) {
println("(порожньо)") // (порожньо)
return
}
for (e in expenses) {
println("- ${e.title}: ${e.amount}") // - coffee: 120
}
}
6. require/check vs власні винятки
У якийсь момент у вас може зʼявитися відчуття: «А навіщо мені власні винятки, якщо є require()?». Чудове запитання, бо саме тут проходить важлива межа між «валідацією» та «контекстом».
require(...) і check(...) — це предикати, які кидають стандартні винятки: IllegalArgumentException і IllegalStateException. Документація Kotlin добре описує їхнє призначення та те, які винятки вони кидають.
Якщо сказати простими словами, require — це «ви передали погані вхідні дані, тож я не зобовʼязана працювати», а check — «у мене всередині неправильний стан, це логічна помилка в коді».
А власні винятки найчастіше потрібні в іншій точці: коли ви хочете відокремити змістовну помилку вашої предметної області. Наприклад, «команду не розпізнано», «не можна додати витрату з відʼємною сумою», «категорія не існує». У таких випадках власний тип винятку допомагає:
- по-перше, ловити його окремо (і не сплутати з іншими),
- по-друге, задати єдиний стиль повідомлень,
- по-третє, зберігати cause, якщо помилка прийшла «знизу».
Наприклад, якщо ви хочете заборонити відʼємні суми, це можна зробити й так:
fun validateAmount(amount: Int): Int {
require(amount > 0) { "Сума має бути > 0, але отримано $amount" }
return amount
}
Це чудовий варіант, бо відʼємна сума — це порушення передумови.
А от якщо ви парсите команду й хочете відокремити «помилку парсингу» від усіх інших проблем, CommandParseException читатиметься краще.
Міні-діагностика: як подивитися ланцюжок причин
Іноді ви хочете показати користувачеві коротке повідомлення, але для себе (або для тимчасової діагностики в навчальному режимі) — надрукувати ланцюжок cause. У Kotlin у будь-якого Throwable є cause: Throwable?.
Зробімо маленьку функцію, яка друкує «драбину причин». Без фанатизму — 7–8 рядків:
fun printCauseChain(e: Throwable) {
var current: Throwable? = e
while (current != null) {
println("${current::class.simpleName}: ${current.message}")
current = current.cause
}
}
Якщо викликати:
try {
parseCommand("add coffee twelve")
} catch (e: CommandParseException) {
printCauseChain(e)
}
то ви побачите щось на кшталт:
CommandParseException: Amount must be a number, got 'twelve'
NumberFormatException: For input string: "twelve"
І це хороший компроміс: користувачеві ви не показуєте страшні деталі, зате в режимі розробки швидко розумієте, що сталося.
7. Типові помилки
Помилка №1: обгорнути виняток і втратити cause.
Коли ви ловите NumberFormatException і кидаєте свій виняток без передавання cause, ви виграєте гарне повідомлення, але програєте можливість розслідування. За кілька тижнів ви дивитиметеся на CommandParseException("Bad amount") і відчуватимете, що ця помилка написана як «я не знаю, що сталося, але щось сталося». Передавайте першопричину другим аргументом (Exception(message, cause)) — Kotlin і JVM уміють зберігати цей звʼязок.
Помилка №2: робити один виняток на всі випадки життя.
Іноді зʼявляється спокуса: «Зроблю AppException і кидатиму його завжди». У результаті тип винятку перестає щось означати, і ви знову змушені все розрізняти за текстом повідомлення. Краще мати хоча б один осмислений тип під ваш шар, наприклад CommandParseException для помилок введення та парсингу, і використовувати його стабільно.
Помилка №3: створювати винятки як object, щоб «не алокувати».
Виняток містить stack trace і контекст створення. Якщо ви зробите виняток-синглтон, він зберігатиме не той контекст, який реально стався в програмі, і діагностика стане дивною. Документація Kotlin прямо попереджає: винятки — це обʼєкти зі станом, тож їх потрібно щоразу створювати як нові екземпляри.
Помилка №4: ловити надто широкий тип і «лагодити все на світі».
catch (e: Exception) навколо половини main виглядає як «захисний купол», але на практиці перетворюється на «пилосос помилок»: ви можете випадково проковтнути баги, які мали впасти й бути виправлені. Сьогоднішня техніка з cause якраз і допомагає зробити правильно: ловіть лише те, чого очікуєте (наприклад, CommandParseException), а решта нехай падає — це сигнал розробнику.
Помилка №5: повідомлення винятку без фактичних значень.
Повідомлення на кшталт "Bad amount" звучить як «погане щось». Повідомлення на кшталт "Amount must be a number, got 'twelve'" звучить як інструкція, що саме виправити, і містить дані для діагностики. Намагайтеся писати повідомлення за формулою «що очікували» + «що отримали».
Помилка №6: змішувати передумови та помилки сценарію в одну купу.
Якщо ви використовуєте require для всього підряд, ви швидко втратите семантику: де помилка введення користувача, де логічний баг, а де проблема сценарію. Памʼятайте, що require()/check() — це про вхід і стан (і вони кидають стандартні винятки). А власні винятки корисні там, де ви хочете виразити помилки «мовою застосунку» і, за потреби, зберегти першопричину.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ