JavaRush /Курси /Kotlin SELF /Власні винятки та першопричина

Власні винятки та першопричина

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

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() — це про вхід і стан (і вони кидають стандартні винятки). А власні винятки корисні там, де ви хочете виразити помилки «мовою застосунку» і, за потреби, зберегти першопричину.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