Обработка исключений в Java в функциональном стиле

В данной статье автор предоставит информацию о собственной библиотеке для обработки исключений (Exception) в функциональном стиле.

Предпосылки

В Java начиная с версии 8 появились новые возможности в виде функциональных интерфейсов и потоков (Stream API). Эти возможности позволяют писать код в новом функциональном стиле без явных циклов, временных переменных, условий ветвления и проч. Я уверен что этот стиль программирования станет со временем основным для большинства Java программистов.

Однако применение функционального стиля на практике осложняется тем, что все стандартные функциональные интерфейсы из пакета java.util.function не объявляют проверяемых исключений (являются checked exception unaware).

Рассмотрим простой пример преобразования URL из строкового представления к объектам URL.

    public List<URL> urlListTraditional(String[] urls) {
        return Stream.of(urls)
            .map(URL::new)  //MalformedURLException here
            .collect(Collectors.toList());
    }

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

    public List<URL> urlListTraditional(String[] urls) {
        return Stream.of(urls)
        .map(s -> {
            try {
                return new URL(s);
            } catch (MalformedURLException me) {
                return null;
            }
        }).filter(Objects::nonNull)
          .collect(Collectors.toList());
    }

Мы должны явно обработать MalformedURLException, вернуть null из лямбды, а затем отфильтровать нулевые значения в потоке. Увы, такой код на практике нивелирует все преимущества функционального подхода.

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

    public List<URL> urlListWithTry(String[] urls) {
        return Stream.of(urls)
            .map(s -> Try.of(() -> new URL(s)))
            .flatMap(Try::stream)
            .collect(Collectors.toList());
    }

Итак, по порядку про Try<T>

Интерфейс Try

Интерфейс Try<T> представляет собой некоторое вычисление, которое может завершиться успешно с результатом типа T или неуспешно с исключением. Try<T> очень похож на Java Optional<T>, который может иметь результат типа T или не иметь результата вообще (иметь null значение).

Объекты Try<T> создаются с помощью статического фабричного метода Try.of(...) который принимает параметром поставщика (supplier) значения типа T, который может выбросить любое исключение Exception.

   Try<URL> url = Try.of(() -> new URL("foo"));

Каждый объект Try<T> находится в одном из двух состояний - успеха или неудачи, что можно узнать вызывая методы Try#isSuccess() или Try#isFailure().

Для логирования исключений подойдет метод Try#onFailure(Consumer<Exception>), для обработки успешных значений - Try#.onSuccess(Consumer<T>).

Многие методы Try<T> возвращают также объект Try, что позволяет соединять вызовы методов через точку (method chaining). Вот пример как можно открыть InputStream от строкового представления URL в несколько строк без явного использования try/catch.

    Optional<InputStream> input =  
        Try.success(urlString)         //Factory method to create success Try from value
        .filter(Objects::nonNull)      //Filter null strings
        .map(URL::new)                 //Creating URL from string, may throw an Exception
        .map(URL::openStream)          //Open URL input stream, , may throw an Exception
        .onFailure(e -> logError(e))   //Log possible error
        .optional();                   //Convert to Java Optional

Интеграция Try с Java Optional и Stream

Try<T> легко превращается в Optional<T> при помощи метода Try#optional(), так что в случае неуспешного Try вернется Optional.empty.

Я намеренно сделал Try<T> API восьма похожим на Java Optional<T> API. Методы Try#filter(Predicate<T>) и Try#map(Function<T,R>) имеют аналогичную семантику соответствующих методов из Optional. Так что если Вы знакомы с Optional<T>, то Вы легко будете работать с Try<T>.

Try<T> легко превращается в Stream<T> при помощи метода Try#stream() точно так же, как это сделано для Optional#stream(). Успешный Try превращается в поток (stream) из одного элемента типа T, неуспешный Try - в пустой поток.

Фильтровать успешные попытки в потоке можно двумя способами - первый традиционный с использованием Try#filter()

    ...
    .filter(Try::isSuccess)
    .map(Try::get)
    ...

Второй короче - при помощи Try#stream()

    ...
    .flatMap(Try::stream)
    ...

будет фильтровать в потоке неуспешные попытки и возвращать поток успешных значений.

Восстановление после сбоев (Recovering from failures)

