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() — это про вход и состояние (и они кидают стандартные исключения). А свои исключения полезны там, где вы хотите выразить ошибки “языком приложения” и, при необходимости, сохранить первопричину.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