Привет, Хабр!
Монада – это структура, которая описывает способы композиции абстракций. Можно представить монаду как контейнер, который может хранить в себе другие значения или операции.
Основные принципы монад:
Единица (Unit): это, по сути, процесс оборачивания значения в монадический контекст. Это может звучать абстрактно, но в целом это означает предоставление общего способа для обращения с различными типами данных в рамках одного и того же монадического принципа.
Связывание (Bind): основа монад, позволяет применять функцию к содержимому монады так, что результатом тоже является монада. Важный момент здесь в том, что функция применяется в контексте монады, не нарушая ее структуру.
Композиция (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-разработчик".
Комментарии (10)
domix32
25.03.2024 08:02А есть ли тип Result/Either, который либо содержит валидное значение либо ошибку? Будет ли оно автоматом оборачивать выброшенные исключения в них?
sergey-gornostaev
25.03.2024 08:02+2Думаю, после упоминания монадических законов стоило упомянуть и то, что Optional их нарушает.
Maccimo
Что тут у нас?
Очередная статья про новинки Java 1.8 в 2024 году!
Optional
не может содержатьnull
, в этом его суть. Если вofNullable()
передатьnull
, то будет возвращён результатOptional.empty()
.И вот такое, а равно как и вызов
Optional::get()
вообще без проверки наisPresent()
перед этим, вы будете встречать частенько. Не потому, что есть хоть какая-то причина так сделать, нет. Просто вашему предшественнику было лень включать мозг и разбираться, как использоватьOptional
правильно.keekkenen
да, неужели ?
что такое value ? разве опшионал не хранит в себе null ?
наверное, произошла типичная подмена понятий в понимании сути
Anarchist
Это так в джаве определили None? :)
keekkenen
если none это отсутствие ссылки на объект, то да
Maccimo
Вы так торопились оставить язвительный комментарий, что совершенно забыли про то, что публичный API и детали реализации это вещи ортогональные. Ну и не поняли сути, конечно же.
Optional
либо хранит ненулевое значение, либо пуст. Ниget()
, ниifPresent()
, ни любой другой методOptional
не вернёт вамnull
в качестве значения, хрянящегося внутри. Вы можете получитьnull
, передав его параметром вorElse()
, но это будет вашnull
, а не хранящееся значение.Технически состояние «пустого»
Optional
реализовано через нулевое значение поляvalue
. Но это, повторюсь, деталь реализации.String
тоже, знаете ли, всегда представляет строку в кодировке UTF-16, но далеко не всегда её в таком виде хранит.keekkenen
да, извиняюсь, получилось язвительно, но тем не менее, содержит/хранит (это одно и тоже), а вот может ли он вернуть это совсем другое
Anarchist
Лучше бы метода get вообще не было. Или был только для Some. Тогда предшественникам и последователям поневоле пришлось бы включать мозги :)