Java Идиоматика и обработка ошибок: исключения, Optional, immutability
0%

Идиоматика и обработка ошибок: исключения, Optional, immutability

Идиоматика и обработка ошибок: исключения, Optional, immutability

Код, который работает на счастливом пути, пишут за час. Код, который предсказуемо ведёт себя при отклонениях, пишут всю карьеру. В Java эта тема особенно нагружена историей: язык — единственный из массовых, который заставил компилятор проверять список выбрасываемых ошибок, и он же 20 лет прожил без всякого способа сказать «значения может не быть». В результате в одном и том же проекте встречаются четыре несовместимых стиля: проверяемые исключения из JDK 1.0, unchecked-обёртки из эпохи Spring, Optional из Java 8 и sealed-результаты из Java 21.

Эта статья — про то, как выбрать стиль осознанно. Мы начнём с модели исполнения: что физически происходит в JVM, когда летит исключение, сколько это стоит и почему «try дорогой» — миф, а «new Exception дорогой» — правда. Дальше разберём решение checked против unchecked как инженерный выбор, а не вкусовщину. Потом — Optional с его настоящим контрактом. И закончим иммутабельностью, которая в Java из «хорошего тона» превратилась в основной инструмент проектирования типов.

Предполагается, что вы уже прошли ООП в Java — records, sealed-типы и pattern matching здесь используются как рабочий инструмент, а не объясняются заново.

Три вопроса, на которые отвечает обработка ошибок

Прежде чем выбирать механизм, стоит развести три разных вопроса, которые в обсуждениях постоянно склеивают:

  1. Кто отвечает за ошибку? Вызывающий нарушил контракт (передал null в метод, который этого не допускает) — отвечает он, и это баг. Упал сетевой канал — не отвечает никто, это свойство мира.
  2. Может ли вызывающий что-то сделать? Повторить, взять из кеша, показать форму с подсветкой поля — или ему остаётся только всплыть выше.
  3. Насколько это частый сценарий? «Пользователь не найден» в поиске случается тысячи раз в секунду; «диск заполнен» — раз в год.

Механизм выбирается по ответам: исключение — для редкого и/или неустранимого, тип-результат или Optional — для частого и ожидаемого. Всё остальное — детали синтаксиса.

Что такое исключение с точки зрения JVM

Начнём с фактов об исполнении, потому что вокруг стоимости исключений накручено много фольклора.

try-блок не порождает ни одной инструкции байткода. Компилятор кладёт в class-файл отдельную структуру — exception table: список записей вида «диапазон индексов байткода [from, to) — тип исключения — индекс обработчика». В счастливом пути JVM эту таблицу даже не читает.

Когда исполняется инструкция athrow (или JVM синтезирует исключение сама — например, при разыменовании null), происходит следующее: стек операндов текущего кадра очищается, JVM ищет в таблице текущего метода первую запись, чей диапазон покрывает текущий pc и чей тип совместим с брошенным объектом. Нашла — прыжок на обработчик. Не нашла — кадр снимается со стека, и поиск повторяется у вызывающего.

Таблица исключений в class-файле и размотка стека в JVM

Отсюда следует главный практический вывод: дорог не throw и не try, а new. Конструктор Throwable вызывает нативный fillInStackTrace(), который проходит по всем кадрам стека и материализует массив StackTraceElement. Стоимость линейна по глубине стека: при типичных для Spring-приложения 80–150 кадрах это порядка микросекунд против единиц наносекунд на обычный возврат из метода. Методику измерения разберём в статье о производительности, но порядок величин надо помнить уже сейчас.

// Антипаттерн: исключение как ветвление в горячем цикле.
// На «плохих» данных этот метод в сотни раз медленнее проверки.
static int parseOrDefault(String s) {
    try {
        return Integer.parseInt(s);
    } catch (NumberFormatException e) {
        return -1;
    }
}

// Идиоматично: проверка вместо исключения там, где «ошибка» — норма.
static int parseDigitsOrDefault(String s) {
    if (s == null || s.isEmpty()) return -1;
    // сначала дешёвая проверка формата, и только потом разбор:
    // исключение перестаёт возникать на ожидаемых входных данных
    return s.chars().allMatch(Character::isDigit) ? Integer.parseInt(s) : -1;
}