Try<T> имеет встроенные средства recover(...) для восстановления после сбоев если вы имеете несколько стратегий для получения результата T. Предположим у Вас есть несколько стратегий:

    public T planA();
    public T planB();
    public T planC();

Задействовать все три стратегии/плана одновременно в коде можно следующим образом

    Try.of(this::planA)
    .recover(this::planB)
    .recover(this::planC)
    .onFailure(...)
    .map(...)
    ...

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

Работа с ресурсами (Try with resources)

Try<T> имплементирует AutoCloseable интерфейс, а следовательно Try<T> можно использовать внутри try-with-resource блока. Допустим нам надо открыть сокет, записать несколько байт в выходной поток сокета и затем закрыть сокет. Соответствующий код с использованием Try<T> будет выглядеть следующим образом.

    try (var s = Try.of(() -> new Socket("host", 8888))) {
        s.map(Socket::getOutputStream)
        .onSuccess(out -> out.write(new byte[] {1,2,3}))
        .onFailure(e -> System.out.println(e));
    }

Сокет будет закрыт при выходе за последнюю фигурную скобку.

Выводы

Try<T> позволяет обрабатывать исключения в функциональном стиле без явного использования конструкций try/catch/finally и поможет сделать Ваш код более коротким, выразительным, легким для понимания и сопровождения.

Надеюсь Вы получите удовольствие от использования Try<T>

Ссылки

Автор - Сергей А. Копылов
e-mail skopylov@gmail.com

Последнее место работы
Communications Solutions CMS IUM R&D Lab
Hewlett Packard Enterprise
Ведущий специалист

Код библиотеки на github

Try JavaDoc

