Всем привет! Меня зовут Илья, я работаю в Райффайзен Банке. Мы пишем свои бэкенд-сервисы на Java и Kotlin, поэтому зачастую приходится переключаться с одного языка на другой. Из-за этого невольно начинаешь сравнивать подходы и механизмы одного языка с его JVM-собратом. Сегодня я бы хотел поговорить об одном из таких механизмов — пропагации ошибок и исключений.

Используете ли вы в своем коде исключения? Ответ кажется странным, так как исключения являются неотъемлемой частью Java. Но что, если я спрошу, используете ли вы исключения для управления логикой своей программы?

Дисклеймер:

В данной статье при использовании слова «Ошибка» или «Исключение», я обычно буду иметь в виду логическую ошибку. Например, только заявка на ипотеку не найдена в Базе Данных, в противовес технических ошибок: когда мы вызвали удаленный веб-сервис и упали с таймаутом, так и не дождавшись ответа.

Для начала давайте вспомним, какие вообще способы контроля флоу программы есть у Java:

  1. Условия 

    • if/else

    • switch/when

  2. Циклы 

    • fori/foreach

    • do while/while do

  3. Остальные

    • GOTO labels + break/continune

    • throw/try/catch (Exception e)/finally

    • return ResultOrError (Typed Errors)

Первые 2 группы работают в пределах одной функции, так что давайте не будем их рассматривать. Break и Continune по большей части тоже, да и используются они редко.

У нас остаются «Исключения» и «Typed Errors». Рассмотрим для начала «Исключения» и попробуем разобраться, как с ними работать в рамках контроля поведения нашей логики.

Исключения

Начнем с «Исключений». Документация Java приводит нам такое определение:

Definition: An exception is an event, which occurs during the execution of a program, that disrupts the normal flow of the program's instructions

Ключевое слово здесь «disrupts», то есть «прерывает». Мы можем в любом месте нашего метода прервать выполнение и выйти из него без результата. Более того, мы будем прерывать все функции по стеку вызовов, пока не встретим обработчик исключений.

Рассмотрим пример:

public User create(String name, int age) {
    if (name == null || name.isEmpty()) {
        throw new IllegalArgumentException("Name is null or empty");
    }
    if (age < 18) {
        throw new IllegalArgumentException("Age is less than 18");
    }
  
    return new User(name, age);
}

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

С одной стороны, неважно, ведь мы написали метод, а уже кто и как будет обрабатывать исключение — не наша забота!

Но давайте будем сознательными разработчиками и подумаем, сможет ли клиент этого метода понять, что будет кинут IllegalArgumentException? Нет, так как это Unchecked Exception, а следовательно, компилятор никак не поможет, и нас может ждать неожиданная ситуация прямо в рантайме или даже на проде.

Кстати, документация Java дает интересное определение для Unchecked Exception.

These are exceptional conditions that are internal to the application, and that the application usually cannot anticipate or recover from. These usually indicate programming bugs, such as logic errors or improper use of an API

Как будто, improper use of an API наш вариант, но остается проблема с тем, что клиент этого метода никак не заметит (без изучения исходного кода), что такая ситуация может произойти. Поэтому давайте немного перепишем:

public User create(String name, int age) throws CustomIllegalArgumentException {
    if (name == null || name.isEmpty()) {
        throw new CustomIllegalArgumentException("Name is null or empty");
    }
    if (age < 18) {
        throw new CustomIllegalArgumentException("Age is less than 18");
    }

    return new User(name, age);
}

Для этого нам придётся создать новый Checked Exception (проверяемые исключения):

public class CustomIllegalArgumentException extends Exception {
    
    public CustomIllegalArgumentException(String message) {
        super(message);
    }
}

Checked Exception заставит компилятор удостовериться, что клиент что-то сделает с этим исключением. Как это происходит? Из документации мы узнаем, что есть некий Catch or Specify Requirement. Он говорит о том, что теперь как клиент, мы обязаны или поймать (обработать) исключение, или пробросить его дальше по стеку вызовов.

В итоге мы получим на вызывающей стороне такую картину:

try {
    userService.create("Петя", 15);
} catch (CustomIllegalArgumentException e) {
    logger.error("Error! User invalid!", e);
}

Выглядит солидно! Исключение выбрасывается, мы его ловим и делаем кастомную логику на случай, когда пользователь не может быть создан. Но будем ли мы уверены в том, что клиентская сторона обработает этот случай, а не сделает вот так?

public void create() throws CustomIllegalArgumentException {
    userService.create("Петя", 15);
}

С мыслями: а я не знаю, что делать, когда пользователь некорректный, надеюсь, выше по стеку вызовов знают! Или вообще, может, ну его, этот try/catch, некрасивый он и громоздкий! 

А что обычно Java-разработчики делают с громоздкими участками кода? Правильно! Стараются спрятать его подальше от глаз. Например, библиотека Lombok позволяет на этапе компиляции убрать, добавить или модифицировать некоторый код, например, сгенерировать getters/setters/constructors. 

О! Аннотация @SkeakyThrows — как раз то, что нужно. Она делает так, что javac теперь не будет ругаться на то, что мы никак не обработали или не прокинули выше по стеку наш CustomIllegalArgumentException.

@SneakyThrows
public void create() {
    userService.create("Петя", 15);
}

Во! Красота! А случай с некорректным пользователем редкий, и он случится не на моем веку.

В итоге получается такая картина:

Но почему создатели Lombok придумали аннотацию, которая обходит такую фундаментальную фичу Java, как Checked Exception? Разве они что-то плохое, и мы должны их обходить? Мы уверены, что создатели языков программирования — люди с гигантским опытом и знаниями — не могут создать неудачное решение.

К сожалению, это не всегда так.

Создатель Java однажды сказал о Checked Exception:

You can’t accidentally say, ‘I don’t care.’ You have to explicitly say, ‘I don’t care

James Gosling

Его задумка была в том, чтобы мы, как разработчики, или обрабатывали исключения, или явно декларировали в своем коде: «Я не хочу этого делать, мне плевать». В итоге мы имеем то, что имеем: практически все новые исключения создаются наследниками от RuntimeException — таким образом, их не обязательно ловить, а те Checked Exception, что уже есть в Java, не обрабатываются должным образом. Согласитесь, такая картина не редкость:

static void closeChannel(@Nullable Channel channel) {
    if (channel != null && channel.isOpen()) {
        try {
            channel.close();
        }
        catch (IOException ignored) {
        }
    }
}

Если вы думаете, что это мой код или код какого-то Vasyan666 с GitHub, то вы ошибаетесь — это метод org.springframework.core.io.buffer.DataBufferUtils#closeChannel из нашего любимого фреймворка Spring.

Из-за этого, глядя на печальный опыт Java, создатели многих языков программирования решили вообще не включать Checked Exception в свой язык. Они понимали, что это лишь усложнит жизнь обычным разработчикам и оттолкнет их от использования языка.

Но есть ли какое-то решение?

Например, в языке Go при вызове функции возвращается не только результат, но и ошибка err. При этом код начинает пестрить лишними проверками, так как не каждый вызов функции оканчивается ошибкой.
Так что разные языки по-своему решают проблему обработки ошибок. Одни отказываются от проверяемых исключений, другие делают обработку ошибок частью сигнатуры функции. Но такой подход тоже не лишён недостатков. Код превращается в череду однотипных проверок.
Из-за этого в мире языков, которые реализуют функциональную парадигму программирования, появилось понятие Typed Errors.

Typed Errors

Для начала приведем определение:

Typed errors refer to a technique from functional programming in which we make explicit in the signature (or type) the potential errors that may arise during the execution of a piece of code

Простыми словами, возвращаемый тип функции теперь содержит не только ее результат, но и/или ошибку.

Например, в Scala и Haskel это реализовано через класс Either стандартной библиотеки. Но так как в нашем банке эти языки не пользуются особой популярностью, рассмотрим технику Typed Errors на языке Kotlin. Он уже содержит класс kotlin.Result, который в зависимости от контекста может хранить или result или throwable.

Однако разработчики пошли дальше и расширили эту функциональность в библиотеке ArrowKt. Давайте воспользуемся ей и перепишем наш пример с валидацией пользователя:

fun create(name: String?, age: Int) = either<Error, User> {
    if (name.isNullOrEmpty()) return Error("Name is null or empty").left()
    if (age < 18) return Error("Age is less than 18").left()
    
    return User(name, age).right()
}

Разберемся, что у нас изменилось:

  1. Теперь в функции мы возвращаем не User, а монаду враппер-класс Either. Это один из двух логических результатов нашей функции — успех (тип User) и ошибка (тип Error). Так как в compile-time мы не знаем результата, в этом классе сейчас содержится сразу 2 типа, но в run-time мы увидим только один.

  2. На каждом return мы не просто создаем результат, а преобразуем его в Either-тип — успех или ошибка с помощью функций right() и left(). Обычно справа у нас обычный результат функции, а вот левая часть — это наша TypedError, в которой может лежать любой тип, даже Exception.

  3. Все тело метода теперь — это блок с типом Raise<Error>.() -> A (продюсер объекта типа A с ресивером типа Raise<Error>). Если вам стало не по себе от предыдущего предложения, ничего страшного, это значит лишь то, что мы теперь можем вызывать в нашем блоке функции вроде raise(Error(«Name is null or empty»)) и поддерживать вложенные функции, которые возвращают Either.

Давайте как раз рассмотрим такой вариант (и заодно заиспользуем удобную функцию ensure):

fun create(name: String?, age: Int) = either<Error, User> {
    val newName = createName(name).bind()
    val newAge = createAge(age).bind()

    User(newName, newAge)
}

fun createName(name: String?) = either<Error, String> {
    return ensure(!name.isNullOrEmpty()) { Error("Name is null or empty") } else name.right()
}

fun createAge(age: Int) = either<Error, Int> {
    return ensure(age > 18) { Error("Age is less than 18") } else age.right()
}

В данном случае при ошибке Name is null or empty мы сразу свяжем Error из функции createName с функцией create с помощью функции bind() и сразу вернем результат, не заходя в функцию createAge. Функция bind() имеет еще одно предназначение — она сигнализирует, что из какой-то функции возвращается не просто результат, а Either.

Погоди, скажете вы. Это те же checked exceptions, только вид сбоку!

На это я могу сказать следующее:

  1. Нельзя просто так взять™ и создать проверяемое исключение. Для этого JVM нужно собрать весь stack trace от начала до конца, что требует некоторых ресурсов и процессорного времени. На деле это не совсем так и имеют место различные оптимизации — подробнее в докладе Владимира Ситникова.

  2. В то время как проверяемые исключения обособленный механизм Java, Typed Errors встроены в return-type, а не существуют от него отдельно. Это значит, что мы можем использовать с ними все инструменты нашего языка: циклы, условия, дженерики и паттерн-матчинг. Представьте себе стрим, где первым делом вы маппите элемент и получаете результат или, в плохом случае, «Исключение». Впоследствии вам нужно вывести результаты в консоль. Проблема возникнет на функции map, которая по сигнатуре требует Function<A, B>! Как говорится, исключение в сделку не входило:

List.of("a.txt", "b.txt", "c.txt").stream()
                .map(fileName -> FileUtils.readLines(new File("~/" + fileName)))// ой-ой Unhandled exception: java.io.IOException
                .forEach(System.out::println);

3.Type Errors имеют мощную поддержку со стороны библиотеки в плане различных helper-функций, которые формируют своего рода DSL, например:

fun main() {
    val user = create("Vasya", 19)
        .map { user -> user.copy(surname = "Petrov") }
        .onLeft { println("Error! $it");return }
        .getOrNull()!! // NPE? Never

    println("User $user created!")
}

В данном случае мы строим функциональную цепочку, которая говорит нам о том, что:

  • Если результат функции create - right, то измени его и добавь поле surname = "Petrov". Далее разверни either и распечатай готовый результат в консоль.

  • Если же результат - left, напечатай ошибку;

В итоге получим:

Error! java.lang.Error: Age is less than 18

Или:

User User(name=Vasya, age=19, surname=Petrov) created!

Более подробные примеры и дополнительные фичи библиотеки можно посмотреть прямо в документации.

Typed Errors: the bad parts

Ну все, бежим прикручивать к Kotlin-проектам эту волшебную библиотеку и жить долго и счастливо? Давайте я добавлю пару ложек дегтя, которые основаны на реальном использовании такого подхода в классических Spring Boot проектах:

  1. Транзакции. Давайте вспомним один из самых популярных вопросов на интервью: «Как работает @Transactional?». При вызове метода, сначала вызывается аспект, который до вызова делает createTransactionIfNecessary(), а после commitTransactionAfterReturning(). Что же заставит транзакцию откатиться? Правильно — исключение, которое будет поймано в одном из блоков try/catch. Как ни странно, с Typed Error это не сработает. Затейники со Stack Overflow уже придумали фикс, но он кажется страшнее, чем сама проблема. В теории, можно сделать вызов TransactionInterceptor.currentTransactionStatus().setRollbackOnly(), но это уже вопрос того, как вручную заставить транзакцию откатиться.

  2. Конструкторы. Настоящие программисты™ пишут программы согласно доменной модели, а настоящая модель имеет правила валидации, и мы не сможем создать объект, если его параметры не удовлетворяют этой валидации. По канону, мы должны возвратить Either из конструктора, но по правилам языка — это запрещено. К счастью, создатели библиотеки придумали остроумный финт ушами, который решит эту проблему, но сделает ваш код чуточку замудреннее.

  3. Забытый bind(). Допустим, у нас есть подобный код:

either<String, String> {
      sendMessage()
      “Success”
  }
fun sendMessage(): Either<String, Unit> = either { raise("Error") }

На первый взгляд все ОКЕЙ, мы ожидаем, что main вернет нам Left, в котором содержится наш Error, но в действительности мы вернем Right с Success! Мы забыли вызвать bind на sendMessage! И что самое обидное, этот код спокойно компилируется и запускается. Поймать такую ошибку можно лишь при качественном покрытии тестами или при использовании линтеров с кастомными правилами, настроенными на подобные кейсы.

В итоге давайте взвесим все плюсы и минусы:

Exceptions

Typed errors

Плюсы

Пишем код так, как завещали предки.

В исключениях хранится подробная информация о том, где именно было зафиксировано исключение вплоть до самого основания стека вызовов.  

Все фреймворки, библиотеки и другие коллеги-разработчики привыкли к логике бросания исключений и ожидают этого от клиентского кода.

Метод явно декларирует варианты результатов и мы обязаны их обрабатывать.

Удобный DSL для обработки ошибок - можем проверить, смаппить или сбиндить ошибку.

Видно, какие методы возвращают ошибку среди большого количества вызовов.

Минусы

Частое игнорирование проверяемых исключений и опасность забыть, поймать непроверяемые.

Гигантские блоки try/catch прячут реальный метод, откуда бросилось исключение.

Сомнительный перформанс при большом количестве исключений.

Код становится сложнее. Никаких Either, left/right могло и не быть, а программист обязан подумать еще один раз.

Никто не гарантирует, что фреймворки/зависимости вокруг нас сразу перестанут кидать исключения. Получается, что теперь мы должны поддерживать обе парадигмы обработки ошибок в нашем проекте.

+1 зависимость в проекте.

Неожиданные приколы с фреймворками и библиотеками. Данная практика не настолько распространена, чтобы ее поддерживали все решения.

Если бы я мог выбирать при старте проекта, какой подход использовать, то я бы воспользовался следующими правилами:

  • Если проект — классическое приложение на Spring Boot/Data Jpa/Secutrity, в котором нет сложной логики с различными ошибками, появляющимися на том или ином этапе выполнения, то подходит традиционный подход с исключениями. Эти фреймворки ожидают, что мы будем кидать исключения, а логика не так сложна, чтобы контролировать ее с помощью Typed Errors.

  • Если проект содержит запутанную бизнес-логику с различными вариантами поведения, правилами валидации и важно не падать при любом возможном случае, а всегда гибко обрабатывать ошибки, тогда наш выбор — Typed Errors. Это особенно логично, если мы планируем писать код в функциональном подходе с использованием chained-функций и методов с различными ресиверами.

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

Заключение

Как и у любого решения, у «Исключений» и «Typed Errors» есть свои плюсы и минусы, которые мы как разработчики, должны учитывать. Важно помнить, что не существует серебряной пули: иногда проще использовать проверенные временем «Исключения», а при других условиях избегать неприятных сюрпризов на проде и делать код более предсказуемым для коллег помогает Typed Errors. В конечном счёте, именно умение выбирать соответствующую проекту комбинацию фреймворков, библиотек и подходов — чаще всего отличает профессионального программиста.

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

Спасибо за внимание! Если есть вопросы или хочется поделиться своим опытом — пишите в комментариях, обсудим!

Комментарии (37)


  1. urvanov
    17.06.2025 10:34

    Кстати, документация Java дает интересное определение для Unchecked Exception.

    These are exceptional conditions that are internal to the application, and that the application usually cannot anticipate or recover from. These usually indicate programming bugs, such as logic errors or improper use of an API

    Когда я учил Java, то там было написано немного иначе. RuntimeException - это ошибка программиста. В нормальной оттестированной программе их не должно быть. Примеры: выход за границу массивы, обращение к полю у переменной null и т. д. Конкретно в случае с классом User из статьи исключение IllegalArgumentException может возникать только из-за того, что не было адекватной проверки пользовательского ввода с формированием сообщений, понятных пользователю, и отображению их в интерфейсе.

    CheckedException - это исключение, которое может возникнуть в правильно написанной программе. Это не ошибка алгоритма и не ошибка программиста. Например: ошибка сети, отсутствие файла, недостаточно прав для выполнения действия и т. д. Именно поэтому CheckedException всегда надо обрабатывать и всегда указывать в throws.

    В Spring Framework да, там все CheckedException внутри фреймворка оборачиваются в RuntimeException. В Spring философия отличается от философии чистой Java. Там считают, что CheckedException - это зло. Их не должно быть. Почему так - не могу сказать. Возможно, у них были свои причины.


    1. cpud47
      17.06.2025 10:34

      Не увидел противоречия Вашего определения и цитаты.

      Почему так - не могу сказать

      Потому что языковых средств не хватает. Например, вот хочу я обработать все ошибки бд в каком-то фильтре/мидлваре – я не могу как-то пометить свои хендлеры, что они кидают DbException, а мидлварь - что она его обрабатывает(чтобы на меня заругались, если вдруг я забыл добавить мидлварь). А большинство ошибок именно так и стоит обрабатывать в спринге... Выразительности физически не хватает.


      1. NickNill
        17.06.2025 10:34

        Потому что обрабатывать ошибки - лень, и не всегда нужно


  1. ruomserg
    17.06.2025 10:34

    Мы в проекте использовали следующее соглашение: методы можно объявлять либо без throws, либо с throws Exception - и больше никак (т.е. никаких IOException, MyCustomException в описаниях нет, и быть не может). Дальше правила жизни:

    • Если ты вызвал функцию которая throws Exception, но не хочешь/не можешь делать error recovery (99% случаев) - пометь свою функцию throws Excepton, и живи дальше (очень похоже на то, к чему в конце-концов пришел C++).

    • Если ты хочешь сделать error recovery - то лови только те исключения, которые ты можешь/хочешь обработать именно на этом уровне.

    • Категорически запрещается ловить и проглатывать произвольное исключение. Разрешается (хотя и не поощряется) подход catch+rethrow - обычно это паттерн catch+log+rethrow. Загромождает логи, но иногда нужно для более быстой локализации проблем.

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

    • Если ты вызвал throws-функцию из какого-нибудь интересного типа а-ля Runnable - убери руки от клавиатуры, и подумай что ты делаешь! Либо у тебя в твоем Runnable должен быть аналог обработки исключений верхнего уровня (как минимум, залогировать). А как максимум - обсуждать с лидами архитектурную проблему: скорее всего где-то кто-то будет ждать побочный эффект нашего runnalbe, который никогда не наступит. И тут нужна асинхронная обработка ошибок - что есть отдельная песня и отдельный свод правил.

    • Если делать совсем правильно - то в архитектуре приложения со слоями, каждый слой должен ловить любой Exception который произошел в нижних слоях, и транслировать его в логический эксепшн выраженный в терминах, которыми оперирует именно этот слой. А в качестве "причины" - передавать as-is эксепшн из нижележащего слоя. Например - слой отвечающий за витрину магазина делает запрос в сервис который определяет динамическую цену. Тот лезет, скажем, в базу и получает transaction error. Идеологически правильно будет перехватить этот Transaction Error в сервисе динамической цены и обернуть его в бизнес-эксепшн DynamicPriceNotAvailableException(..., underlyingException). Тогда сервис витрины может хоть как-то сделать recovery из этой ситуации: то ли показать backup статическую цену, то ли пропустить товар, то ли сделать что-то еще. Однако, такие заморочки нужны, ну скажем в 10% случаев когда что-то кидается.

    • Обязательная оговорка - речь идет о системе не mission-critical класса. Если вы пишете софт для кардиостимулятора или контроллера тормозов - правила относительно исключений и их обработки будут жестче - сильно жестче!

    Отказываться от исключений и возвращаться к return value - я считаю нерациональным.


    1. izibrizi2
      17.06.2025 10:34

      Перехват эксепшена из нижнего слоя это маст хев, потому что не очень приятно видеть какой нибудь apache http exeption при вызове сервиса


      1. ruomserg
        17.06.2025 10:34

        Очень сильно зависит от требований к приложению, ИМХО. Если в конце этих перехватов надо записать лог и показать пользователю "Произошла непредвиденная ошибка. Попробуйте еще раз. Trace-id=..." - то все эти перехваты и перевыбросы другого типа становятся явным излишеством. Поэтому мы перехватывали и перевыбрасывали только там, где были явные требования к error recovery.


        1. gsaw
          17.06.2025 10:34

          Не обязательно из-за recovery. Иногда от сервиса прилетает к примеру IOException без дополнительной информации, только вызывающий метод, обладает информацией, что бы нормально записать в лог, что произошло.


          1. ruomserg
            17.06.2025 10:34

            Ну вот я же выше писал паттерн "catch+log+rethrow"! Это он и есть: поймали внизу иерархии, залогировали (с инфой которая есть в этом месте), и перебросили дальше. :-)


          1. gerashenko
            17.06.2025 10:34

            Cause в помощь в этом случае. Для логов stack trace на верхнем уровне.


            1. gsaw
              17.06.2025 10:34

              Stack trace не всегда достаточно, все равно может потеряться контекст, в котором было брошено исключение. Какие то данные, которые собирались для метода, выбросившего исключение, бизнескейс, имя файла хотя бы, его размер и путь, запись которого вызвало IOException исключение. Исключения выброшенные каким ни будь библиотечными функциями, часто содержат информацию слишком низкого уровня. Типа "у вас ошибка в строке 17", а для анализа хотелось бы эту самую строку в логе иметь.


              1. gerashenko
                17.06.2025 10:34

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


                1. gsaw
                  17.06.2025 10:34

                  Я тоже не вижу проблем. либо "обертками исключений", либо в нужном месте записать в лог и SneakyThrow


  1. novoselov
    17.06.2025 10:34

    Если у вас Control Flow построен на еxception'ах, то у вас бардак в консерватории, а не в Java. Правильное использование exception'ов это про прерывание исполнения, это значит что операция пользователя не может быть завершена успешно (с ожидаемым результатом или за ожидаемое время), а значит меняется и оговоренное поведение (приложение может вернуть ошибку и сложить лапки или запустить процедуру деградации/восстановления функциональности и т.д.), то есть тут уже не так важно сколько времени тратится на сбор стека и восстановление консистентности. Тогда в идеальном случае весь ваш критический путь исполнения будет оптимизирован через предсказание ветвлений.

    Насчет проверок условий тут конечно вопрос к изначальному дизайну. Контракты с предусловиями/постусловиями описаны достаточно давно, но в Java их не завезли, имеем то что имеем.


    1. Jijiki
      17.06.2025 10:34

      AsynchronousChannel , но помойму прикол в том что если будет отвязка то нужен контроль, потомучто можно нечайно запустить больше ведь чем положено, а значит пользователю чтобы не думать, разрабу надо учесть этот нюанс, последовательного запуска штук, в 24 еще есть join(так же Runtime exec - выкидывает депрекейтед, но это норм есть ProcessBuilder, еще есть виртуальные потоки, еще есть Structured Concurrency - 499, остальное впринципе можно кинуть в диспатч например в свинге есть свои штуки по асинхронности - действительно отвязывает от интерфейса, но помойму это и хорошо и плохо проверки то всё равно придётся делать, тут самое наивное именно что не использовать асинк, а просто прагматично ждать завершения, ну будет зависесть от выбора - я так сделал - просто жду меня устраивает), но и в 24 есть ускорение в виде векторов, по минимальным показателям - минимальным 24 даёт прирост ну 10% точно по сравнению с 17 версией


      1. novoselov
        17.06.2025 10:34

        Я извиняюсь, но что эта за бессвязный поток мыслей?


    1. Ratenti
      17.06.2025 10:34

      Assert в java есть, для дебага но это же просто другой синтаксис для исключений

      Во многих языках программирования контракты реализуются с помощью assert . Assert по умолчанию компилируются в режиме выпуска в C/C++ и аналогичным образом деактивируются в C# [ 8 ] и Java.

      Используйте assert, чтобы документировать предположения и проверить корректность логики во время разработки.

      Не полагайтесь на assert в рабочем продакшене для критически важных проверок.


  1. unreal_undead2
    17.06.2025 10:34

    используете ли вы исключения для управления логикой своей программы?

    А что, в Java исключения достаточно легковесны и сравнимы по производительности с обычными вызовами функций и т.п. ? По крайней мере в плюсах это дорогое удовольствие.


    1. NightBlade74
      17.06.2025 10:34

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

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


    1. novoselov
      17.06.2025 10:34

      Сами по себе они относительно недорогие, больше всего времени там занимает сбор стектрейса. В свежих версиях Java сборку стектрейса оптимизировали, добавили lazy создание фреймов и StackWalker, но это все равно не панацея. Есть конечно опции JVM типа -XX:-OmitStackTraceInFastThrow и специальный конструктор для создания исключения без стектрейса, но я бы не советовал сильно полагаться на первое и злоупотреблять вторым без понимания зачем вам это надо. В случае если вы кидаете и перехватывает exception в рамках одного метода это работает также быстро как goto (при условии соблюдения ряда специфичных условий, типа отсутствия synchronized).
      Подробнее можно почитать тут https://shipilev.net/blog/2014/exceptional-performance/


      1. unreal_undead2
        17.06.2025 10:34

        Спасибо, интересно.

        если вы кидаете и перехватывает exception в рамках одного метода это работает также быстро как goto

        С точки зрения использования для логики приложения (как предлагается в статье) - масштаб мелковат, но в принципе оптимизация полезная. Теоретически и в плюсах можно, проанализировав try/catch/throw в рамках метода (вместе с заинлайненным кодом), сделать просто переход на обработчик, не знаю, делают ли такое реально компиляторы.


  1. Jijiki
    17.06.2025 10:34

    конечно использую при записи в файл ), процесс еще провожу с try

    одна из моих плохих практик последних наверно, просто беру STDOUT_FILENO в С и пишу в поток сообщение, но файлы всё равно проверяю


  1. MountainGoat
    17.06.2025 10:34

    У меня другая парадигма, не на Яве. Исключение - это непредсказуемая ошибка, о которой никто заранее не подумал. Никакая иная реакция на исключение, кроме завершения всей программы и перезапуска - недопустима. Любые предсказуемые события, типа хотел открыть файл, а он не открывается - реализуются не исключениями.

    Это освобождает компилятору большое поле для манёвра. Компилятор наконец-то может рассчитывать на то, что последовательный блок кода будет выполнен последовательно. Ему не нужно опасаться, что в любой момент точка исполнения из середины блока улетит пёс знает куда. (Ну а если улетит, то корректность кода уже не важна).

    Не говоря уже, что за 20 лет стажа я так и не понял, как грамотно писать программы в среде, где любая функция может выбросить неизвестно какое исключение, потому что оно пришло из пятого уровня вложенности.


    1. Jijiki
      17.06.2025 10:34

      эти случаи могут быть описаны в ключевых стандартах один из них например посикс, например как проводить процесс, должны быть соблюдены многие моменты, вызывая write ресурс может быть не доступен, а записывая в не туда например не проверяя операций с памятью без ексепшена, приводило бы к некоторым моментам, да где-то нету ексепшенов, но если есть потребность в стабильной библиотеке где нет обновлений они скорее нужны чем нет, это как типо нан - лучше реализовать и проверять делимое на 0(тут тоже стандарт IEEE_754), ну и забавный пример, было несколькно раз стэк трейс кинуть если не загружается ОС, предположим просто застынет а предположим кидаем стек трейс, как бы результат очевиден если это связано с ексепшенами


  1. vasyakolobok77
    17.06.2025 10:34

    Кажется вы смешиваете несколько парадигм в одну.

    Если вы пишете код в функциональном стиле / используете project-reactor, то ваш вариант TypedError / Flux/Mono.

    Если же вы пишете код в классическом стиле, то в исключениях нет ничего плохого. Указанные вами минусы честно говоря притянуты за уши.

    Частое игнорирование проверяемых исключений и опасность забыть, поймать непроверяемые.

    Исключения надо обрабатывать на том уровне, на котором вы можете что-то сделать.

    Например, валидация данных, указанных пользователем - проверяем корректность данных - кидаем ошибку в случае неверных данных - отдаем ошибку клиенту.

    К слову про SneakyThrows - это вредная аннотация и в продакшен коде ее не должно быть ровно по той причине, что вы указали.

    Гигантские блоки try/catch прячут реальный метод, откуда бросилось исключение

    1) Немного непонятно про гигантские блоки. Никто не мешает вам разбить большой метод на более мелкие. Это не имеет отношение к исключениям.

    2) С чего бы исключения прятали метод откуда они брошены? Как раз наоборот, исключения с их стектрейсом показывают откуда они брошены. В отличие от TypedError, которые представляют собой просто сообщение об ошибке.

    Сомнительный перформанс при большом количестве исключений.

    Как уже упомянули другие комментаторы, если вы используете исключения для control-flow, то у нас для вас плохие новости: вы неправильно используете исключения. Исключения по своему имени - это исключительная ситуация, которая в обычном исполнении программы не должна возникать.

    Еще о плюсах исключений. Конечно, это все вкусовщина, но для меня конструкция try-with-resources автоматического закрытия ресурсов выглядит проще и понятней чем тот огород из flatMap / doOnFinally, что приходится городить при использовании project-reactor.


    1. Ratenti
      17.06.2025 10:34

      А вот в Kotlin компилятор не заставляет обрабатывать исключения.


  1. falkenberg
    17.06.2025 10:34

    на случай если цепочки .map().flatmap() от функций, способных выбросить исключения, вызывают положительные эмоции, могу порекомендовать обратить внимание на функциональную библиотеку для ямы vavr.io Там как раз есть подходящая для этих целей монада Try


  1. izibrizi2
    17.06.2025 10:34

    Если вы пишите сервис на спринге, то проверяемые исключения действительно могут досаждать, потому что в случае чего, у вас автоматом откатится транзакция и "юзер подождет". Но если вы пишите систему реального времени, допустим, систему управления воротами, где используете низкоуровневый ввод вывод, то просто так прокинуть исключение на верхний хендлер уже не получится - вас люди сожрут. Тут проверяемые исключения как раз очень сильно помогают контроллировать ошибочные ситуации и не гадать где что может стрельнуть. Как раз поэтому в гошке и сделали иаку проверку на ошибки - она не для очередного круда, а для низкоуровнего взаимодействия, чтобы информировать об ошибках явно.

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

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


    1. Ratenti
      17.06.2025 10:34

      В сервисе на Котлин со спринг проверяемые исключения могут досаждать тем, что транзакция не откатиться при из возникновения а Котлин не заставляет их ловить.


  1. Vest
    17.06.2025 10:34

    Я люблю исключения ради стек трейса. Когда исключение бросается и потом как-нибудь прилетает в лог, то в логах всегда видно строчку, откуда ошибка пришла и через что она прострелила.

    Все эти ошибки (привет, Раст) выглядят очень хорошо, пока ты не начинаешь искать источник проблемы. Не то место, где ты разименовываешь значение, а там, где эта ошибка была создана.

    Стандартные исключения — это то, за что я готов платить производительностью.


  1. totsamiynixon
    17.06.2025 10:34

    Я из мира C#, и вопросы там возникают похожие. Механизма Checked Exceptions там нет. Я разрабатываю по следующему принципу: контракт класса, метода или интерфейса формируется как сигнатурой этого метода, так и ожидаемыми исключениями (что-то подобное Checked Exceptions). В моем случае ожидаемые исключения отражены не в синтаксисе языка, как в Java, а в документации к коду. Это не так удобно, как в Java, но что поделать.

    Возьмем реальный пример: предположим, что нужно спроектировать достаточно примитивный интерфейс Chat со следующими методами: sendMessage(text, replyTo): string и свойством messages: string[]. В контексте нашего приложения важно уведомлять пользователя об ошибке отправки сообщения. Поэтому к сигнатуре метода sendMessage(text, replyTo) прикрепляется маркер throws ChatMessageDeliveryException — эта ошибка становится частью контракта.

    ChatMessageDeliveryException может быть выброшена в ряде случаев, которые разработчик интерфейса интерпретирует как ожидаемые: нет интернета, получатель добавил отправителя в чёрный список, отправитель заблокирован и т. д. Можно выразить это черед одно общее исключение, можно через исключение под кейс, а можно через какой-то enum - зависит от потребности клиента. Таким образом, если sendMessage(text, replyTo) выбросит любое другое исключение, это будет считаться ошибкой разработки и должно логироваться как ошибка, а также отображаться в соответствующих алертах. ChatMessageDeliveryExceptionтак же может быть залогирован на уровне предупреждения вызывающей стороной.

    Таким образом, на клиенте (SPA, Mobile, Desktop) срабатывает принцип Dependency Inversion: клиент описывает интерфейс, с которым он хочет работать, — тот, который он понимает, какие ошибки ожидает и умеет обрабатывать (какие исключения для него являются Checked). Затем на уровне инфраструктуры реализуется адаптер для этого интерфейса. Это значит, что клиент самостоятельно решает, что для него Cheched, а что Unchecked.

    Если говорить о сервере, то чем ближе мы к самому высокому уровню (обычно это уровень Controller / Use Case: отправить сообщение, ответить на сообщение, начать новый чат), тем для меньшего числа ошибок вызывающий код действительно может выполнить какой-то recovery. Все Checked Exceptions будут описывать бизнес-прерывания и преобразовываться в HTTP-статус, отличный от 500. А все Unchecked Exceptions будут залогированы как ошибки, и клиенту вернётся ответ со статусом 500, указывающий, что клиент не может исправить эту проблему. Это значит, что не нужно на самом высоком уровне отлавливать ошибку подключения к базе данных; ее нужно отловить в инфраструкторном конвеере, где это соединение открывается, там же должна быть предпринята попытка recovery, если она предусмотрена.

    В итоге получается, что чем более «общий» модуль (соединение с базой данных, вебсокет соединение и другой I/O), тем больше исходов в нём будет интерпретировано как Checked Exception и тем меньше как Unchecked Exception. Для «узкого» модуля зависимость обратно пропорциональна (Controllers / Use Cases / Clients). Получается достаточно сбалансированно.

    Важно подчеркнуть, что Checked Exceptions необязательны в этой схеме, можно везде использовать Unchecked Exception, если вы не хотите получить «гарантию» обработки на уровне компилятора.


  1. Anarchist
    17.06.2025 10:34

    Исключения и нуллы - вечная боль Java и JVM- языков.


  1. Grodastr
    17.06.2025 10:34

    А вы как все валидацию проводите если не кидаете исключения?
    Есть же обычно метод, в который приходит что то, а в конце проверка, лежит ли что нибудь в мапе ошибок. Если лежит - шли нафиг пользователя с его "Войной и Миром" в пароле


    1. unreal_undead2
      17.06.2025 10:34

      Просто вернуть код ошибки никак?


      1. Grodastr
        17.06.2025 10:34

        Сколько там современная жисонина в среднем имеет полей? Не дофига ли кодов ошибок? И получается - если какие то поля обязательны, то тоже делать коды на них?


        1. unreal_undead2
          17.06.2025 10:34

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


          1. Grodastr
            17.06.2025 10:34

            Ну так этим и занимаются же Хендлеры исключений. Целую аннотацию же придумали в том же Спринге @RestControllerAdvice над классом и @ExceptionHandler над методами.


            1. unreal_undead2
              17.06.2025 10:34

              Не спорю, что с исключениями код может получиться элегантнее - вопрос в производительности, если вдруг невалидные данные повалят сплошным потоком.