⚡ Раздел 7 · Вопрос #2

Что такое checked exception и когда его использовать

В иерархии классов checked-исключениями являются все прямые и косвенные наследники java.lang.Exception, кроме ветки java.lang.RuntimeException.


🟢 Junior Level

Checked exception (проверяемое исключение) — это тип исключительной ситуации в Java, обработку которого компилятор javac требует в обязательном порядке.

В иерархии классов checked-исключениями являются все прямые и косвенные наследники java.lang.Exception, кроме ветки java.lang.RuntimeException.

Почему их создали? (Концепция Джеймса Гослинга)

Создатели языка Java исходили из идеи надежности: если метод взаимодействует с внешней средой (сеть, файловая система, база данных), сбой неизбежен по объективным причинам (диск заполнен, кабель выдернут, файл удален). Checked-исключения принуждают разработчика на этапе написания кода явно решить:

  1. Либо перехватить ошибку и восстановиться: блок try-catch.
  2. Либо честно предупредить вызывающий код в сигнатуре метода: throws IOException.

Базовый пример

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class CheckedBasics {

    // Вариант 1: Делегирование ответственности наверх через throws
    public String loadData(Path path) throws IOException {
        return Files.readString(path);
    }

    // Вариант 2: Локальная обработка через try-catch
    public String loadDataWithFallback(Path path) {
        try {
            return Files.readString(path);
        } catch (IOException e) {
            // Восстановление: возвращаем резервные данные по умолчанию
            return "DEFAULT_CONFIG_DATA";
        }
    }
}

3 канонических критерия использования Checked Exception (по Джошуа Блоху)

Использовать проверяемое исключение оправдано только тогда, когда соблюдаются все 3 условия:

  1. Сбой предсказуем и ожидаем: Это не ошибка в коде программиста (не баг), а внешнее обстоятельство.
  2. Сбой восстановим (Recoverable): Вызывающий метод действительно может предпринять осмысленное действие по исправлению ситуации (повторить запрос, переключиться на резервный сервер, прочитать файл из кэша).
  3. Вызывающий код обязан среагировать: Игнорирование ситуации приведет к фатальной поломке бизнес-процесса.

🟡 Middle Level

Правила наследования методов и Liskov Substitution Principle (LSP)

При переопределении (@Override) методов в классах-наследниках компилятор накладывает строгие правила на проверяемые исключения в соответствии с принципом подстановки Лисков:

public class ParentService {
    public void execute() throws IOException { ... }
}

public class ChildService extends ParentService {
    // 1. РАЗРЕШЕНО: объявить более узкое исключение (наследник IOException)
    @Override
    public void execute() throws FileNotFoundException { ... }

    // 2. РАЗРЕШЕНО: вообще не объявлять checked-исключений
    // @Override
    // public void execute() { ... }

    // 3. РАЗРЕШЕНО: объявлять ЛЮБЫЕ unchecked-исключения (RuntimeException)
    // @Override
    // public void execute() throws IllegalArgumentException { ... }

    // 4. ЗАПРЕЩЕНО: объявлять более широкое исключение (Exception) или совершенно новое checked (SQLException)
    // @Override
    // public void execute() throws Exception { ... } // Ошибка компиляции!
}

Почему это так: Клиент, работающий с полиморфной ссылкой ParentService service = new ChildService(), ожидает перехватить максимум IOException. Если бы наследник выбросил SQLException, обработчик catch (IOException e) пропустил бы ошибку, нарушив контракт типа.

Правильная работа с InterruptedException

InterruptedException — классический пример оправданного checked-исключения в стандартной библиотеке Java. Оно сигнализирует о том, что другой поток вызвал thread.interrupt().

Грубейшая ошибка — проглатывание исключения:

// КАТЕГОРИЧЕСКИ НЕЛЬЗЯ ТАК ДЕЛАТЬ:
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    // Пусто или просто printStackTrace: флаг прерывания потока СТЕРТ!
}

Каноничное решение: восстановить статус прерывания потока:

try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    // Восстанавливаем прерванный статус для пула потоков / фреймворка
    Thread.currentThread().interrupt();
    log.warn("Thread execution was interrupted", e);
    throw new ProcessAbortedException("Execution aborted due to thread interrupt", e);
}

🔴 Senior Level

Binary & Source Compatibility (JLS §13.4.21)

Добавление проверяемого исключения в сигнатуру публичного API метода существующей библиотеки ломает обратную совместимость (Breaking Change):

  • Любой клиентский код, вызвавший метод api.processData(), мгновенно перестает компилироваться при обновлении версии зависимости.
  • В то же время добавление unchecked исключения сохраняет обратную совместимость компиляции.

Альтернативы checked exceptions в современном дизайне API

В промышленном коде на смену checked-исключениям пришли функциональные типы и паттерны:

1. Использование Optional<T>

Если сущность может законно отсутствовать (не является сбоем):

// Вместо User findById(Long id) throws UserNotFoundException
public Optional<User> findById(Long id);

2. Sealed Interface Result<T, E> (Паттерн Either / Result)

С появлением sealed classes и pattern matching в Java 21 появилась возможность описывать ожидаемые ошибки как значения без накладных расходов на генерацию стека:

package com.example.result;

public sealed interface Result<T> {
    record Success<T>(T value) implements Result<T> {}
    record Failure<T>(String errorCode, String message) implements Result<T> {}
}

Использование в сервисе:

public Result<PaymentReceipt> processPayment(PaymentRequest request) {
    if (!accountService.hasFunds(request.accountId(), request.amount())) {
        return new Result.Failure<>("INSUFFICIENT_FUNDS", "Account balance is too low");
    }
    PaymentReceipt receipt = paymentGateway.charge(request);
    return new Result.Success<>(receipt);
}

3. Адаптеры для Stream API

Для вызова legacy методов с checked исключениями внутри Streams создается generic-обертка:

@FunctionalInterface
public interface ThrowingFunction<T, R, E extends Exception> {
    R apply(T t) throws E;

    static <T, R> java.util.function.Function<T, R> unchecked(ThrowingFunction<T, R, ?> f) {
        return t -> {
            try {
                return f.apply(t);
            } catch (Exception ex) {
                if (ex instanceof RuntimeException runtimeException) {
                    throw runtimeException;
                }
                throw new RuntimeException("Checked exception occurred during stream processing", ex);
            }
        };
    }
}

Использование:

List<String> content = paths.stream()
        .map(ThrowingFunction.unchecked(Files::readString))
        .toList();

🎯 Шпаргалка для интервью

30-секундный ответ

Checked exception — это исключение, наследующееся от Exception (но не от RuntimeException), обработку которого компилятор javac контролирует принудительно (требуя try-catch или throws).

По канонам эффективной Java (Joshua Bloch) checked-исключение стоит использовать только тогда, когда сбой ожидаем и вызывающая сторона способна программно восстановиться после него (например, повторить операцию или применить резервный вариант).

Если восстановление невозможно (проблема с сетью, сбой базы данных), использование checked-исключений превращается в антипаттерн, приводя к загрязнению сигнатур методов (Signature Pollution) и несовместимости со Stream API и лямбдами. В современной архитектуре checked-исключения заменяются на RuntimeException, Optional<T> или паттерн Result<T>.

4 каверзных вопроса с ответами

  1. Может ли метод класса-наследника объявить новое checked-исключение при переопределении метода родителя? Ответ: Нет, не может. Согласно принципу подстановки Лисков (LSP) и спецификации Java (JLS), переопределенный метод может объявить более узкие checked-исключения (подтипы), объявить меньше исключений или вообще не объявлять их, но не имеет права объявлять новые или более широкие checked-исключения. При этом unchecked исключения (RuntimeException) разрешено объявлять любые.

  2. Что произойдет с бинарной и исходной совместимостью библиотеки, если добавить throws IOException в существующий публичный метод интерфейса? Ответ: Исходная совместимость (Source Compatibility) будет сломана: код всех клиентов библиотеки упадет с ошибкой компиляции (unhandled exception), требуя доработки.

  3. Почему в Java InterruptedException сделали checked-исключением и как его правильно обрабатывать? Ответ: Чтобы разработчик не мог случайно проигнорировать запрос на остановку потока. При возникновении InterruptedException флаг прерывания потока сбрасывается в false. Обработчик обязан либо пробросить исключение выше, либо восстановить статус потока вызовом Thread.currentThread().interrupt(), чтобы пулы потоков и планировщики задач могли корректно завершить работу.

  4. Чем отличается обработка ошибки через Result<T> от checked-исключения с точки зрения производительности? Ответ: Создание объекта исключения очень дорого из-за вызова нативного метода Throwable.fillInStackTrace(), собирающего фреймы стека потока в памяти JVM. Паттерн Result (или Optional) создает легковесный POJO/Record в Eden-памяти без обхода стека, что в десятки раз быстрее и снижает нагрузку на сборщик мусора.

Красные флаги (чего категорически нельзя говорить)

  • ❌ «Я объявляю throws Exception на всех методах, чтобы компилятор не мешал» (уничтожает пользу системы типов и ломает контракт метода).
  • ❌ «Checked-исключения нужно перехватывать и оставлять catch пустым, если не знаешь, что делать» (сокрытие ошибок приводит к тяжелейшим плавающим багам).
  • ❌ «При переопределении метода можно добавить в throws любое checked-исключение» (грубейшее нарушение принципа LSP, код не скомпилируется).
  • ❌ «InterruptedException можно просто залогировать и продолжить выполнение цикла» (потеря сигнала прерывания блокирует корректное завершение потоков).

Связанные темы