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


В предыдущей статье была рассмотрена функциональная обработка исключений с помощью интерфейса Try<T>. Статья вызвала определенный интерес читателей и была отмечена в "Сезоне Java".


В данной статье автор продолжит тему и рассмотрит простую и "грамотную" (literate) обработку исключений при помощи функций высшего порядка без использования каких либо внешних зависимостей и сторонних библиотек.


Решение


Для начала перепишем пример из предыдущей статьи преобразования URL из строкового представления к объектам URL c использованием Optional.


    public List<URL> urlList(String[] urls) {
        return Stream.of(urls)      //Stream<String>
        .map(s -> {
            try {
                return Optional.of(new URL(s));
            } catch (MalformedURLException me) {
                return Optional.empty();
            }
            })                      //Stream<Optional<>URL>
        .flatMap(Optional::stream)  //Stream<URL>, filters empty optionals
        .toList();
    }

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


И тут нам помогут следующие функции:


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

    /**
     * Higher-order function to convert partial function T=>R to total function T=>Optional<R>
     * @param <T> function input parameter type
     * @param <R> function result type
     * @param func partial function T=>R that may throw checked exception
     * @return total function T => Optional<R>
     */
    static <T, R> Function<T, Optional<R>> toOptional(CheckedFunction<T, R> func) {
        return param -> {
            try {
                return Optional.ofNullable(func.apply(param));
            } catch (RuntimeException err) {
                throw err;  //All runtime exceptions are treated as errors/bugs
            } catch (Exception e) {
                return Optional.empty();
            }
        };
    }

Дадим некоторые пояснения. CheckedFunction<T, R> это функция которая может выбросить исключение при преобразовании T => R. Подобные функции в терминах функционального программирования называются частичными (partial) функциями, потому что значение функции не определено при некоторых входных значениях параметра.


Функция toOptional(...) преобразует частичную (partial) функцию T => R в полную (total) функцию T => Optional. Подобного рода функции, которые принимают параметром и/или возвращают другую функцию, в терминах функционального программирования называются функциями высшего порядка (higher-order function).

С использованием новой функции код примет следующий опрятный вид:


    public List<URL> urlList(String[] urls) {
        return Stream.of(urls)      //Stream<String>
        .map(toOptional(URL::new))  //Stream<Optional<URL>>
        .flatMap(Optional::stream)  //Stream<URL>, filters empty optionals
        .toList();                  //List<URL>
    }

И теперь её можно применять везде где возможны проверяемые (checked) исключения.


    List<Number> intList(String [] numbers) {
        NumberFormat format = NumberFormat.getInstance();
        return Stream.of(numbers)       //Stream<String> 
        .map(toOptional(format::parse)) //Checked ParseException may happen here
        .flatMap(Optional::stream)      //Stream<Number>
        .toList();                      //List<Number>
    }

Улучшаем обработку исключений


При использовании Optional<T> пропадает информация о самом исключении. В крайнем случае исключение можно залогировать в теле функции toOptional, но мы найдем лучшее решение.


Нам нужен любой контейнер, который может содержать значение типа T либо само исключение. В терминах функционального программирования таким контейнером является Either<Exception, T>, но к сожаления класса Either<L,R> (как и класса Try<T>) нет в составе стандартной библиотеки Java.


Вы можете использовать любой подходящий контейнер, которым Вы обладаете. Я же в целях краткости буду использовать следующий:


    //Require Java 14+
    record Result<T>(T result, Exception exception) {
        public boolean failed() {return exception != null;}
        public Stream<T> stream() {return failed() ? Stream.empty() : Stream.of(result);}
    } 

Теперь наша функция высшего порядка получит имя toResult и будет выглядеть так:


    static <T, R> Function<T, Result<R>> toResult(CheckedFunction<T, R> func) {
        return param -> {
            try {
                return new Result<>(func.apply(param), null);
            } catch (RuntimeException err) {
                throw err;
            } catch (Exception e) {
                return new Result<>(null, e);
            }
        };
    }

А вот и применение новой функции toResult()


    List<Number> intListWithResult(String [] numbers) {
        NumberFormat format = NumberFormat.getInstance();
        return Stream.of(numbers)      //Stream<String>
        .map(toResult(format::parse))  //Stream<Result<Number>>, ParseException may happen
        .peek(this::handleErr)         //Stream<Result<Number>>
        .flatMap(Result::stream)       //Stream<Number>
        .toList();                     //List<Number>
    }

    void handleErr(Result r) {
        if (r.failed()) {
            System.out.println(r.exception());
        }
    }

Теперь возможное проверяемое исключение сохраняется в контейнере Result и его можно обработать в потоке.


Выводы