Если исключение действительно нужно как сигнал в горячем пути (так делают, например, сетевые библиотеки вроде Netty), трассу можно отключить через защищённый конструктор:

/** Сигнальное исключение без стектрейса: создаётся за наносекунды. */
public final class NoMoreData extends RuntimeException {
    public static final NoMoreData INSTANCE = new NoMoreData();

    private NoMoreData() {
        // message, cause, enableSuppression, writableStackTrace
        super(null, null, false, false);
    }
}

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

Две вещи, которые ломают диагностику в проде

OmitStackTraceInFastThrow. JIT-компилятор умеет заменять повторно возникающие «неявные» исключения (NullPointerException, ArrayIndexOutOfBoundsException, ArithmeticException) на заранее созданный экземпляр без трассы. Классическая картина: в логах сначала нормальные стектрейсы, а потом сотни строк java.lang.NullPointerException вообще без at .... Лечится флагом -XX:-OmitStackTraceInFastThrow — цена невелика, на проде его обычно оставляют выключенным именно ради диагностики.

Потеря сообщения. Начиная с JDK 15 работает JEP 358: JVM генерирует подробное сообщение NPE вида Cannot invoke "Order.total()" because "order" is null. Если вы видите голое NullPointerException без пояснения — либо это старая JVM, либо сработал fast throw.

Иерархия Throwable: три ветки с разной семантикой

Компилятор делит Throwable ровно надвое: RuntimeException, Error и их наследники — unchecked, всё остальное — checked, и требует объявления в throws или обработки. Обратите внимание на асимметрию: Error — не «страшное исключение», а класс проблем, в которых прикладной код не может ничего сделать. Ловить Throwable или Error в бизнес-логике почти всегда ошибка: вы перехватите OutOfMemoryError и продолжите работать в JVM, у которой уже нет памяти.

Единственные легитимные места, где ловят Throwable, — верхний уровень пула потоков и границы плагинных систем, и там всегда с немедленным логированием и завершением задачи.

Checked или unchecked: решение, а не вкусовщина

Проверяемые исключения задумывались как часть сигнатуры: «этот метод может не справиться, и вы обязаны это учесть». Идея красивая, но за 30 лет практика вынесла вердикт: checked-исключения хороши для узкого класса ситуаций и вредны для всего остального. Почему — видно по тому, что случается, когда их слишком много: код заполняется catch (Exception e) { /* ignore */ }, а сигнатуры протекают снизу вверх, связывая контроллер с деталями JDBC-драйвера.

Правило Джошуа Блоха (Effective Java, item 71) в переводе на инженерный язык: checked — только если вызывающий может и обязан осмысленно восстановиться, и это верно для КАЖДОГО вызывающего. Если реакция обязательна лишь иногда — берите unchecked и документируйте @throws.

Как это выглядит в реальных библиотеках: Spring полностью отказался от checked-исключений в своих API и переводит SQLException в иерархию unchecked DataAccessException — именно потому, что «поймать SQLException» на уровне сервиса бессмысленно, а вот отличить DuplicateKeyException от QueryTimeoutException полезно. JDK движется в ту же сторону: UncheckedIOException появился в Java 8 ровно для того, чтобы IOException не ломал лямбды.

Стандартные исключения: не плодите свои

Item 72 у Блоха: используйте стандартные типы, когда семантика совпадает.

Ситуация Тип
Недопустимое значение параметра IllegalArgumentException
Объект в неподходящем состоянии для вызова IllegalStateException
Параметр null, где это запрещено NullPointerException
Индекс вне диапазона IndexOutOfBoundsException
Метод не реализован в этой реализации UnsupportedOperationException
Ошибка вычисления с некорректным значением ArithmeticException

Свои типы заводите тогда, когда вызывающий будет по ним ветвиться: InsufficientFunds против AccountBlocked — это разные HTTP-ответы и разные метрики, значит разные классы. Если разницы в обработке нет — хватит одного типа с полем-кодом.

Перевод исключений на границе абстракции

Самое ценное правило всей главы (item 73): бросайте исключения, соответствующие уровню абстракции. Репозиторий не должен выпускать наружу SQLException — вызывающий не обязан знать, что данные лежат в PostgreSQL. Он переводит низкоуровневый сбой в доменный, обязательно сохраняя причину.

public final class JdbcOrderRepository implements OrderRepository {

    @Override
    public Optional<Order> findById(OrderId id) {
        try (var conn = dataSource.getConnection();
             var st = conn.prepareStatement("select * from orders where id = ?")) {
            st.setString(1, id.value());
            try (var rs = st.executeQuery()) {
                return rs.next() ? Optional.of(map(rs)) : Optional.empty();
            }
        } catch (SQLException e) {
            // Перевод абстракции: наружу — доменный тип, внутрь — исходная причина.
            // Сообщение содержит контекст (item 75), а не просто "ошибка БД".
            throw new OrderStorageException("не удалось прочитать заказ " + id.value(), e);
        }
    }
}

Три детали, которые различают зрелый и незрелый код:

  • cause не теряется. new OrderStorageException(msg) без второго аргумента — самая частая причина «нечитаемых» инцидентов: в логе есть факт сбоя и нет ни строчки о том, что именно упало в драйвере.
  • Сообщение содержит данные для диагностики, но не секреты. Идентификатор заказа — да, тело платёжного запроса с токеном — нет.
  • Нет log.error(...) перед throw. Антипаттерн log-and-throw даёт две записи об одном событии: одну из репозитория, одну из глобального обработчика. Логирует тот, кто ошибку гасит, а не тот, кто пробрасывает.

Атомарность сбоя

Item 76: после неудачного вызова объект должен остаться в том же состоянии, что и до него. Самый простой способ — проверять аргументы до изменения состояния:

public void withdraw(BigDecimal amount) {
    // сначала все проверки — потом ни одной модификации не «откатывать»
    if (amount.signum() <= 0) {
        throw new IllegalArgumentException("сумма должна быть положительной: " + amount);
    }
    if (balance.compareTo(amount) < 0) {
        throw new InsufficientFunds(id, balance, amount);
    }
    balance = balance.subtract(amount);   // единственная мутация, и она уже безопасна
}

Иммутабельные типы дают атомарность сбоя бесплатно: если конструктор бросил, объекта просто нет. Это одна из причин, по которым дальше мы будем настойчиво продвигать неизменяемость.

try-with-resources и подавленные исключения

До Java 7 корректное закрытие ресурсов писали в finally, и почти все писали неправильно: если тело try бросало одно исключение, а close() в finally — другое, наружу улетало второе, а первое, настоящее, исчезало бесследно.

try-with-resources решает это на уровне языка. Ресурсы закрываются в порядке, обратном объявлению, а исключения из close() не заменяют основное, а прикрепляются к нему как suppressed.

// Java 9+: в заголовок можно вынести уже готовую effectively final переменную
var in = Files.newInputStream(path);
try (in; var out = Files.newOutputStream(target)) {
    in.transferTo(out);
}
// out закрывается первым, in — вторым

Подавленные исключения нужно уметь читать: в стектрейсе они печатаются с префиксом Suppressed:. Если в вашем логе его нет, а close() подозрительно молчит — проверьте, не написан ли там ручной finally вместо try-with-resources.

finally: четыре способа потерять ошибку

// 1. return в finally проглатывает исключение НАВСЕГДА
static int broken() {
    try {
        throw new IllegalStateException("важная ошибка");
    } finally {
        return 42;   // компилируется с предупреждением, метод вернёт 42
    }
}

// 2. throw в finally затирает основное исключение
try {
    process();
} finally {
    cleanup();   // если cleanup() бросит — исходная ошибка исчезнет
}

// 3. continue/break в finally внутри цикла — тот же эффект, что и return

// 4. пустой catch — самый распространённый
try {
    riskyOperation();
} catch (Exception ignored) { }   // "ignored" не делает это законным

Item 77 у Блоха формулирует минимальную планку: если вы действительно осознанно игнорируете исключение — напишите комментарий, почему это безопасно, и назовите переменную ignored. Всё остальное — потерянная информация об инциденте.

Отдельно про InterruptedException

Прерывание — не ошибка, а кооперативный сигнал отмены. Поймав InterruptedException, вы сбросили флаг прерывания потока, и если не восстановить его, вышестоящий код никогда не узнает про отмену — это классическая причина «зависших» пулов и виртуальных потоков, которые не реагируют на shutdown.

try {
    var task = queue.take();
    handle(task);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();   // восстановить флаг — обязательно
    throw new IllegalStateException("прерван при ожидании задачи", e);
}

Подробности кооперативной отмены — в статье о конкурентности.

Жизненный цикл ошибки в приложении

В прод-сервисе ошибка проходит несколько слоёв, и на каждом с ней делают ровно одно действие. Схема ниже — контракт, который стоит зафиксировать в командном соглашении.

Граница приложения в Spring Boot выглядит так (детали DI и web-слоя — в статье о Spring):

@RestControllerAdvice
public class ApiExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);

    // Ожидаемая доменная ситуация: 404, без стектрейса в логе
    @ExceptionHandler(OrderNotFound.class)
    public ProblemDetail handleNotFound(OrderNotFound e) {
        var pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
        pd.setTitle("Заказ не найден");
        return pd;
    }

    // Неожиданный сбой: 500, стектрейс в лог, наружу — без деталей
    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception e) {
        log.error("необработанная ошибка", e);   // единственное место логирования
        return ProblemDetail.forStatusAndDetail(
                HttpStatus.INTERNAL_SERVER_ERROR, "внутренняя ошибка сервиса");
    }
}

Ключевое правило: наружу не уходит ни одного стектрейса. Трасса — это карта вашего кода и подсказка атакующему; клиенту достаточно кода ошибки, человекочитаемого текста и traceId, по которому вы найдёте детали в логах. Формат ответа стоит взять из RFC 9457 (Problem Details) — в Spring 6 он поддержан из коробки типом ProblemDetail.

Ожидаемые ошибки как значения: sealed + pattern matching

Исключения плохо подходят для ситуаций, которые не исключительны. Валидация формы — не сбой: ошибок может быть несколько, они часть нормального ответа. С Java 21 такие случаи выражаются алгебраическим типом: sealed-интерфейс плюс switch с исчерпывающей проверкой.

public sealed interface PaymentResult {
    record Success(TransactionId id, BigDecimal charged) implements PaymentResult { }
    record Declined(String reasonCode, String message)   implements PaymentResult { }
    record NeedsConfirmation(URI redirect)               implements PaymentResult { }
    record GatewayUnavailable(Duration retryAfter)       implements PaymentResult { }
}

// Компилятор гарантирует полноту: добавите новый вариант — switch перестанет собираться
String describe(PaymentResult result) {
    return switch (result) {
        case PaymentResult.Success(var id, var amount) ->
                "оплачено %s, транзакция %s".formatted(amount, id);
        case PaymentResult.Declined(var code, var msg) ->
                "отказ банка [%s]: %s".formatted(code, msg);
        case PaymentResult.NeedsConfirmation(var uri) ->
                "требуется 3-D Secure: " + uri;
        case PaymentResult.GatewayUnavailable(var retry) ->
                "шлюз недоступен, повтор через " + retry.toSeconds() + " с";
    };
}

Это не «Java догоняет Rust» — это разные инструменты для разных задач. Тип-результат делает ошибку частью сигнатуры и заставляет её обработать здесь и сейчас; исключение позволяет проскочить десять кадров до места, где обработка вообще возможна. Ошибка — пытаться заменить одно другим целиком: код, где каждый метод возвращает Result, в Java превращается в лапшу из вложенных switch, потому что нет ни оператора ?, ни do-нотации.

Библиотечные варианты (Try и Either из Vavr) существуют и работают, но тянут в проект чужую систему типов. Для 90% приложений достаточно связки «исключения для сбоев + sealed-типы для ожидаемых развилок».

Optional: контракт, а не замена null

Optional появился в Java 8 вместе со Stream API и был спроектирован для одной цели — дать возвращаемому значению явный тип «может отсутствовать». Брайан Гётц, архитектор языка, формулировал это прямо: Optional — не механизм общего назначения для избегания null, а способ смоделировать «результата нет» в возвращаемом значении метода (см. его ответ на StackOverflow).

// Правильно: метод честно говорит, что результата может не быть
Optional<User> findByEmail(String email);

// Дальше — цепочка без единой проверки на null
String displayName = users.findByEmail(email)
        .map(User::profile)
        .flatMap(Profile::nickname)          // Optional<String> внутри Optional<Profile>
        .map(String::strip)
        .filter(s -> !s.isEmpty())
        .orElseGet(() -> maskEmail(email));  // вычисляется только если пусто

Полезные методы, о которых часто не знают

