Про монаду ходит множество мемов и легенд. Говорят, что каждый уважающий себя программист в ходе своего функционального возмужания должен написать хотя бы один туториал про монаду — недаром на сайте языка 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<T> {
public static <T> Optional<T> ofNullable(T value) { }
public<U> Optional<U> map(Function<? super T, ? extends U> mapper) { }
public<U> Optional<U> flatMap(Function<? super T, Optional<U>> mapper) { }
}
Optional можно рассматривать как контейнер, содержащий либо один элемент, либо ничего. Если вызвать у этого контейнера метод map и передать туда анонимную функцию (лямбду) или ссылку на метод, то map применит эту функцию к находящемуся внутри Optional объекту и вернёт результат, также завернув его в Optional. Если же объекта внутри не окажется — то map просто вернёт опять-таки пустой контейнер Optional, но с другим типовым параметром.
Метод flatMap позволяет делать то же, что и метод map, но он принимает функции, которые сами возвращают Optional — тогда результат применения этих функций не будет дополнительно заворачиваться в Optional, и мы избежим двойной вложенности.
Такой интерфейс Optional позволяет выстраивать вызовы в цепочки, например, следующим образом:
return Optional.ofNullable(employee)
.map(Employee::getPerson)
.map(Person::getAddress)
.map(Address::getStreet)
.orElse("<неизвестно>");
Выглядит чуть компактнее, чем в предыдущем примере. Но плюсы на этом не заканчиваются. Во-первых, мы убрали из кода всю шелуху, не относящуюся к делу — мы выполнили несколько простых действий с объектом employee, описав их в коде явно и без лишнего вспомогательного кода. Во-вторых, мы можем быть уверены в отсутствии NPE, если где-то на пути этой цепочки встретится null-значение — Optional уберегает нас от этого. В-третьих, полученная конструкция является выражением (а не утверждением, как конструкция if из предыдущего примера), а значит, возвращает значение — следовательно, её значительно легче композировать с другим кодом.
Итого, как же мы решили проблему потенциального отсутствия объекта с помощью типа Optional?
- Обозначили явно проблему в типе объекта (Optional<Employee>).
- Спрятали весь вспомогательный код (проверка на отсутствие объекта) внутрь этого типа.
- Передали типу набор простых стыкующихся действий.
Что здесь понимается под «стыкующимися действиями»? А вот что: метод Person::getAddress принимает на вход объект типа Person, полученный как результат предыдущего метода Employee::getPerson. Ну а метод Address::getStreet, соответственно, принимает результат предыдущего действия — вызова метода Person::getAddress.
А теперь — главное: Optional в Java — это не что иное, как реализация паттерна монады.
Проблема: итерация
Со всем синтаксическим сахаром, появившимся в Java за последние годы, это, казалось бы, уже и не проблема. Однако посмотрим на такой код:
List<String> 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<T> extends BaseStream<T, Stream<T>> {
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);
}
Действительно, метод map, как и в случае с Optional, принимает на вход функцию, трансформирующую объект, применяет её ко всем элементам коллекции, а возвращает очередной Stream из полученных трансформированных объектов. Метод flatMap принимает функцию, которая сама по себе возвращает Stream, и сливает все полученные при преобразовании потоки в единый Stream.
С использованием Streams API код итерации можно переписать вот так:
List<String> 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<Department> departments;
public Stream<Department> getDepartmentsStream() {
return departments.stream();
}
Решение если и выглядит более компактным, то ненамного, но плюсы его не только в этом. По сути это альтернативный механизм работы с коллекциями, более компактный, типобезопасный, композируемый, и достоинства его начинают вскрываться по мере увеличения объёма и сложности кода.
Итак, использованный подход к решению проблемы итерации по элементам коллекций вновь можно сформулировать в виде нескольких уже знакомых нам утверждений:
- Обозначили явно проблему в типе объекта (Stream<Company>).
- Спрятали весь вспомогательный код (итерация по элементам и вызов переданной функции над ними) внутрь этого типа.
- Передали объекту этого типа набор простых стыкующихся действий.
Подытожим: интерфейс 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 он уже кое-где попадается — хотя местами его реализация и оставляет желать лучшего.
Для более глубокого погружения в тему рекомендую следующие ресурсы:
- Scala Monads: Declutter your code with monadic design
- Brian Beckman: Don’t Fear the Monad
- Mario Fusco. Monadic Java
Комментарии (33)
jxcoder
08.07.2015 13:08+2Вот мне всегда нравятся такие «замечательные» примеры.
А что делать, если всё таки нужна обработка отсутствия данных о employee?
А логирование?danslapman
08.07.2015 13:43В Scala можно, например, коллектить в список Either[String, Error]
jxcoder
08.07.2015 13:44Ну я имел ввиду, что в статье об этом не рассказывают. А это ведь важно.
Обработка таких ситуаций вполне частая задача в промышленной разработке.
chabapok
08.07.2015 13:48Очевидно, в этом случае придется проверять монаду на пустоту. Хотя да, согласен. В монаде из либы не хватает нормального механизма узнать на каком именно этапе у нас возникло бы npe.
lany
08.07.2015 13:57Обработка исключительных ситуаций в стримах обсуждается, например, здесь.
jxcoder
08.07.2015 13:59+3Хочется это всё видеть в статье. Те примеры, что даны в этой статье достаточно просты и известны. Интересуют те самые детали, которых в статье нет.
lany
08.07.2015 14:03+1Я вот лично сам не знаю как лучше. На мой взгляд проблема вообще надумана. Надо просто везде кидать unchecked exception, если что-то пошло не так. Восстановление после исключения в потоке не так часто требуется. Я запросил из базы данных 10 записей, на пятой произошёл connection timeout к базе. Не надо возвращать пользователю первые четыре записи, надо сказать, что ошибка произошла. А то он решит, что записей всего четыре.
trix
14.07.2015 16:05я вас умоляю, оба варианта имеют право на жизнь, если у гугла где-то шард отвалится, вы хотели бы получить хотя б первую страницу поисковых результатов или только сообщение об ошибке?
если чо, с результатами можно и признак полноты вернуть
forketyfork Автор
08.07.2015 13:59+2Но это же не туториал по использованию Optional, таких руководств тоже предостаточно. Задача статьи была продемонстрировать, как реализован паттерн монады в разных классах, естественно, я не стал погружаться в детали использования этих классов в разных юзкейсах.
Если вам нужна особая обработка отсутствия объекта на каком-то конкретном шаге, возможно, такой код вам и не подойдёт. Но в подавляющем большинстве случаев, с которыми я сталкивался, это как раз бывает неважно.
Если вы имеете в виду логирование в функциональном коде, то его логичнее всего сделать либо внутри применяемых функций, либо (что ещё лучше) внутри монадического объекта, так как логирование само по себе побочный эффект.
dginz
08.07.2015 13:18+3Стоит заметить, что если у вас уже есть готовый API для, например,
Company
илиDepartment
, то вы не сможете добавлять методы, возвращающиеStream
и будет иметь смысл использовать что-то типа:
List<String> streamEmployeeNames = companies.stream() .map(Company::getDepartments).flatMap(Collection::stream) .map(Department::getEmployees).flatMap(Collection::stream) .map(Employee::getName) .collect(toList());
На самом деле, мне кажется, что это выглядит даже опрятнее, чем метод автора, так как не требуется добавлять какие-то непонятные дополнительные методы.forketyfork Автор
08.07.2015 14:01+2Совершенно верно, именно так и выглядел мой пример в своей первой версии. Но потом мне показалось, что эти дополнительные преобразования несколько затуманивают монадический паттерн, а задача была именно его здесь продемонстрировать. А так получается, что мы ушли от одного бойлерплейта, а пришли к другому: о)
lany
08.07.2015 14:07+3На самом деле создавать методы, возвращающие Stream — это рекомендации лучших собаководов в Java-8. Если вы можете модифицировать ваш класс-коллекцию, добавьте метод, возвращающий Stream. И назовите его не
getDepartmentsStream()
, а простоdepartments()
. СмотритеString.chars()
,BufferedReader.lines()
,Random.ints()
и т. д. Если у вас есть старый метод, возвращающий какой-нибудьList
или массив, и есть возможность прорефакторить, разумно удалить старый метод. Stream будет гибче и эффективнее.gurinderu
08.07.2015 21:46Я бы сказал, что будет красивее и гибче. Насчет эффективнее можно поспорить)
lany
09.07.2015 05:10+2Объясняю, почему это в целом эффективнее. Вот, скажем, у вас метод getDepartments() возвращает
List
. Поначалу вы внутри своего объекта хранили данные вArrayList
и потому метод выглядел примерно какreturn Collection.unmodifiableList(list);
. Потом вы отрефакторили свой объект и решили, что удобнее втнутри хранитьMap
name -> department. Тогда в этом методе придётся написатьreturn new ArrayList<>(map.values())
. Пользователям же нашего метода далеко не всегда нужен именноList
. В одном месте мы вызываемgetDepartments().toArray(new Department[0]);
, потому что дальше вызываем метод, которому требуется массив. В другом месте нам просто интересно, есть ли вообще хоть один элемент:if(!getDepartments().isEmpty())
, в третьем нам нужны на самом деле списки сотрудников, как в примере автора статьи. Во всех этих случаях копирование значений мэпки в список оказывается излишней тратой времени и памяти. Если же мы будем возвращать Stream, мы можем рефакторить внутренности класса в весьма широких пределах и лишнего копирования не потребуется.
Throwable
09.07.2015 13:53-3А теперь задумаемся о смысле (жизни) к чему все это. Ключевое слово — динамическая композиция кода.
Т.н. «классический императивный стиль» соответствует статической композиции. Ветвистый код намертво прибит гвоздями к экрану изнутри, а его поведение определяется исключительно данными. Достоинства: простота и предопределенность. Недостаток — многописанина и сложности с более высоким уровнем абстракции: все должно быть записано явно.
В динамической композиции кода, из которой более чем полностью состоит это ваше функциональное программирование, последовательность действий выстраивается в процессе выполнения. Это позволяет абстрагироваться от конкретики и сконцентрироваться на результате действий (т.н. «декларативный стиль»), тогда как вся конкретика реализуется уже готовыми паттернами. Недостатки: сложность пошаговой отладки и чтения кода «с нуля».
Так что все эти модные акторы, монады, ФРП — способы динамической композиции кода.forketyfork Автор
09.07.2015 14:03+3Что-то я не понял вашу мысль. Где вы увидели динамическую композицию? Все действия (функции) известны на этапе компиляции, их «стыкуемость» друг с другом статически проверена с помощью системы типов — опять же на этапе компиляции. Да, если рассмотреть какой-нибудь функциональный, но всё же динамически типизированный JavaScript, аналогичные паттерны не будут поддерживаться статической системой типов, и там композиция кода действительно будет динамической, но это именно особенность системы типов языка, а не парадигмы ФП.
«Сложность пошаговой отладки» — странная формулировка применительно к ФП. В функциональном коде просто нечего «пошагово отлаживать», т.к. он не «пошаговый», а декларативный. Сложность чтения функционального кода — это миф и исключительно дело привычки.Throwable
10.07.2015 11:14Формально поведение кода всегда детерминировано, если вы про это. Однако для ФП связывание (композиция) может происходит в рантайме и может зависеть от сторонних факторов. В качестве примеров возьмите джавовский CompletableFuture или reactive programming. Сначала программа вычисляет результат композиции — некую комбинированную функцию, а затем данная функция применяется к набору данных и вычисляется конечный результат. Комбинированная функция состоит из множества блоков, но может быть скомпонована каждый раз по-разному — компоновка полностью контролируется рантаймом, отсюда и динамическая композиция. Для императивного подхода данный метод недоступен, поскольку вся композиция происходит на этапе компиляции. Не знаю, объяснил ли я мысль.
forketyfork Автор
10.07.2015 11:21+2Ну представьте себе ветвистый вложенный-перевложенный if в процедурном коде. Там тоже есть множество блоков, конкретная последовательность выполнения которых на момент компиляции вам неизвестна и определяется состоянием. Чем это не «комбинированная функция из множества блоков» в ваших терминах? И она тоже компонуется каждый раз по-разному в рантайме. Да что там, в Java вы на этапе компиляции в большинстве случаев даже не знаете, какой именно код будет фактически исполнен при вызове метода у объектной переменной, потому что все методы (кроме статических, финальных и приватных) являются виртуальными.
lany
09.07.2015 18:38+2Сложность пошаговой отладки действительно имеется, но по факту это вопрос перепрошивки мозга. Нужна ли она, пошаговая отладка пайплайнов-то? Если у вас методы, используемые в
map
иfilter
покрыты тестами, то отлаживать весь пайплайн не придётся. Здесь пошаговая отладка нужна даже меньше, чем в SQL-запросах (в которых без неё тоже как-то живут).
Насчёт чтения кода не соглашусь. Конечно, можно переусердствовать с функциональщиной, но корявый код можно написать в рамках любой концепции. Нормальный функциональный код читается легче, чем императивный, потому что читающий видит более высокие абстракции и не отвлекается на ненужные детали.
kalterfive
13.07.2015 11:37public<U> Optional<U> map(Function<? super T, ? extends U> mapper) { }
Подсветка синтаксиса хромает. Токен «extends»
Lertmind
На КДПВ героиня из неплохого аниме Ergo Proxy.
termsl
У нее пули как-то криво летят
forketyfork Автор
Мне кажется, это не пули, а motion blur от наведённого пистолета, но я уточню у нашей художницы.
1101_debian
И там был Прокси-Монада (не уверен что написал правильно)
overmes
12C41 RE-L