Pull to refresh
1506.76
OTUS
Цифровые навыки от ведущих экспертов

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

Level of difficultyEasy
Reading time10 min
Views6.2K

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

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

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

  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<? super T,? extends U> mapper) позволяет применить функцию к содержимому Optional, если оно присутствует, и вернуть новый Optional с результатом. Это соответствует операции "bind", обеспечивая возможность цепочек преобразований без риска NullPointerException.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Optional<String> 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<? extends T> other) работает аналогично, но альтернативное значение предоставляется через Supplier.

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

Optional<String> optional = Optional.empty();

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

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

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

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

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

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

Stream

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

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

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

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

Примеры

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

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

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

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

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

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

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

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

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

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

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

List<String> 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<U> supplier) или runAsync(Runnable runnable), которые асинхронно выполняют поставщика или задачу соответственно. Это аналогично операции "unit" в монадах

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

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

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

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

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

Примеры

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

CompletableFuture<String> 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<Integer> futurePrice = CompletableFuture.supplyAsync(() -> getPrice("ProductID"))
    .thenApply(price -> price * 2)
    .exceptionally(e -> {
        e.printStackTrace();
        return 0;
    });

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

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

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

CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "Результат из задачи 1");
CompletableFuture<String> 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<String> webPageLinks = List.of("Link1", "Link2", "Link3"); // список ссылок на веб-страницы
List<CompletableFuture<String>> pageContentFutures = webPageLinks.stream()
    .map(webPageLink -> CompletableFuture.supplyAsync(() -> downloadWebPage(webPageLink)))
    .collect(Collectors.toList());

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

CompletableFuture<List<String>> 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<Integer, CompletableFuture<Integer>> multiply = num -> CompletableFuture.supplyAsync(() -> num * 2);
        Function<Integer, CompletableFuture<Integer>> add = num -> CompletableFuture.supplyAsync(() -> num + 3);

        CompletableFuture<Integer> 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<T, R> {
    private final Function<T, R> computation;

    public ReaderMonadExample(Function<T, R> computation) {
        this.computation = computation;
    }

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

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

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

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

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


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

Tags:
Hubs:
Total votes 15: ↑9 and ↓6+5
Comments13

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS