Мотивация

Поводом к написанию этой статьи стал "Pragmatic FP" манифест
в котором автор утверждает что прагматичное функциональное программирование (далее FP) требует использования всего двух монад - Option<T> и Result<T>.

Данная статья в некотором смысле развивает идеи "Pragmatic FP" и утверждает что
прагматичное FP можно делать с помощью всего одной монады - XResult<T>

Проблема

В функциональном программировании традиционно существует три монады для
обработки/представления отказов (errors).

  • Option<T>/Maybe<T> - для представления отсутствующего результата (null значение)

  • Result<T>/Try<T> - для обработки случаев когда получение результата может завершиться исключением (exception)

  • Either<L,R> - для всех остальных случае. Левое значение L традиционно ассоциируется с отказом, правое R - c успешным результатом.

В Java Result и Either монады отсутствуют в составе стандартной библиотеки, Optional появился в Java8, однако его дизайн не идеален и часто критикуется,
для примера тут, в Oracle Java Magazine

Что лично меня не устраивает в Optional

Трудно установить место возниковения null при использовании нескольких операций `map()` и непонятно какой фильтр сработал при использовании нескольких операций `filter()`. Иными словами, Optional не сохраняет контекста при возникновении проблемы. Ну да ладно, не будем слишком придираться.

Следующей общей проблемой в Java для всех монад является то, что разные монады плохо сочетаются (compose), а точнее совсем не сочетаются при помощи стандартной для этого операции flatMap(). Для примера, попробуйте скомбинировать Optional и Result. Без использования isPresent() и isSuccess() у вас это не получится, что делает использование FP в данном случае сомнительным.

Далее, в реальной жизни возможностей Optional и Result часто бывает недостоточно. Рассмотрим пример простой классической функции.

public User getUserById(long id);

С точки зрения FP, данная функция всегда возвращает пользователя, а на практике данная функция может

  • вернуть пользователя User

  • вернуть null

  • выбросить Runtime исключение

  • результат может зависеть от фазы Луны ;)

Представим себя на месте автора этой функции. Как сделать эту функцию безопасной? Так?

public Optional<User> getUserById(long id);

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

public Result<Optional<User>> getUserById(long id);

Нет, это уже слишком. Но и это еще не всё. Представим себе что мы получаем пользователя по REST API и на HTTP запрос получили ответ с кодом из серии 400-500. То есть причиной сбоя (error cause) является не null значение и не исключение, а ошибка HTTP протокола. Как сделать функцию безопасной, что вернуть в случае отказа? Ответ - монаду XResult<T>

Что в имени тебе моём ...

X в имени XResult<T> означает

  • eXtended - расширенный. XResult одновременно обладает свойствами Option<T>, Result<T> и Either<L,R>

  • eXtensible - расширяемый. Вы можете легко добавлять новые причины отказов (ErrCause) для вашей предметной области. (problem domain).

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

  • ExceptionCause - исключение

  • FilterCause - фильтрация с указанием возможной причины

  • SimpleCause - причина сбоя которая описывается простой строкой.

Как мы видим, отсутствует NullCause, в котором нет особенной нужды. Вместо NullCause используется ExceptionCause с исключением NullPointerException, которое создается в момент обнаружения null значения (разумеется вместе с stack-trace, который позволяет обнаруживать место возникновения проблемы)

Class Diagram
Class Diagram

XResult на практике

Попробуем решить указанную выше задачу получения пользователя при помощи XResult

XResult<User> user = XResult.fromCallable(() -> getUserById(100));

Статическая функция XResult.fromCallable() обработает возможные исключения и нулевые результаты и вернет пользователя (или ошибку) упакованную в XResult. Далее с ним можно делать всё что угодно, как и с любой другой монадой - map(), flatMap(), filter() и др.

Теперь представим себя на месте писателя этой функции. Чтобы сделать функцию безопасной для применения, мы определим её следующим образом:

public XResult<User> getUserById(long id) {
}

