Обработка исключений в 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
Ведущий специалист
Комментарии (27)
Antharas
14.07.2022 01:03+4Окей, это все круто, что вы смогли элегантно обойти проблему. Но, зачем велосипед если есть vavr?
tsypanov
14.07.2022 10:53+2Вавр использует свой
Option
плохо сочетается сj.u.Optional
, здесь же по максимуму АПИ совместимо со стандартной библиотекой.sshikov
14.07.2022 15:29По моему опыту — вполне сочетается. Подход простой — не используете Optional вообще, ибо при наличии более полноценного Option от vavr он обычно нафиг не нужен (за теми редкими исключениями, когда его кто-то возвращает другой). Не исключаю конечно, что проблемы бывают — но чтобы прям было плохо…
sergeykopylov Автор
14.07.2022 16:52Верно, кстати ссылку на vavr я давал в конце статьи.
Есть как минимум пара различий между vavr и предлагаемой библиотекой
vavr перехватывает Throwable, в том числе исключения Error, моя библиотека - только Exception, выкидывая Error наружу, что соответствует общепринятым рекомендациям по обработке исключений.
в vavr сложно реализована работа с ресурсами (до восьми дополнительных классов билдеров), у меня - всем хорошо известный известный механизм
try-with-resource
webaib1
15.07.2022 10:24+1Вы либо делаете полноценную библиотеку со всеми плюхами вавр (а их там очень много), можете попробовать pr со своим виденьем try создать. Думаю, что вам тогда объяснят, почему vavr просто ловит throwable (это важное свойство ФП, чтобы функция была определена на всем диапазоне входного типа и одинаково себя вела - очень упрощает тестирование). Либо не паритесь, т.к. цеплять зависимость с одной монадой от одного физика в своей проект - это верный способ наговнокодить.
Cerberuser
14.07.2022 06:17А что делать, если есть смысл всё-таки выкинуть исключение из цепочки преобразований, а не "отфильтровать ошибки"? Плюнуть на Stream ввиду невозможности сделать это в рамках его API?
panzerfaust
14.07.2022 07:05+5На мой взгляд, кидаться исключениями из стримов это даже для классического императивного подхода не комильфо. В ФП есть неписанный принцип "если компилируется - должно работать". Хороший принцип.
Если следовать ФП-подходу, то нужно таки дотащить объект монады с ошибкой до специального места, где обрабатываются все исключения - и там уже делать что угодно.
Cerberuser
14.07.2022 08:13Окей, тогда другой вопрос к автору - можем ли мы свернуть, к примеру,
Stream<Try<T>>
вTry<List<T>>
(чтобы потом дотащить его до упомянутого специального места и уже там один раз дёрнутьorElseThrow
)? С ходу по документации ничего подобного найти не удалось.panzerfaust
14.07.2022 10:19Такие трансформации уже выходят за рамки смысла всех этих монад. Откуда авторам знать, какие у вас планы на данную коллекцию и какой подход к ошибкам предполагается. Это просто другой уровень абстракции. К слову о подходах: это или фейлфаст или фейлсейф.
Если вам по бизнесу нужен фейлсейф, то в стриме вместо X -> Y вы делаете X -> Try<Y> и на выходе получаете List<Try<Y>>. По сути получаете сводку типа "9 объектов из 10 обработались нормально, с 1 из 10 возникли такие-то проблемы".
Если фейлфаст подход, то я бы вообще предварительную валидацию написал. Опять же потому, что если написан стрим, который что-то делает и возвращает коллекцию, то пусть он всегда возвращает коллекцию, а не валится внезапно по исключению.
sergey-gornostaev
14.07.2022 08:49Писал для себя на скорую руку, но может вам пригодится https://github.com/TheDeadOne/exception-unchecker
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 лишняя, список будет инициализирован в любом случае.
sergeykopylov Автор
14.07.2022 16:38Виноват, вы смотрите не тот файл. Смотрите пожалуйста на
function/src/main/java/com/github/skopylov58/functional/Try.java
Файл, который Вы смотрели, из прежних итераций, я удалил их из репозитария.
sshikov
14.07.2022 08:38+2неуспешный Try — в пустой поток.
Вообще говоря, в vavr это поведение тоже вызывает вопросы. Неуспешные случаи тоже иногда надо обрабатывать, если у вас на выходе 10 exceptions — то стрим из них был бы логичным. Ну хотя бы по выбору.
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); }
sergeykopylov Автор
14.07.2022 16:33Виноват, вы смотрите не тот файл. Смотрите пожалуйста на
function/src/main/java/com/github/skopylov58/functional/Try.java
Файл TryAutoCloseable из прежних итераций, я удалил его из git репозитария.
karambaso
14.07.2022 11:31-2Данная задача решается одним единственным интерфейсом. Правда под другие задачи потребуются ещё интерфейсы, но суммарно это всё чище и проще предложенного.
Dmitry2019
14.07.2022 15:44+1Может опишите?
karambaso
15.07.2022 11:54+1Пишете интерфейс с одним методом экземпляра, выбрасывающим эксепшн. Под ним пишете статический метод, берущий на вход ваш интерфейс и возвращающий false/null/чтоНибудьЕщё в случае возникновения исключения.
Всё, дальше радуетесь жизни и простоте. И не надо библиотеки сочинять.
Xobotun
14.07.2022 15:46Показалось похожим на
CompletableFuture<T>
, но с method-chaining'ом. Кстати, что делать, если.recover(this::planA)
при восстановлении задействует внешний ресурс и на нём упал, не вернув его в исходное состояние?sergeykopylov Автор
14.07.2022 16:56Увы, я думаю эта проблема выходит за рамки данной скромной библиотеки ;)
osmanpasha
15.07.2022 05:13+3А я что ни в одном примере использования не заметил того, что код становится лучше, читабельнее, чем традиционный императивный стиль.
И ещё заметил, при возникновении исключения обработчик не может положить в результат какое-нибудь значение, хотя такое может быть полезно
Artyomcool
15.07.2022 06:45Особенно классно искать в таком подходе перформанс-проблемы: профиль будет показывать погоду.
BugM
Зумеры изобрели монаду Either
0xd34df00d
Судя по recover, не просто монаду, а целый MonadPlus.
kpmy
Почему зумеры-то? Лямбдам в Java уже 10 лет скоро.