Еще одна функциональная библиотека для Java

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


  1. BugM
    14.07.2022 00:59
    +9

    Зумеры изобрели монаду Either


    1. 0xd34df00d
      14.07.2022 05:00
      +8

      Судя по recover, не просто монаду, а целый MonadPlus.


    1. kpmy
      15.07.2022 11:57

      Почему зумеры-то? Лямбдам в Java уже 10 лет скоро.


  1. Antharas
    14.07.2022 01:03
    +4

    Окей, это все круто, что вы смогли элегантно обойти проблему. Но, зачем велосипед если есть vavr?


    1. tsypanov
      14.07.2022 10:53
      +2

      Вавр использует свой Option плохо сочетается с j.u.Optional, здесь же по максимуму АПИ совместимо со стандартной библиотекой.


      1. sshikov
        14.07.2022 15:29

        По моему опыту — вполне сочетается. Подход простой — не используете Optional вообще, ибо при наличии более полноценного Option от vavr он обычно нафиг не нужен (за теми редкими исключениями, когда его кто-то возвращает другой). Не исключаю конечно, что проблемы бывают — но чтобы прям было плохо…


    1. sergeykopylov Автор
      14.07.2022 16:52

      Верно, кстати ссылку на vavr я давал в конце статьи.

      Есть как минимум пара различий между vavr и предлагаемой библиотекой

      • vavr перехватывает Throwable, в том числе исключения Error, моя библиотека - только Exception, выкидывая Error наружу, что соответствует общепринятым рекомендациям по обработке исключений.

      • в vavr сложно реализована работа с ресурсами (до восьми дополнительных классов билдеров), у меня - всем хорошо известный известный механизм try-with-resource


      1. webaib1
        15.07.2022 10:24
        +1

        Вы либо делаете полноценную библиотеку со всеми плюхами вавр (а их там очень много), можете попробовать pr со своим виденьем try создать. Думаю, что вам тогда объяснят, почему vavr просто ловит throwable (это важное свойство ФП, чтобы функция была определена на всем диапазоне входного типа и одинаково себя вела - очень упрощает тестирование). Либо не паритесь, т.к. цеплять зависимость с одной монадой от одного физика в своей проект - это верный способ наговнокодить.


  1. Cerberuser
    14.07.2022 06:17

    А что делать, если есть смысл всё-таки выкинуть исключение из цепочки преобразований, а не "отфильтровать ошибки"? Плюнуть на Stream ввиду невозможности сделать это в рамках его API?


    1. panzerfaust
      14.07.2022 07:05
      +5

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

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


      1. Cerberuser
        14.07.2022 08:13

        Окей, тогда другой вопрос к автору - можем ли мы свернуть, к примеру, Stream<Try<T>> в Try<List<T>> (чтобы потом дотащить его до упомянутого специального места и уже там один раз дёрнуть orElseThrow)? С ходу по документации ничего подобного найти не удалось.


        1. panzerfaust
          14.07.2022 10:19

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

          Если вам по бизнесу нужен фейлсейф, то в стриме вместо X -> Y вы делаете X -> Try<Y> и на выходе получаете List<Try<Y>>. По сути получаете сводку типа "9 объектов из 10 обработались нормально, с 1 из 10 возникли такие-то проблемы".

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


    1. sergey-gornostaev
      14.07.2022 08:49

      Писал для себя на скорую руку, но может вам пригодится https://github.com/TheDeadOne/exception-unchecker


      1. sergeykopylov Автор
        14.07.2022 16:12

        Спасибо, интересно. Оказывается Вы тоже думали об этой проблеме


  1. aleksandy
    14.07.2022 07:36
    +5

    Эм, серьёзно? Вот так просто взять и промолчать, если какой-то из ресурсов не закрылся.

    protected final void closeResources() {
      List<AutoCloseable> list = resources.get();
      if (list == null || list.isEmpty()) {
        return;
      }
      list.forEach(c -> {
        try {
          c.close();
        } catch (Exception e) {
          //silently
        }
      });
      list.clear();
      resources.remove();
    }

    И проверка на null лишняя, список будет инициализирован в любом случае.


    1. sergeykopylov Автор
      14.07.2022 16:38

      Виноват, вы смотрите не тот файл. Смотрите пожалуйста на

      function/src/main/java/com/github/skopylov58/functional/Try.java

      Файл, который Вы смотрели, из прежних итераций, я удалил их из репозитария.


  1. sshikov
    14.07.2022 08:38
    +2

    неуспешный Try — в пустой поток.

    Вообще говоря, в vavr это поведение тоже вызывает вопросы. Неуспешные случаи тоже иногда надо обрабатывать, если у вас на выходе 10 exceptions — то стрим из них был бы логичным. Ну хотя бы по выбору.


  1. ValeryIvanov
    14.07.2022 09:33
    +1

    Посмотрел исходный код и у меня сразу возникло несколько вопросов.

    • Почему Try не интерфейс?

    • Почему Try наследуется от TryAutoClosable, который в свою очередь реализует AutoClosable, а не наоборот?

    И ещё, я не уверен, что этот код нормальный:

    public class TryAutoCloseable implements AutoCloseable {
        private static final ThreadLocal<List<AutoCloseable>> resources = ThreadLocal.withInitial(ArrayList::new);  
        
        protected void addResource(AutoCloseable autoCloseable) {
            resources.get().add(autoCloseable);
        }
    


    1. sergeykopylov Автор
      14.07.2022 16:33

      Виноват, вы смотрите не тот файл. Смотрите пожалуйста на

      function/src/main/java/com/github/skopylov58/functional/Try.java

      Файл TryAutoCloseable из прежних итераций, я удалил его из git репозитария.


  1. karambaso
    14.07.2022 11:31
    -2

    Данная задача решается одним единственным интерфейсом. Правда под другие задачи потребуются ещё интерфейсы, но суммарно это всё чище и проще предложенного.


    1. Dmitry2019
      14.07.2022 15:44
      +1

      Может опишите?


      1. karambaso
        15.07.2022 11:54
        +1

        Пишете интерфейс с одним методом экземпляра, выбрасывающим эксепшн. Под ним пишете статический метод, берущий на вход ваш интерфейс и возвращающий false/null/чтоНибудьЕщё в случае возникновения исключения.

        Всё, дальше радуетесь жизни и простоте. И не надо библиотеки сочинять.


  1. Xobotun
    14.07.2022 15:46

    Показалось похожим на CompletableFuture<T>, но с method-chaining'ом. Кстати, что делать, если .recover(this::planA) при восстановлении задействует внешний ресурс и на нём упал, не вернув его в исходное состояние?


    1. sergeykopylov Автор
      14.07.2022 16:56

      Увы, я думаю эта проблема выходит за рамки данной скромной библиотеки ;)


  1. osmanpasha
    15.07.2022 05:13
    +3

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

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


    1. Artyomcool
      15.07.2022 06:45

      Особенно классно искать в таком подходе перформанс-проблемы: профиль будет показывать погоду.


  1. kpmy
    15.07.2022 09:12
    -2

    Мог бы плюсануть - плюсанул бы.