Слово на букву «М», или Монады уже здесь
Про монаду ходит множество мемов и легенд. Говорят, что каждый уважающий себя программист в ходе своего функционального возмужания должен написать хотя бы один туториал про монаду — недаром на сайте языка Haskell даже ведётся специальный таймлайн для всех отважных попыток приручить этого таинственного зверя. Бывалые разработчики поговаривают также и о проклятии монад — мол, каждый, кто постигнет суть этого чудовища, начисто теряет способность кому-либо увиденное объяснить. Одни для этого вооружаются теорией категорий, другие надевают космические костюмы, но, видимо, единого способа подобраться к монадам не существует, иначе каждый программист не выдумывал бы свой собственный.
Действительно, сама концепция монады неинтуитивна, ведь лежит она на таких уровнях абстракции, до которых интуиция просто не достаёт без должной тренировки и теоретической подготовки. Но так ли это важно, и нет ли другого пути? Тем более, что эти таинственные монады уже окружают многих ничего не подозревающих программистов, даже тех, кто пишет на языках, никогда не считавшихся «функциональными». Действительно, если приглядеться, то можно обнаружить, что они уже здесь, в языке Java, под самым нашим носом, хотя в документации по стандартной библиотеке слово «монада» мы едва ли найдём.
Именно поэтому важно если не постичь глубинную суть этого паттерна, то хотя бы научиться распознавать примеры использования монады в уже существующих, окружающих нас API. Конкретный пример всегда даёт больше, чем тысяча абстракций или сравнений. Именно такому подходу и посвящена эта статья. В ней не будет теории категорий, да и вообще какой-либо теории. Не будет оторванных от кода сравнений с объектами реального мира. Я просто приведу несколько примеров того, как монады уже используются в знакомом нам API, и постараюсь дать читателям возможность уловить основные признаки этого паттерна. В основном в статье пойдёт речь о Java, и ближе к концу, чтобы вырваться из мира legacy-ограничений, мы немного коснёмся Scala.
Проблема: потенциальное отсутствие объекта
Посмотрим на такую строчку Java-кода:
return employee.getPerson().getAddress().getStreet();
Если предположить, что она в своём контексте нормально компилируется, опытный глаз всё равно заметит здесь серьёзную проблему — любой из возвращаемых объектов в цепочке вызовов может отсутствовать (метод вернёт null), и тогда при выполнении этого кода будет выброшен безжалостный NullPointerException. К счастью, мы всегда можем завернуть эту строчку в кучу проверок, например, так:
if (employee != null
&& employee.getPerson() != null
&& employee.getPerson().getAddress() != null) {
return employee.getPerson().getAddress().getStreet();
} else {
return "<неизвестно>";
}
Выглядит и само по себе не очень, а композируется с другим кодом и того хуже. А главное, если забыть хоть одну проверку, можно получить исключение во время выполнения. Всё потому, что информация о потенциальном отсутствии объекта никак не зафиксирована в типах, и компилятор не спасёт нас от ошибки. Но ведь мы всего лишь хотели выполнить три простых последовательных действия — у сотрудника взять персону, у персоны — адрес, у адреса — улицу. Вроде бы задача простая, а код раздулся от вспомогательных проверок и стал неудобочитаемым.
К счастью, в Java 8 появился тип java.util.Optional. В нём есть много интересных методов, но мы поговорим вот об этих:
public class Optional {
public static Optional of(T value) { }
public Optional map(Function super T, ? extends U> mapper) { }
public Optional flatMap(Function super T, Optional> mapper) { }
}
Optional можно рассматривать как контейнер, содержащий либо один элемент, либо ничего. Если вызвать у этого контейнера метод map и передать туда анонимную функцию (лямбду) или ссылку на метод, то map применит эту функцию к находящемуся внутри Optional объекту и вернёт результат, также завернув его в Optional. Если же объекта внутри не окажется — то map просто вернёт опять-таки пустой контейнер Optional, но с другим типовым параметром.
Метод flatMap позволяет делать то же, что и метод map, но он принимает функции, которые сами возвращают Optional — тогда результат применения этих функций не будет дополнительно заворачиваться в Optional, и мы избежим двойной вложенности.
Такой интерфейс Optional позволяет выстраивать вызовы в цепочки, например, следующим образом:
return Optional.of(employee)
.map(Employee::getPerson)
.map(Person::getAddress)
.map(Address::getStreet)
.orElse("<неизвестно>");
Выглядит чуть компактнее, чем в предыдущем примере. Но плюсы на этом не заканчиваются. Во-первых, мы убрали из кода всю шелуху, не относящуюся к делу — мы выполнили несколько простых действий с объектом employee, описав их в коде явно и без лишнего вспомогательного кода. Во-вторых, мы можем быть уверены в отсутствии NPE, если где-то на пути этой цепочки встретится null-значение — Optional уберегает нас от этого. В-третьих, полученная конструкция является выражением (а не утверждением, как конструкция if из предыдущего примера), а значит, возвращает значение — следовательно, её значительно легче композировать с другим кодом.
Итого, как же мы решили проблему потенциального отсутствия объекта с помощью типа Optional?
- Обозначили явно проблему в типе объекта (Optional
). - Спрятали весь вспомогательный код (проверка на отсутствие объекта) внутрь этого типа.
- Передали типу набор простых стыкующихся действий.
Что здесь понимается под «стыкующимися действиями»? А вот что: метод Person: getAddress принимает на вход объект типа Person, полученный как результат предыдущего метода Employee: getPerson. Ну, а метод Address: getStreet, соответственно, принимает результат предыдущего действия — вызова метода Person: getAddress.
А теперь — главное: Optional в Java — это не что иное, как реализация паттерна монады.
Проблема: итерация
Со всем синтаксическим сахаром, появившимся в Java за последние годы, это, казалось бы, уже и не проблема. Однако посмотрим на такой код:
List employeeNames = new ArrayList<>();
for (Company company : companies) {
for (Department department : company.getDepartments()) {
for (Employee employee : department.getEmployees()) {
employeeNames.add(employee.getName());
}
}
}
Здесь мы хотим собрать имена всех сотрудников всех отделов и всех компаний в единый список. В принципе, код выглядит не так плохо, хотя процедурный стиль модификации списка employeeNames заставит поморщиться любого функционального программиста. Кроме того, код состоит из нескольких вложенных циклов перебора, которые явно избыточны — с помощью них мы описываем механизм итерации по коллекции, хотя нам он по большому счёту неинтересен, мы просто хотим собрать всех людей изо всех отделов всех компаний и получить их имена.
В Java 8 появился целый новый API, позволяющий более удобно работать с коллекциями. Основным интерфейсом этого API является интерфейс java.util.stream.Stream, содержащий в себе, в числе прочего, методы, которые могут показаться знакомыми из предыдущего примера:
public interface Stream extends BaseStream> {
Stream map(Function super T, ? extends R> mapper);
Stream flatMap(Function super T, ? extends Stream extends R>> mapper);
}
Действительно, метод map, как и в случае с Optional, принимает на вход функцию, трансформирующую объект, применяет её ко всем элементам коллекции, а возвращает очередной Stream из полученных трансформированных объектов. Метод flatMap принимает функцию, которая сама по себе возвращает Stream, и сливает все полученные при преобразовании потоки в единый Stream.
С использованием Streams API код итерации можно переписать вот так:
List streamEmployeeNames = companies.stream()
.flatMap(Company::getDepartmentsStream)
.flatMap(Department::getEmployeesStream)
.map(Employee::getName)
.collect(toList());
Здесь мы немного схитрили, чтобы обойти ограничения Streams API в Java — к сожалению, они не замещают собой существующие коллекции, а являются целой параллельной вселенной функциональных коллекций, порталом в которую является метод stream (). Поэтому каждую полученную в ходе обработки данных коллекцию мы должны ручками проводить в эту вселенную. Для этого мы добавили в классы Company и Department геттеры для коллекций, которые сразу преобразуют их в объекты типа Stream:
static class Company {
private List departments;
public Stream getDepartmentsStream() {
return departments.stream();
}
Решение если и выглядит более компактным, то ненамного, но плюсы его не только в этом. По сути это альтернативный механизм работы с коллекциями, более компактный, типобезопасный, композируемый, и достоинства его начинают вскрываться по мере увеличения объёма и сложности кода.
Итак, использованный подход к решению проблемы итерации по элементам коллекций вновь можно сформулировать в виде нескольких уже знакомых нам утверждений:
- Обозначили явно проблему в типе объекта (Stream
). - Спрятали весь вспомогательный код (итерация по элементам и вызов переданной функции над ними) внутрь этого типа.
- Передали объекту этого типа набор простых стыкующихся действий.
Подытожим: интерфейс Stream в Java — это реализация паттерна монады.
Проблема: асинхронные вычисления
Из того, что до сих пор было сказано, может сложиться впечатление, что паттерн монады предполагает наличие какой-то обёртки над объектом (объектами), в которую можно накидывать функции, преобразующие эти объекты, а весь скучный и ненужный код, связанный с применением этих функций, обработкой потенциальных ошибок, механизмами обхода — описать внутри обёртки. Но на самом деле применимость монады ещё шире. Рассмотрим проблему асинхронных вычислений:
Thread thread1 = new Thread(() -> {
String string = "Hello" + " world";
});
Thread thread2 = new Thread(() -> {
int count = "Hello world".length();
});
У нас есть два отдельных потока, в которых мы хотим выполнить вычисления, причём вычисления в потоке thread2 должны производиться над результатом вычислений в потоке thread1. Я даже не буду пытаться привести здесь код синхронизации потоков, который заставит эту конструкцию работать — кода будет много, а главное, он будет плохо композироваться, когда таких блоков вычислений будет множество. А ведь мы хотели всего лишь выполнить последовательно друг за другом два простых действия —, но асинхронность их выполнения путает нам все карты.
Чтобы побороть излишнюю сложность, ещё в Java 5 появились футуры (Future), позволяющие организовывать блоки многопоточных вычислений в цепочки. К сожалению, в классе java.util.concurrent.Future мы не найдём знакомых нам методов map и flatMap — он не реализует монадический паттерн (хотя его реализация CompletableFuture подбирается к этому достаточно близко). Поэтому здесь мы опять немного схитрим и выйдем за пределы Java, а попытку представить, как бы выглядел интерфейс Future, появись он в Java 8, оставим в качестве домашнего задания читателям. Рассмотрим интерфейс трейта scala.concurrent.Future в стандартной библиотеке языка Scala (сигнатура методов несколько упрощена):
trait Future[+T] extends Awaitable[T] {
def map[S](f: T => S): Future[S]
def flatMap[S](f: T => Future[S]): Future[S]
}
Если приглядеться, методы очень знакомые. Метод map применяет переданную функцию к результату выполнения футуры — когда этот результат будет доступен. Ну, а метод flatMap применяет функцию, которая сама возвращает футуру — таким образом, эти две футуры можно объединить в цепочку с помощью flatMap:
val f1 = Future {
"Hello" + " world"
}
val f2 = { s: String =>
Future {
s.length()
}
}
Await.ready(
f1.flatMap(f2)
.map(println),
5.seconds
)
Итак, как же мы решили проблему выполнения асинхронных взаимозависимых блоков вычислений?
- Обозначили явно проблему в типе объекта (Future[String]).
- Спрятали весь вспомогательный код (вызов следующей по цепочке футуры по окончании предыдущей) внутрь этого типа.
- Передали объекту этого типа набор простых стыкующихся действий (футура f2 принимает объект такого типа (String), который возвращает футура f1).
Можно резюмировать, что Future в Scala также реализует паттерн монады.
Итоги
Монада — это паттерн функционального программирования, позволяющий легко и без побочных эффектов композировать (выстраивать в цепочки) действия, которые в противном случае могли бы быть разделены тоннами небезопасного вспомогательного кода. Кроме приведённых примеров, в функциональных языках монады используются для обработки исключительных ситуаций, работы с вводом-выводом, базами данных, состоянием и много где ещё. Паттерн монады реализуем на любом языке, в котором функции являются объектами первого класса (их можно рассматривать как значения, передавать в качестве аргументов и т.д.), и даже в Java он уже кое-где попадается — хотя местами его реализация и оставляет желать лучшего.
Для более глубокого погружения в тему рекомендую следующие ресурсы: