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("Amount must be an integer, got '$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("Empty command")

    return when (parts[0].lowercase()) {
        "add" -> parseAdd(parts)
        "list" -> Command.ListAll
        "exit" -> Command.Exit
        else -> throw CommandParseException("Unknown command: '${parts[0]}'")
    }
}

Здесь мы бросаем CommandParseException «напрямую» в случаях, где не было низкоуровневой первопричины: мы сами обнаружили проблему.

А теперь самое интересное — parseAdd.

parseAdd: оборачиваем NumberFormatException в понятную ошибку

fun parseAdd(parts: List<String>): Command.Add {
    if (parts.size < 3) {
        throw CommandParseException("Usage: add <title> <amount>")
    }

    val title = parts[1]
    val amountText = parts[2]

    val amount = try {
        amountText.toInt()
    } catch (e: NumberFormatException) {
        throw CommandParseException("Amount must be a number, got '$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("Added: ${cmd.title} (${cmd.amount})") // Added: coffee (120)
                }
                Command.ListAll -> {
                    printExpenses(expenses)
                }
                Command.Exit -> return
            }
        } catch (e: CommandParseException) {
            println("Input error: ${e.message}") // например: Input error: Amount must be a number, got 'twelve'
            // В учебных целях можно временно печатать первопричину:
            // println("Cause: ${e.cause?.javaClass?.simpleName}") // Cause: NumberFormatException
        }
    }
}

printExpenses сделаем короткой и безопасной:

fun printExpenses(expenses: List<Expense>) {
    if (expenses.isEmpty()) {
        println("(empty)") // (empty)
        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) { "Amount must be > 0, got $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 и контекст создания. Если вы сделаете singleton-исключение, оно будет хранить не тот контекст, который реально случился в программе, и диагностика станет странной. Kotlin-документация прямо предупреждает, что исключения — stateful-объекты и их нужно создавать как новые экземпляры.

Ошибка №4: ловить слишком широкий тип и “чинить всё на свете”.
catch (e: Exception) вокруг половины main выглядит как «защитный купол», но на практике превращается в “пылесос ошибок”: вы можете случайно проглотить баги, которые должны были упасть и быть исправлены. Сегодняшняя техника про cause как раз помогает делать правильно: ловите только то, что ожидаете (например, CommandParseException), а остальное пусть падает — это сигнал разработчику.

Ошибка №5: сообщение исключения без фактических значений.
Сообщение вроде "Bad amount" звучит как «плохое что-то». Сообщение вроде "Amount must be a number, got 'twelve'" звучит как инструкция, что исправить, и содержит данные для диагностики. Старайтесь писать сообщения по формуле «что ожидали» + «что получили».

Ошибка №6: смешивать предусловия и ошибки сценария в одну кучу.
Если вы используете require для всего подряд, вы быстро потеряете семантику: где ошибка ввода пользователя, где логический баг, где проблема вашего сценария. Помните, что require()/check() — это про вход и состояние (и они кидают стандартные исключения). А свои исключения полезны там, где вы хотите выразить ошибки “языком приложения” и, при необходимости, сохранить первопричину.

1
Задача
Kotlin SELF, 40 уровень, 2 лекция
Недоступна
Ошибка формы
Ошибка формы
1
Задача
Kotlin SELF, 40 уровень, 2 лекция
Недоступна
Количество товара
Количество товара
1
Задача
Kotlin SELF, 40 уровень, 2 лекция
Недоступна
Команда консоли
Команда консоли
1
Задача
Kotlin SELF, 40 уровень, 2 лекция
Недоступна
Лестница причин
Лестница причин
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