JavaRush /Курсы /Kotlin SELF /Несколько catch

Несколько catch

Kotlin SELF
12 уровень , 5 лекция
Открыта

1. Как выбирается catch и почему важен порядок

Когда вы впервые освоили try/catch, очень хочется сделать так: «ну окей, я поймаю всё и напишу одно сообщение». Это ощущается как броня, как волшебный щит от всех бед. Проблема в том, что это щит из картона: да, падать перестанет, но вы перестанете понимать, что происходит, а пользователь будет получать “ошибка” без подсказок, что именно он сделал не так.

В Kotlin обработчик catch срабатывает только если внутри try реально произошло исключение, и при этом выбирается первый подходящий catch сверху вниз. Причём «подходящий» означает не только точное совпадение типа, но и совпадение по иерархии: если вы ловите более общий класс исключений, он “накроет” и более конкретные. Это прямо важно для темы сегодняшней лекции: порядок catch определяет поведение программы.

Чтобы почувствовать пользу нескольких catch, представьте простую консольную программу, где пользователь вводит число. Ошибки могут быть разные: он может ввести "12a" (это про формат), может ввести 0 (это про деление на ноль), а может ввести индекс 100 для массива из трёх элементов (это про границы). Если всё это сообщать одной фразой, получится грустный квест «угадай, что не так».

Правило «первый подходящий»

Сейчас будет немного «формальной математики», но без неё дальше легко сделать код, который выглядит правильно… и не работает так, как вы думаете.

Выбор обработчика можно представить так:

flowchart TD
    A[Код в try] --> B{Исключение произошло?}
    B -->|нет| C[Пропускаем catch, идём дальше]
    B -->|да| D[Сравниваем с 1-м catch]
    D --> E{Тип подходит?}
    E -->|да| F[Выполняем этот catch]
    E -->|нет| G[Пробуем следующий catch]
    G --> E

Ключевой момент: проверка идёт сверху вниз, и сработает только первый подходящий обработчик.

Почему это связано с иерархией? Потому что у исключений есть «родители»: например, NumberFormatException — это частный случай ошибки, а Exception — очень общий “родитель” для многих ошибок времени выполнения. Если вы поставите обработчик для “родителя” слишком рано, он заберёт себе управление, и более конкретные обработчики просто не будут иметь шанса.

Сначала конкретные, потом общие

Очень типичный путь новичка выглядит так: «а давайте первым catch поймаем Exception, чтобы уж точно…». Kotlin на это обычно отвечает довольно строго: «дружище, ниже уже есть catch для NumberFormatException, но он никогда не выполнится, потому что Exception перехватит всё раньше». И это хорошо: компилятор буквально спасает вас от неработающей логики.

Посмотрим на пример неправильного порядка. Он короткий, но показательный:

fun main() {
    try {
        println(readln().toInt())
    } catch (e: Exception) {
        println("Любая ошибка")
    }
    catch (e: NumberFormatException) { ... } // так нельзя: недостижимо
}

Этот код демонстрирует принцип: общий catch (e: Exception) должен быть “последним рубежом”, если вы вообще решили его писать.

А вот вариант, где порядок уже разумный: сначала ловим конкретную причину, а потом — максимально общий вариант, если вдруг случилось что-то неожиданное.

fun main() {
    try {
        println(readln().toInt())
    } catch (e: NumberFormatException) {
        println("Ошибка: нужно ввести целое число") // например: "12a"
    } catch (e: Exception) {
        println("Непредвиденная ошибка: ${e.message}")
    }
}

Обратите внимание, что e.message может быть null, и это нормально (не все исключения обязаны иметь текст). Но даже с null вы хотя бы понимаете: “что-то пошло не по плану, и это не ошибка формата”.

Кстати, если вы Java-программист: Kotlin не требует ловить исключения явно (в Kotlin они “unchecked”), но позволяет это делать, если вы хотите превратить падение в контролируемое поведение.

2. Гранулярность: насколько точно ловить исключения

Гранулярность — это слово, которое звучит как марка дорогого кофе, но означает простую вещь: насколько подробно вы различаете причины ошибок.

Можно ловить очень конкретно: отдельно NumberFormatException, отдельно ArithmeticException, отдельно ArrayIndexOutOfBoundsException. Тогда вы сможете дать пользователю максимально точную подсказку. Но если вы напишете по 30 строк в каждом catch, программа превратится в роман в трёх томах «Страдания обработчиков». И да, романы бывают интересными, но компилятор их не читает.

Можно ловить слишком общо: одним catch (e: Exception) и печатать «Ошибка». Тогда программа не падает, но вы теряете диагностику и возможность объяснить пользователю, что именно не так. В этот момент приложение становится похожим на банкомат, который пишет «ОШИБКА» и смотрит на вас с укором.