opt.orElse(defaultValue);          // значение вычисляется ВСЕГДА — осторожно
opt.orElseGet(this::expensive);    // ленивое вычисление, по умолчанию берите его
opt.orElseThrow();                 // JDK 10+, читаемая замена get()
opt.orElseThrow(() -> new OrderNotFound(id));
opt.ifPresentOrElse(this::send, this::logMissing);   // JDK 9
opt.or(() -> fallbackRepo.find(id));                 // JDK 9, ленивая альтернатива
opt.stream();                                        // JDK 9, склейка со Stream API
opt.isEmpty();                                       // JDK 11, читается лучше, чем !isPresent()

Разница orElse и orElseGet — самая частая незамеченная ошибка производительности:

// БАГ: createDefaultUser() выполняется даже когда пользователь найден
User u = repo.findById(id).orElse(createDefaultUser());

// Правильно: вызов только при отсутствии значения
User u = repo.findById(id).orElseGet(this::createDefaultUser);

Где Optional неуместен

Антипаттерн Что не так Как надо
Optional<T> как поле сущности лишний объект на каждую запись, класс перестаёт быть Serializable обычное поле + документированный null или Optional в геттере
Optional<T> как параметр метода вызывающий обязан оборачивать; два вызова вместо перегрузки перегрузка методов или @Nullable
Optional<List<T>> два способа сказать «ничего нет» пустая коллекция
opt.get() без проверки NoSuchElementException без контекста orElseThrow(() -> ...) с доменным типом
opt.isPresent() ? opt.get() : def ручной разбор вместо API orElse / orElseGet
Optional.ofNullable(x).orElse(y) тяжеловесно для простого дефолта Objects.requireNonNullElse(x, y)
Optional в горячем цикле аллокация на каждой итерации обычный null внутри приватного кода

Ещё две технические детали. Optionalvalue-based class: на нём нельзя синхронизироваться, его нельзя сравнивать через ==, и в будущем (проект Valhalla) он может стать value-типом без идентичности. И он не реализует Serializable, что намеренно: в DTO, летящих через сериализацию, ему не место.

Отличный разбор всех пограничных случаев — доклад Стюарта Маркса Optional: The Mother of All Bikesheds.

Immutability: final — это не «неизменяемый»

final у поля означает ровно одно: ссылку нельзя переприсвоить. Что происходит по этой ссылке, final не контролирует. Именно на этом ломается наивное представление, будто record автоматически даёт неизменяемость.

Три способа положить список внутрь record и три разных гарантии

public record Order(OrderId id, Instant createdAt, List<Item> items) {

    // Компактный конструктор: валидация и нормализация ДО присваивания полям
    public Order {
        Objects.requireNonNull(id, "id");
        Objects.requireNonNull(createdAt, "createdAt");
        // List.copyOf делает независимый снимок и запрещает null-элементы
        items = List.copyOf(items);
    }

    // «Витер»: новый объект вместо мутации
    public Order withItem(Item extra) {
        var next = new ArrayList<>(items);
        next.add(extra);
        return new Order(id, createdAt, next);   // копия снова заморозится в конструкторе
    }

