Монады как строительные блоки функционального Java

38e84d4e876cdeea7b065b0bddd0de4f

Привет, Хабр!

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

Основные принципы монад:

  1. Единица (Unit): это, по сути, процесс оборачивания значения в монадический контекст. Это может звучать абстрактно, но в целом это означает предоставление общего способа для обращения с различными типами данных в рамках одного и того же монадического принципа.

  2. Связывание (Bind): основа монад, позволяет применять функцию к содержимому монады так, что результатом тоже является монада. Важный момент здесь в том, что функция применяется в контексте монады, не нарушая ее структуру.

  3. Композиция (Composition): возможность соединять различные монадические операции в последовательность, где каждая следующая операция применяется к результату предыдущей, так можно строить сложные операционные цепочки.

В этой статье мы рассмотрим то, как реализуются монады в Java.

Монады в Java

Java когда-то казался немного упрямым в плане ФП, но теперь предлагает множество инструментов. И среди этих инструментов выделяются три: Optional, Stream, и CompletableFuture.

Optional

Optional — это контейнер для значения, которое может быть или не быть (т.е., может быть null). Вместо того, чтобы возвращать null из метода, что всегда является потенциальным источником ошибок и исключений NullPointerException, мы возвращаем экземпляр Optional.

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

Optional.of(value) и Optional.empty() позволяют создавать экземпляр Optional, содержащий значение или пустой. Это аналогично операции «unit» в теории монад, позволяя «обернуть» значение в монадический контекст.

Метод map(Function mapper) позволяет применить функцию к содержимому Optional, если оно присутствует, и вернуть новый Optional с результатом. Это соответствует операции «bind», обеспечивая возможность цепочек преобразований без риска NullPointerException.

С помощью flatMap и map, Optional позволяет строить цепочки операций над потенциально отсутствующими значениями, сохраняя при этом контекст отсутствия значения. Это и есть суть «композиции».

Как Optional предотвращает NullPointerException

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

Optional предоставляет различные методы для обработки возможного отсутствия значения без риска возникновения NPE:

  • isPresent(): проверяет, содержит ли объект Optional значение.

  • ifPresent(Consumer consumer): выполняет заданное действие, если значение присутствует.

  • orElse(T other): возвращает значение, если оно присутствует, иначе возвращает альтернативное значение, переданное в качестве аргумента.

  • orElseGet(Supplier other): аналогичен orElse, но альтернативное значение предоставляется с помощью функционального интерфейса Supplier, что позволяет избежать его создания, если значение присутствует.

  • orElseThrow(Supplier exceptionSupplier): возвращает значение, если оно присутствует, иначе бросает исключение, созданное с помощью предоставленного поставщика.

Примеры использования

Создадим optional объекты:

Optional optionalEmpty = Optional.empty();
Optional optionalOf = Optional.of("Habr");
Optional optionalNullable = Optional.ofNullable(null);

Optional.empty() создает пустой Optional объект. Optional.of(value) создает Optional объект с ненулевым значением. Если значение null, будет выброшено исключение NullPointerException. Optional.ofNullable(value) создает Optional объект, который может содержать null.

Проверка наличия значения и получение значения:

Optional optional = Optional.of("Привет, Хабр!");

if (optional.isPresent()) {
    System.out.println(optional.get());
}

// юзаем ifPresent для выполнения действия, если значение присутствует
optional.ifPresent(System.out::println);

isPresent() проверяет, содержит ли Optional значение.get() возвращает значение, если оно присутствует. В противном случае выбрасывается NoSuchElementException. ifPresent(Consumer consumer) выполняет заданное действие с значением, если оно присутствует.

Предоставление альтернативных значений:

Optional optional = Optional.empty();

// взвращает "Пусто", если Optional не содержит значения
String valueOrDefault = optional.orElse("Пусто");
System.out.println(valueOrDefault);

// возвращает значение, предоставленное Supplier, если Optional пуст
String valueOrGet = optional.orElseGet(() -> "Значение от Supplier");
System.out.println(valueOrGet);

orElse(T other) возвращает значение, если оно присутствует, иначе возвращает переданное альтернативное значение.orElseGet(Supplier other) работает аналогично, но альтернативное значение предоставляется через Supplier.

Дроп исключения, если значение отсутствует

Optional optional = Optional.empty();

String valueOrThrow = optional.orElseThrow(() -> new IllegalStateException("Значение отсутствует"));
// код выбросит исключение IllegalStateException с сообщением "Значение отсутствует"

Преобразование и фильтрация значений

Optional optional = Optional.of("Привет, Хабр!");

Optional upperCase = optional.map(String::toUpperCase);
System.out.println(upperCase.orElse("Пусто"));

Optional filtered = optional.filter(s -> s.length() > 10);
System.out.println(filtered.orElse("Фильтр не пройден"));

map(Function mapper) преобразует значение, если оно присутствует, с помощью предоставленной функции.filter(Predicate predicate) возвращает значение в Optional, если оно удовлетворяет условию предиката, иначе возвращает пустой Optional.

Stream

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

Одна из основных характеристик Stream в Java — ленивые вычисления, т.е операции над элементами потока не выполняются немедленно. Вместо этого, вычисления запускаются только тогда, когда это становится необходимым, например, при вызове терминальной операции (collect, forEach, reduce).

Stream в Java рассматривается как монада, так как поддерживает операции преобразования и фильтрации данных, сохраняя при этом контекст этих данных.

Операции над потоками данных в Java делятся на промежуточные и терминальные. Промежуточные операции возвращают поток, позволяя формировать цепочки преобразований (filter, map, sorted). Терминальные операции запускают выполнение всех ленивых операций и закрывают поток. После выполнения терминальной операции поток не может быть использован повторно.

Примеры

Создание потока и фильтр:

List names = Arrays.asList("Алексей", "Борис", "Владимир", "Григорий");
Stream streamFiltered = names.stream().filter(name -> name.startsWith("А"));
streamFiltered.forEach(System.out::println);
// "Алексей"

Выбираем только те имена, которые начинаются на «А», и выводим их.

Преобразование элементов потока:

List numbers = Arrays.asList(1, 2, 3, 4, 5);
Stream squaredNumbers = numbers.stream().map(n -> n * n);
squaredNumbers.forEach(System.out::println);
// выводит квадраты каждого числа: 1, 4, 9, 16, 25

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

Сортировка потока

List cities = Arrays.asList("Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург");
Stream sortedCities = cities.stream().sorted();
sortedCities.forEach(System.out::println);
// выводит города в алфавитном порядке

Агрегирование элементов потока

List ages = Arrays.asList(25, 30, 45, 28, 32);
OptionalDouble averageAge = ages.stream().mapToInt(Integer::intValue).average();
averageAge.ifPresent(avg -> System.out.println("Средний возраст: " + avg));
// выводит средний возраст

Сделаем цепочку посложней:

List transactions = Arrays.asList("ДЕБЕТ:100", "КРЕДИТ:150", "ДЕБЕТ:200", "КРЕДИТ:300");
double totalDebit = transactions.stream()
    .filter(s -> s.startsWith("ДЕБЕТ"))
    .map(s -> s.split(":")[1])
    .mapToDouble(Double::parseDouble)
    .sum();
System.out.println("Общий дебет: " + totalDebit);
// общая сумма по дебетовым операциям

CompletableFuture

CompletableFuture представляет собой модель будущего результата асинхронной операции. Это некий promise (обещание), что результат будет предоставлен позже. В отличие от простых Future, представленных в Java 5, CompletableFuture предлагает большой API для составления асинхронных операций, обработки результатов, исключений и реализации неблокирующего кода.

Простейший способ создать CompletableFuture — использовать методы supplyAsync(Supplier supplier) или runAsync(Runnable runnable), которые асинхронно выполняют поставщика или задачу соответственно. Это аналогично операции «unit» в монадах

CompletableFuture позволяет применять функции к результату асинхронной операции с помощью методов thenApply, thenCompose и thenCombine:

  • thenApply применяет функцию к результату, когда он становится доступен, возвращая новый CompletableFuture.

  • thenCompose используется для сглаживания результатов, когда один CompletableFuture должен быть последован другим, аналогично flatMap в монадах.

  • thenCombine объединяет два CompletableFuture, применяя функцию к их результатам.

CompletableFuture также имеет методы handle и exceptionally для обработки ошибок и исключений в асинхронных операциях.

Примеры

Асинхронное выполнение задачи с возвращаемым результатом

CompletableFuture future = CompletableFuture.supplyAsync(() -> {
    try {
        TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    }
    return "Результат асинхронной операции";
});

// додитесь завершения операции и получите результат
try {
    String result = future.get(); // блокирует поток до получения результата
    System.out.println(result);
} catch (InterruptedException | ExecutionException e) {
    e.printStackTrace();
}

get() блокирует текущий поток до тех пор, пока результат не станет доступен.

Преобразование результатов и обработка исключений

CompletableFuture futurePrice = CompletableFuture.supplyAsync(() -> getPrice("ProductID"))
    .thenApply(price -> price * 2)
    .exceptionally(e -> {
        e.printStackTrace();
        return 0;
    });

futurePrice.thenAccept(price -> System.out.println("Цена в два раза выше: " + price));

Асинхронно получаем цену продукта, удваиваем её, а затем обрабатываем возможные исключения, возвращая 0 в случае ошибки. Результат обрабатывается без блокировки.

Комбинирование двух независимых асинхронных задач

CompletableFuture future1 = CompletableFuture.supplyAsync(() -> "Результат из задачи 1");
CompletableFuture future2 = CompletableFuture.supplyAsync(() -> "Результат из задачи 2");

future1.thenCombine(future2, (result1, result2) -> result1 + ", " + result2)
    .thenAccept(System.out::println);

Две независимые асинхронные задачи выполняются параллельно. Их результаты объединяются и обрабатываются после завершения обеих задач.

Последовательное выполнение зависимых асинхронных операций

CompletableFuture.supplyAsync(() -> {
    return "Первая операция";
}).thenApply(firstResult -> {
    return firstResult + " -> Вторая операция";
}).thenApply(secondResult -> {
    return secondResult + " -> Третья операция";
}).thenAccept(finalResult -> {
    System.out.println(finalResult);
});

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

List webPageLinks = List.of("Link1", "Link2", "Link3"); // список ссылок на веб-страницы
List> pageContentFutures = webPageLinks.stream()
    .map(webPageLink -> CompletableFuture.supplyAsync(() -> downloadWebPage(webPageLink)))
    .collect(Collectors.toList());

CompletableFuture allFutures = CompletableFuture.allOf(pageContentFutures.toArray(new CompletableFuture[0]));

CompletableFuture> allPageContentsFuture =

 allFutures.thenApply(v -> 
    pageContentFutures.stream()
        .map(pageContentFuture -> pageContentFuture.join())
        .collect(Collectors.toList())
);

allPageContentsFuture.thenAccept(pageContents -> {
    pageContents.forEach(System.out::println); // вывод содержимого всех страниц
});

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

Monad Transformer

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

Это можно реализовать с помощью CompletableFuture:

import java.util.concurrent.CompletableFuture;
import java.util.function.Function;

public class CompletableFutureMonadTransformerExample {

    public static void main(String[] args) {
        Function> multiply = num -> CompletableFuture.supplyAsync(() -> num * 2);
        Function> add = num -> CompletableFuture.supplyAsync(() -> num + 3);

        CompletableFuture result = CompletableFuture
                .supplyAsync(() -> 5) // начальное значение
                .thenCompose(multiply) // применяем первую асинхронную операцию
                .thenCompose(add); // применяем вторую асинхронную операцию

        result.thenAccept(finalResult -> System.out.println("Результат: " + finalResult));
        // ожидаем завершения всех асинхронных операций, чтобы программа не завершилась раньше времени
        result.join();
    }
}

Reader Monad

Reader Monad позволяет инжектировать зависимости в функции и операции без явной передачи этих зависимостей через каждый уровень вызова. Reader Monad позволяет «протаскивать» это состояние через цепочку вызовов без изменения сигнатур функций.

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

import java.util.function.Function;

public class ReaderMonadExample {
    private final Function computation;

    public ReaderMonadExample(Function computation) {
        this.computation = computation;
    }

    public R apply(T environment) {
        return computation.apply(environment);
    }

    public  ReaderMonadExample map(Function mapper) {
        return new ReaderMonadExample<>(environment -> mapper.apply(computation.apply(environment)));
    }

    public static void main(String[] args) {
        ReaderMonadExample reader = new ReaderMonadExample<>(String::length);
        ReaderMonadExample modifiedReader = reader.map(length -> length * 2);

        System.out.println("Результат: " + modifiedReader.apply("Hello, Хабр!"));
    }
}

Создаем абстракцию для передачи контекста (в нашем случае строки) через функцию, получающую длину строки, а затем удваиваем её. map позволяет преобразовать результат без необходимости явно передавать контекст.

Статья подготовлена в преддверии старта специализации «Java-разработчик».

© Habrahabr.ru