Идиоматика и обработка ошибок: исключения, 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 здесь используются как рабочий инструмент, а не объясняются заново.
Три вопроса, на которые отвечает обработка ошибок
Прежде чем выбирать механизм, стоит развести три разных вопроса, которые в обсуждениях постоянно склеивают:
- Кто отвечает за ошибку? Вызывающий нарушил контракт (передал
nullв метод, который этого не допускает) — отвечает он, и это баг. Упал сетевой канал — не отвечает никто, это свойство мира. - Может ли вызывающий что-то сделать? Повторить, взять из кеша, показать форму с подсветкой поля — или ему остаётся только всплыть выше.
- Насколько это частый сценарий? «Пользователь не найден» в поиске случается тысячи раз в секунду; «диск заполнен» — раз в год.
Механизм выбирается по ответам: исключение — для редкого и/или неустранимого, тип-результат
или Optional — для частого и ожидаемого. Всё остальное — детали синтаксиса.
Что такое исключение с точки зрения JVM
Начнём с фактов об исполнении, потому что вокруг стоимости исключений накручено много фольклора.
try-блок не порождает ни одной инструкции байткода. Компилятор кладёт в class-файл
отдельную структуру — exception table: список записей вида «диапазон индексов байткода
[from, to) — тип исключения — индекс обработчика». В счастливом пути JVM эту таблицу
даже не читает.
Когда исполняется инструкция athrow (или JVM синтезирует исключение сама — например,
при разыменовании null), происходит следующее: стек операндов текущего кадра очищается,
JVM ищет в таблице текущего метода первую запись, чей диапазон покрывает текущий pc
и чей тип совместим с брошенным объектом. Нашла — прыжок на обработчик. Не нашла — кадр
снимается со стека, и поиск повторяется у вызывающего.
Отсюда следует главный практический вывод: дорог не 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.
самим вызывающим?"} B -->|да| C["Unchecked:
IllegalArgumentException,
IllegalStateException,
NullPointerException"] B -->|нет| D{"Может ли вызывающий
осмысленно среагировать?"} D -->|"нет, это баг или сбой среды"| E["Unchecked:
доменный RuntimeException
с cause"] D -->|"да: retry, fallback,
подсказка пользователю"| F{"Реакция обязательна
для каждого вызывающего?"} F -->|да| G["Checked:
наследник Exception"] F -->|"нет, только для части"| H["Unchecked + javadoc
или тип-результат"] C --> I["Документируйте @throws,
не ловите у себя же"] E --> I H --> I G --> J["Сигнатура — часть контракта:
меняя её, ломаете вызывающих"]
Как это выглядит в реальных библиотеках: 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 внутри приватного кода |
Ещё две технические детали. Optional — value-based class: на нём нельзя
синхронизироваться, его нельзя сравнивать через ==, и в будущем (проект Valhalla) он
может стать value-типом без идентичности. И он не реализует Serializable, что
намеренно: в DTO, летящих через сериализацию, ему не место.
Отличный разбор всех пограничных случаев — доклад Стюарта Маркса Optional: The Mother of All Bikesheds.
Immutability: final — это не «неизменяемый»
final у поля означает ровно одно: ссылку нельзя переприсвоить. Что происходит по
этой ссылке, final не контролирует. Именно на этом ломается наивное представление,
будто 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);
}
}
Проверка, которую стоит сделать глазами при код-ревью любого «неизменяемого» типа:
- Все поля
finalи объявлены в классе, который либоfinal, либоsealed. - Изменяемые коллекции копируются на входе (
List.copyOf) — иначе вызывающий сохранит ссылку и продолжит менять содержимое. - Изменяемые объекты копируются на выходе — геттер не должен отдавать внутренний
Date,ArrayListили массив (item 50 у Блоха: defensive copies). - Массивов в публичном 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.
Источники
- The Java Tutorials: Exceptions — базовый разбор от Oracle.
- JVM Specification §2.10, §3.12 — исключения на уровне байткода и exception table.
- JLS §11: Exceptions — формальные правила checked-анализа.
- JLS §17.5: final Field Semantics — гарантии видимости для неизменяемых объектов.
- JEP 358: Helpful NullPointerExceptions — как JVM объясняет, что именно было
null. - Javadoc: Optional — читайте контракт целиком, включая раздел про value-based classes.
- Bloch, Effective Java, 3rd ed. — глава 10 (items 69–77) и item 17 «Minimize mutability».
- Goetz et al., Java Concurrency in Practice — глава 3 о безопасной публикации неизменяемых объектов.
- Brian Goetz о назначении Optional — первоисточник про «Optional только для возвращаемых значений».
- Stuart Marks, Optional: The Mother of All Bikesheds — разбор всех спорных случаев.
- RFC 9457: Problem Details for HTTP APIs — стандартный формат ответа об ошибке.
- Spring: DataAccessException hierarchy — образцовый пример перевода исключений.
- JSpecify и NullAway — статическая проверка null-корректности.
Что дальше
Мы научились аккуратно работать с отсутствием значения и с неуспехом. Следующий шаг —
работа с последовательностями: как Stream устроен внутри, чем ленивость отличается
от итерации, какие коллекторы бывают и где функциональный стиль в Java перестаёт
окупаться.
Stream API и функциональный стиль: лямбды, коллекторы, ленивость