    public BigDecimal total() {
        return items.stream().map(Item::price).reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

Проверка, которую стоит сделать глазами при код-ревью любого «неизменяемого» типа:

  1. Все поля final и объявлены в классе, который либо final, либо sealed.
  2. Изменяемые коллекции копируются на входе (List.copyOf) — иначе вызывающий сохранит ссылку и продолжит менять содержимое.
  3. Изменяемые объекты копируются на выходе — геттер не должен отдавать внутренний Date, ArrayList или массив (item 50 у Блоха: defensive copies).
  4. Массивов в публичном API нет вообще: неизменяемых массивов в Java не существует, а clone() на каждом обращении дорог.

Разница между Collections.unmodifiableList(x) и List.copyOf(x) — не косметическая: первый возвращает представление поверх чужого хранилища, и правки через исходную ссылку будут видны сквозь «неизменяемую» обёртку. Именно эту ловушку иллюстрирует схема выше.

Почему иммутабельность в Java — не только про стиль

Потокобезопасность бесплатно. Java Memory Model даёт особую гарантию для final-полей (JLS §17.5): значения, записанные в конструкторе, «замораживаются» на его выходе, и любой поток, получивший корректно опубликованную ссылку, увидит их без синхронизации. Для не-final полей такой гарантии нет — там нужен volatile или блокировка. Это фундамент всей главы о конкурентности.

Корректные ключи. Мутабельный объект в HashMap или HashSet — источник багов класса «элемент есть, а contains возвращает false»: изменение поля меняет hashCode, и объект остаётся в чужом бакете. Подробности устройства — в статье о коллекциях.

Цена ниже, чем кажется. Аргумент «копирование убивает производительность» в Java почти всегда неверен: молодое поколение GC устроено так, что аллокация — это сдвиг указателя, а сборка мёртвых короткоживущих объектов не стоит почти ничего. Плюс escape analysis умеет вовсе не создавать объект, если тот не покидает метод. Модель памяти и GC разбираются в статье о JVM.

Где действительно дорого. Точечные правки больших коллекций: List.copyOf от списка на миллион элементов на каждой операции — это O(n) на изменение. Здесь нужны либо персистентные структуры (Vavr, PCollections), либо паттерн «мутабельный builder → неизменяемый результат», как в StringBuilder и Stream.collect.

Null-hostile API: как перестать ловить NPE

Java не умеет отличать String от «String или null» на уровне типов — в отличие от Kotlin или C# с включёнными nullable reference types. Компенсируется это дисциплиной плюс инструментами.

public final class ReservationService {
    private final Clock clock;

    public ReservationService(Clock clock) {
        // Падаем немедленно и в понятном месте, а не через три вызова с непонятным NPE
        this.clock = Objects.requireNonNull(clock, "clock");
    }

    public Reservation reserve(String userId, Instant from, Duration length) {
        Objects.requireNonNull(userId, "userId");
        Objects.requireNonNull(from, "from");
        if (length.isNegative() || length.isZero()) {
            throw new IllegalArgumentException("длительность должна быть положительной: " + length);
        }
        ...
    }
}

Правило проектирования: API не принимает и не возвращает null. Не принимает — requireNonNull в начале публичных методов. Не возвращает — пустая коллекция вместо null-списка, Optional вместо null-значения. Тогда null остаётся только внутри приватной реализации, где его область видимости — десяток строк.

Инструменты, которые переводят это из соглашения в проверку:

  • JSpecify — с 2024 года общий для индустрии набор аннотаций (@Nullable, @NonNull, @NullMarked), который понимают IDE и статические анализаторы.
  • NullAway — плагин к Error Prone, проверяющий null-корректность во время компиляции с накладными расходами порядка нескольких процентов времени сборки.
  • Встроенные инспекции IntelliJ IDEA по тем же аннотациям.

Подключение анализаторов в пайплайн — тема статьи о SDLC.

Проверяемые исключения и лямбды

Отдельная боль, с которой вы столкнётесь в следующей статье про Stream API: функциональные интерфейсы JDK не объявляют checked-исключений, поэтому такой код не компилируется.

// ОШИБКА КОМПИЛЯЦИИ: Files.readString бросает IOException,
// а Function.apply её не объявляет
List<String> texts = paths.stream()
        .map(p -> Files.readString(p))
        .toList();

Идиоматичное решение — обернуть в unchecked прямо в лямбде, именно для этого в JDK есть UncheckedIOException:

List<String> texts = paths.stream()
        .map(p -> {
            try {
                return Files.readString(p);
            } catch (IOException e) {
                throw new UncheckedIOException("не прочитан файл " + p, e);
            }
        })
        .toList();

Если такое повторяется, вынесите адаптер:

@FunctionalInterface
interface IoFunction<T, R> {
    R apply(T t) throws IOException;
}

static <T, R> Function<T, R> unchecked(IoFunction<T, R> f) {
    return t -> {
        try {
            return f.apply(t);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    };
}

// применение
List<String> texts = paths.stream().map(unchecked(Files::readString)).toList();

Трюк «sneaky throws» (обход проверки через дженерик-каст) в библиотеках встречается, но в прикладном коде его лучше не заводить: он ломает ожидания читателя и делает catch по типу невозможным без чтения исходников.

Честно: чем Java-подход отличается от соседей

Java — единственный массовый язык с проверяемыми исключениями, и это одновременно её сильная и слабая сторона. Сильная: в сигнатуре видно, что операция может не удаться, и компилятор не даст об этом забыть. Слабая: механизм не композируется — ни с лямбдами, ни с дженериками, ни с асинхронностью, и за 30 лет так и не получил ни одного удобного оператора для проброса.

Соседний трек C#/.NET решает те же задачи иначе, и различия поучительны:

  • Checked-исключений нет вообще. C# сознательно отказался от них ещё в 2000-х — Андерс Хейлсберг считал их провалившимся экспериментом. Плата: в C# нельзя по сигнатуре понять, что метод может бросить, и документация становится единственным источником правды.
  • Nullability — в системе типов. С C# 8 компилятор отличает string от string?. Java такого не имеет и в обозримом будущем не получит; JSpecify — надстройка, а не язык.
  • using против try-with-resources решают одну задачу, но в C# исключение из Dispose() затирает исходное — механизма suppressed там нет.
  • with-выражения для record’ов встроены в C#. В Java «витеры» пишутся руками; автоматический вариант обсуждается в JEP 468.

Другие модели полезно знать как контрастный фон. В Go ошибка — обычное значение, возвращаемое вторым; многословно, зато нет невидимых путей выполнения. В Rust Result<T, E> плюс оператор ? дают композицию, которой Java-типам результата как раз не хватает. В Elixir работает противоположная философия — «let it crash»: процесс падает, супервизор перезапускает, и ошибку не обрабатывают вовсе.

Java — посередине: исключения для сбоев, значения для ожидаемых развилок, дисциплина и статический анализ вместо гарантий компилятора. Это рабочий, но требующий культуры компромисс: язык не заставит вас писать правильно, поэтому правила должны быть записаны в командном соглашении и проверяться линтером.

Грабли: сводка

Грабля Симптом в проде Лечение
catch (Exception e) { } ошибка исчезает, поведение «просто неправильное» логировать или пробрасывать, всегда
e.printStackTrace() трасса в stdout мимо системы логов log.error("контекст", e)
throw new X(msg) без cause в логе нет исходной причины всегда передавайте cause
log-and-throw дубли ошибок в логах, шум в алертах логирует тот, кто гасит
catch (Throwable) в бизнес-логике JVM продолжает после OutOfMemoryError ловить Exception и не выше
Проглоченный InterruptedException пулы не завершаются по shutdown восстановить флаг interrupt()
return в finally исключение исчезает молча никогда не выходить из finally
SQLException в контроллере web-слой знает про JDBC перевод абстракции в репозитории
Стектрейс в HTTP-ответе утечка структуры кода наружу ProblemDetail + traceId
optional.get() NoSuchElementException без контекста orElseThrow(() -> доменный тип)
orElse(дорогойВызов()) лишняя работа на счастливом пути orElseGet(...)
Optional в полях и параметрах лишние аллокации, проблемы сериализации Optional только в возвращаемых типах
Ссылка на чужой список в record «неизменяемый» объект меняется снаружи List.copyOf в компактном конструкторе
Мутабельный объект как ключ HashMap элемент «пропадает» из множества ключи только неизменяемые
Исключение как ветвление в цикле необъяснимая просадка CPU проверка вместо исключения
Пустые NPE в логах ничего не видно, кроме имени класса -XX:-OmitStackTraceInFastThrow

Мини-итог

  • Исключение в JVM — это запись в таблице класса плюс поиск обработчика при размотке. try бесплатен, throw дёшев, дорог new Throwable: он снимает стектрейс.
  • Checked — только для сбоев, на которые обязан реагировать каждый вызывающий. Всё остальное — unchecked, с документацией и переводом абстракции на границах слоёв.
  • Причину (cause) не терять, логировать один раз в точке гашения, наружу стектрейсы не отдавать, для ожидаемых развилок использовать sealed-типы, а не исключения.
  • Optional — тип возвращаемого значения, а не универсальная замена null. orElseGet по умолчанию, get() не использовать.
  • final защищает ссылку, не содержимое. Настоящая неизменяемость — это копии на входе и на выходе, и она даёт бесплатную потокобезопасность через семантику final-полей в JMM.

Источники

Что дальше

Мы научились аккуратно работать с отсутствием значения и с неуспехом. Следующий шаг — работа с последовательностями: как Stream устроен внутри, чем ленивость отличается от итерации, какие коллекторы бывают и где функциональный стиль в Java перестаёт окупаться.

Stream API и функциональный стиль: лямбды, коллекторы, ленивость

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов