⚡ Розділ 7 · Питання #2

Що таке checked виняток і коли його використовувати

В ієрархії класів 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 з’явилася можливість описувати очікувані помилки як звичайні значення без накладних витрат на генерацію стека:

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 без обходу стека, що в десятки разів швидше та знижує навантаження на Garbage Collector.

Червоні прапорці (чого категорично не можна говорити)

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

Пов’язані теми