Для простой и "грамотной" (literate) обработки проверяемых (checked) исключений в функциональном стиле без использования внешних зависимостей необходимо:


  1. Выбрать подходящий контейнер для хранения результата. В простейшем случае это может быть Optional<T>. Лучше использовать контейнер который может хранить значение результата или перехваченное исключение.


  2. Написать функцию высшего порядка которая преобразует частичную функцию T => R, которая может выбросить исключение, в полную функцию T => YourContainer<R> и применять ее в случае необходимости.



Ссылки


На github-e


JavaDoc


Source Code


Junit tests


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

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


  1. aleksandy
    13.09.2022 12:09
    +3

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

    2. Зачем вообще контейнер, можно же просто пробросить отловленное исключение через, полагаю, всем давно известный хак с автовыведением и стиранием типов.

    public static <E extends Throwable> void sneakyThrow(Throwable e) throws E {
        throw (E) e;
    }


    1. sergeykopylov Автор
      13.09.2022 12:51

      1. Можно потерять или замаскировать NullPointerException, IllegalArgumentException и множество других багов. Но окончательное решение за Вами, я обрисовал лишь общую схему.

      2. При пробросе исключения при помощи sneakyThrow обработка потока завершится на первом "плохом" URL. А мы хотим обработать все URL-ы. Так то так.


      1. aleksandy
        13.09.2022 13:59
        +1

        1. В смысле потерять? Оно же будет в Result-е лежать.

        2. Так и в Вашем варианте при первом RuntimeException-е обработка стрима завершится.


        1. sergeykopylov Автор
          13.09.2022 17:44

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

          2. Некоторая разница имеется - в Вашем варианте обработка стрима завершится на первом "плохом" URL, у меня при первом NullPointerException или другом не-проверяемом исключении.

          Хотя я с Вами соглашусь, что перехватывать не-проверяемые исключения иногда необходимо.


  1. DenisPantushev
    13.09.2022 16:17
    +1

    Скептически я отношусь к этим выкрутасам с функциональщине. Лучше "бойлерплейт" код, который понятен, что он делает и в чем смысл кода, чем глухонемой код, который непонятно что делает.

    map-flatmap-toList

    map-flatmap-toList

    map-flatmap-toList

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


    1. sshikov
      13.09.2022 16:27
      +3

      >Одно и то-же.
      Так он и делает одно и то же. С вашей точки зрения это разные вещи, а с точки зрения типов — одинаковые. И нет, не надо в голове все прокручивать — достаточно понять, что внутри у map — и эта функция проще, чем все в целом (в том числе и тем, что ее можно автономно протестировать, понять, отладить). И если у вас такое повторяется в коде — то такие повторяющиеся куски можно легко зарефакторить в один метод, в отличие от того кода, который вам очевидно нравится, но который содержит побочные эффекты. И который как раз надо понять весь целиком, потому что его нельзя декомпозировать так, как map-flatMap.


    1. myazinn
      15.09.2022 18:14

      Лучше "бойлерплейт" код, который понятен

      С этим сложно спорить, но при чем здесь ФП? Если вам непонятен нормальный ФП код, то возможно дело не в коде, а в том, что вы не поняли этот подход / не хотите понимать, так как он другой?


  1. sshikov
    13.09.2022 16:32
    +1

    Не очень понятен смысл подобных статей в 2022 году, когда в мире существует больше одной готовой реализации Try. Которые можно просто открыть в гитхабе да посмотреть на реальный боевой код, проверенный сотнями пользователей за много лет. Было бы гораздо полезнее именно что взять реальный код того же vavr.io, и рассмотреть — тем более что он не такой уж и большой. При этом вполне можно и предложить какие-то улучшения, если у автора есть мысли, как это сделать.


    1. sergeykopylov Автор
      13.09.2022 17:57
      +2

      В своей статье автор хотел

      • неявно выразить свою мечту о том что-бы простейшие функциональные примитивы (Try, Either и м.б. др.) были в составе стандартной библиотеки Java

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


      1. sshikov
        13.09.2022 18:20

        Ну, это вполне логично, как мотивация. Я немножко не про это.

        Правда не очень понимаю, зачем вам это в стандартной библиотеке? Вот спринг, к примеру, не там — и вас это сильно напрягает каждый день? Или допустим, JDBC драйвер для базы данных Oracle 19 — его там тоже нет, ну так и что? Добавили в pom.xml, или что там у вас, одну зависимость — и оно уже в стандартной библиотеке (я намеренно упрощаю, но в реальности все обычно не сильно сложнее).

        Если что, это была не столько критика, сколько предложение на тему, как бы я написал такой текст. То есть, взять небольшой кусок vavr, и на его примере рассмотреть, как это практически сделано, почему так, и как можно было бы иначе. Против самой идеи у меня вообще никаких возражений нет, у меня весь проект нынче так построен (и это уже второй такой).