Здоровая середина обычно такая: ожидаемые, понятные ошибки — обрабатываем конкретно; неожиданное — либо вообще не ловим (пусть падает и показывает stack trace, особенно в учебных программах), либо ловим последним catch (e: Exception) и печатаем нейтральное сообщение, не притворяясь, что мы “всё починили”.

Небольшая таблица для ориентира:

Подход Что пишем Что получаем в итоге
Слишком общий catch (e: Exception) и один текст “Не падает”, но непонятно, почему
Слишком дробный 10 разных catch + сложная логика Точно, но громоздко и трудно поддерживать
Практичный 24 конкретных + общий в конце И понятно пользователю, и логика читается

Стратегия обработки: ожидаемые и неожиданные ошибки

Сейчас мы соберём всё в одну мысль: несколько catch — это не «потому что так красивее», а потому что разные причины требуют разных действий.

Если пользователь ввёл "12a" вместо числа, это ожидаемая ошибка ввода. Программа должна спокойно объяснить, что нужно вводить цифры, и, возможно, попросить повторить ввод (это вы уже умеете делать через циклы). Это не катастрофа, это просто человеческий фактор: у людей пальцы.

Если пользователь ввёл делитель 0, это тоже ожидаемая ситуация. Да, делить на ноль нельзя, но это легко объяснить.

Если у вас внезапно вылетел ArrayIndexOutOfBoundsException, тут ситуация двоякая. Иногда это тоже “ожидаемая” ошибка (если пользователь сам вводит индекс). Но иногда это чистый баг в программе: вы сами посчитали индекс неправильно. И вот тут слишком общий catch может замаскировать реальную ошибку, из-за чего вы будете часами искать, почему “иногда программа ведёт себя странно”.

Поэтому стратегически полезно задавать себе простой вопрос: кто виноват?

Если виноват пользователь (ввод), пишем дружелюбное сообщение и остаёмся живыми. Если виноват код (баг), лучше, чтобы разработчик это увидел. В учебных программах иногда полезнее вообще не ловить такое исключение, чтобы получить stack trace и быстро найти строку, где вы ошиблись.

3. Практический пример: мини-программа «Оценки»

Давайте продолжим практику в стиле “консольных мини-приложений”, которые мы строили с первых дней: ввод, простая логика, вывод. Пусть у нас есть массив оценок, и пользователь хочет посмотреть оценку по индексу и посчитать «10 / d» (чтобы специально иметь шанс поймать деление на ноль). Да, это искусственный пример, но он честно тренирует несколько разных исключений.

Сначала сделаем маленький каркас данных:

fun main() {
    val grades = intArrayOf(5, 4, 3)

    print("Индекс оценки: ")
    val rawIndex = readln()

    print("Делитель: ")
    val rawDiv = readln()

    // дальше добавим try/catch
}

Теперь добавим try и несколько catch. Обратите внимание: внутри try у нас сразу три места, где может быть проблема: toInt() (формат), grades[i] (границы), 10 / d (деление на ноль). А значит, имеет смысл различить причины.

fun main() {
    val grades = intArrayOf(5, 4, 3)

    try {
        val i = readln().toInt()
        val d = readln().toInt()

        println("Оценка = ${grades[i]}")
        println("10 / d = ${10 / d}")
    } catch (e: NumberFormatException) {
        println("Ошибка: ввод должен быть целым числом")
    } catch (e: ArrayIndexOutOfBoundsException) {
        println("Ошибка: индекс вне диапазона 0..${grades.lastIndex}")
    } catch (e: ArithmeticException) {
        println("Ошибка: деление на ноль")
    }
}

Здесь важны два момента.

Первый момент: мы ловим три разных исключения и даём три разных сообщения. Это и есть практическая ценность нескольких catch.

Второй момент: порядок. Тут нет Exception, но всё равно идея «сначала конкретное» сохраняется. А если бы мы добавили общий обработчик, он должен быть последним, потому что иначе он “съест” остальные.

4. Минимизируем try: делим обработку на блоки

Сейчас сделаем небольшой рефакторинг мышлением, а не “архитектурой века”. Частая рекомендация: try должен быть как можно меньше — накрывать только то, что реально может упасть и что вы действительно хотите обработать.

В предыдущем примере мы накрыли много сразу. Это нормально для обучения “несколько catch”. Но в реальной жизни вам иногда полезнее отделить ввод индекса от вычислений. Тогда вы точнее понимаете, какая операция дала сбой, и код становится легче тестировать глазами.

Например, можно сделать так: один try на парсинг чисел, второй — на работу с массивом и делением. Мы не обязаны так делать, но как мыслительный инструмент это помогает.

fun main() {
    val grades = intArrayOf(5, 4, 3)

    val i = try { readln().toInt() }
    catch (e: NumberFormatException) { return }

    val d = try { readln().toInt() }
    catch (e: NumberFormatException) { return }

    try {
        println(grades[i])
        println(10 / d)
    } catch (e: Exception) {
        println("Ошибка вычисления: ${e.message}")
    }
}

Здесь я специально сделал return в случае неправильного ввода. Это не “идеальный UX”, но это аккуратная демонстрация: если ввод некорректен — дальше выполнять нечего, потому что у нас нет валидных чисел. Вы уже знаете, что return позволяет быстро закончить функцию.

И да, тут есть общий catch (e: Exception) во втором блоке. Такой ход иногда допустим как “последний рубеж”, но его стоит использовать осознанно и держать простым.

5. Сообщения в catch и общий обработчик

Что говорить пользователю

Когда вы уже умеете ловить разные исключения, появляется новая опасность: начать писать в catch “настоящую логику приложения”, как будто это обычная ветка if. Так можно, но это быстро ломает читаемость: основной сценарий становится рваным, потому что логика расползается по обработчикам.

В консольной учебной программе обычно достаточно, чтобы catch делал три вещи: кратко объяснил проблему, подсказал правильный ввод и вернул программу в контролируемое состояние (например, завершил выполнение или попросил повторить ввод).

Для примера: если вы ловите NumberFormatException, полезно сказать «нужно целое число» — а не выводить полотно “java.lang.NumberFormatException: For input string: ...” пользователю. Это сообщение приятно только тому, кто хотел увидеть stack trace (то есть вам, когда вы отлаживаетесь), а не пользователю.

Общий catch как страховка, а не мешок для мусора

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

Если вы добавляете общий catch, держите в голове два правила.

Во-первых, он должен быть последним, иначе более конкретные обработчики станут недостижимыми, потому что общий обработчик перехватит исключения раньше.

Во-вторых, он не должен притворяться, что “всё починил”. Если вы ловите вообще всё, ваше сообщение должно быть честным и нейтральным: «непредвиденная ошибка» — и, возможно, e.message. Вы не обязаны “лечить” то, что не понимаете.

Вот типичный шаблон для учебной консольной программы:

fun main() {
    try {
        println("Результат: ${100 / readln().toInt()}")
    } catch (e: NumberFormatException) {
        println("Ошибка: нужно целое число")
    } catch (e: ArithmeticException) {
        println("Ошибка: деление на ноль")
    } catch (e: Exception) {
        println("Непредвиденная ошибка: ${e.message}")
    }
}

Здесь два первых catch — “ожидаемые” ошибки для этого сценария, а последний — страховка.

6. Типичные ошибки при работе с несколькими catch

Ошибка №1: ставить catch (e: Exception) первым.
Это выглядит как “сделаю универсально”, но по смыслу ломает всю идею нескольких обработчиков. Более общий тип перехватывает и более частные случаи, и в итоге ваш аккуратный catch (e: NumberFormatException) либо становится недостижимым (компилятор ругнётся), либо вы просто никогда не увидите его работу. Правильная привычка — располагать catch от самых конкретных к самым общим.

Ошибка №2: делать одинаковый текст во всех catch.
Если в каждом обработчике вы печатаете «Ошибка», вы формально написали несколько catch, но практической пользы от них ноль. Смысл разветвления в том, чтобы реакции отличались: “не число” и “деление на ноль” — это разные подсказки пользователю, и их стоит разделять хотя бы текстом.

Ошибка №3: превращать catch в “второй main” со сложной логикой.
В обработчиках новичок иногда начинает писать половину программы: повторно вводить данные, пересоздавать массивы, запускать циклы и печатать отчёты на 40 строк. Так код становится трудно читать: основной сценарий распадается на куски. Лучше, чтобы catch был коротким, а сложная логика жила в обычных функциях и обычном потоке выполнения.

Ошибка №4: ловить исключение “на всякий случай” и молча его проглатывать.
Пустой catch — это как заклеить лампочку “Check Engine” изолентой: машина не перестаёт ломаться, вы просто перестаёте об этом узнавать. Даже в учебной программе минимум — вывести понятный текст. Иначе вы теряете диагностику и потом удивляетесь, почему «оно иногда ничего не делает».

Ошибка №5: путать “где случилось” и “где обработали”.
Исключение возникает в конкретной строке внутри try, а обрабатывается в catch. Если в голове нет этой связи, появляются странные ожидания вроде «я напишу catch, и программа продолжит с той же строки». Нет: строка, где произошло исключение, не “заканчивается”, выполнение прыгает в catch. Поэтому после try/catch имеет смысл держать код таким, чтобы он корректно работал и при успешном сценарии, и при сценарии обработки ошибки.

Ошибка №6: слишком широкий try, который скрывает источник проблемы.
Когда try накрывает “полпрограммы”, вы теряете понимание, какая именно операция потенциально падает и какую ошибку вы на самом деле обрабатываете. Это особенно неприятно, если внутри try много разных действий: ввод, вычисления, работа с массивами. Старайтесь, чтобы try оборачивал небольшой фрагмент, который вы можете объяснить словами: “вот здесь я парсю число” или “вот здесь я обращаюсь к элементу массива”.

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