Из тела этой функции мы вернем

  • XResult.ofNullable(user) - когда пользователь user получен по сети, null значение обработается автоматически

  • XResult.err(exception) - когда мы перехватим любое исключение exception

  • XResult.err("HTTP error 401") - в случае ошибок HTTP протокола

Давайте расширим обработку HTTP ошибок для того чтобы вызывающая сторона могла получить больше информации об ошибке. Для этого определим класс HttpError

public class HttpError implements ErrCause {
    int resultCode;
    String errorDescription;
    String method;
    String url;
    ...
}

Тогда при выявлении HTTP ошибки мы сделаем следующее

    if (httpResponse.getCode() >= 400) {
        HttpError httpErr = new HttpError(httpResponse);
        return XRresult.err(httpErr);
    }

На вызывающнй стороне HttpError можно получить следующим образом:

    XResult<User> user = getUserById(100);
    user.on( ok -> {
        // обработка успеха
        },
        err -> {
          if (err instanceof HttpError)) {
                HttpError httpErr = (HttpError) err;
                int code = httpErr.getResutCode();
          }
        })

В итоге

Монада XResult<T>

  • обладает свойствами Optional<T>, Result<T> и Either<L,R>

  • легко расширяется для создания причин отказов ErrCause в вашей предметной области

  • позволяет применять FP в легаси проектах и создавать безопасные API в новых проектах

  • имеет компактный дизайн и API javadoc

  • имплементирован в одном исходном файле (~260 строк кода с подробным javadoc)

  • 100% покрыт юнит тестами

  • не имеет run-time зависимостей

  • размещен на Github

Для компиляции используется Java версии 8 (JavaLanguageVersion.of(8)),
это сделано преднамеренно, потому что может возникнуть необходимость
применить XResult для древнего легаси.

При использовании Java 17+ код XResult может быть легко переписан с использованием
запечатанных (sealed) интерфейсов, записей (records), сопоставления с образцом
(pattern matching), что сделает код еще более коротким и удобным в использовании.
Но это на будущее.

Сергей Копылов

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


  1. sshikov
    26.01.2025 14:46

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

    Ну т.е. в самом примитивном варианте, чисто в качестве иллюстрации:

    XResult.err(new RuntimeException("HTTP error 401"))

    И что изменилось?


    1. sergeykopylov Автор
      26.01.2025 14:46

      На первый взгляд, не вижу разницы с Try

      Try не обрабатывает null значения, Optional не умеет перехватывать исключения, XResult делает и то, и другое


  1. vadimr
    26.01.2025 14:46

    Трудно установить место возниковения null при использовании нескольких операций `map()` и непонятно какой фильтр сработал при использовании нескольких операций `filter()`. Иными словами, Optional не сохраняет контекста при возникновении проблемы. Ну да ладно, не будем слишком придираться.

    Вообще в теории map именно тем и отличается от for-each, что у него нет императивного контекста.


  1. BeiZer0
    26.01.2025 14:46

    Мне интересно, а в случае с опциональными полями класса предлагается тоже писать XResult? Меня в принципе смущает, что оригинальный Result не несёт в дженерике тип ошибки(как, например, IO из ZIO), чтобы по сигнатуре можно было понять чего ожидать, а тут ещё и скрывается был ли null результатом выполнения чистой функции или сайд эффекта, т.е. для меня метод возвращающий Result<Option<T>> означает, что метод с сайд эффектом может вернуть значение или отсутствие значения, а ещё ошибку, итого 3 исхода, все из которых видно по сигнатуре, а XResult это скрывает, по итогу на каждый такой метод нужно писать проверку на null, но длиннее т.к. == null заменяется на isInstanceOf NPE и потом ещё и в половине случаев обрабатывать это не как ошибку, а как ожидаемое отсутствие значения.


    1. sergeykopylov Автор
      26.01.2025 14:46

      Мне интересно, а в случае с опциональными полями класса предлагается тоже писать XResult?

      Думаю нет. Классики рекомендуют использовать Optional в основном как возвращаемое значение функции, то же самое будет справедливо для XResult.