Статьи

Примеры функторов и монад на простом Java

Эта статья изначально была приложением в нашей книге « Реактивное программирование с использованием RxJava» . Однако знакомство с монадами, хотя и очень связанное с реактивным программированием, не очень подходило. Поэтому я решил вынуть его и опубликовать отдельно как пост в блоге. Я знаю, что « мое собственное, наполовину правильное и наполовину полное объяснение монад » — это новый « Hello, world » в блогах по программированию. Тем не менее, в статье рассматриваются функторы и монады с определенной точки зрения на структуры данных и библиотеки Java. Таким образом, я думал, что стоит поделиться.

RxJava был разработан и построен на основе таких фундаментальных понятий, как функторы , моноиды и монады . Хотя Rx изначально был смоделирован для императивного языка C #, и мы изучаем RxJava, работая над аналогичным императивным языком, библиотека имеет свои корни в функциональном программировании. Вы не должны удивляться, узнав, насколько компактен RxJava API. Существует всего лишь несколько основных классов, обычно неизменяемых, и все они составлены в основном из чистых функций.

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

Монада — это моноид в категории эндофункторов, в чем проблема?
Джеймс Ири

Проклятие монады состоит в том, что как только вы получаете прозрение, как только понимаете — «о, вот что это такое» — вы теряете способность объяснять это кому-либо.
Дуглас Крокфорд

Подавляющее большинство программистов, особенно те, у кого нет опыта функционального программирования, склонны считать монады неким загадочным понятием информатики, настолько теоретическим, что это никак не может помочь в их карьере программиста. Эту негативную перспективу можно объяснить десятками статей и постов в блогах, которые либо слишком абстрактны, либо слишком узки Но оказывается, что монады вокруг нас, даже стандартная библиотека Java, особенно после Java Development Kit (JDK) 8 (подробнее об этом позже). То, что является абсолютно блестящим, состоит в том, что, как только вы впервые понимаете монады, внезапно несколько несвязанных классов и абстракций, служащих совершенно другим целям, становятся знакомыми.

Монады обобщают различные, казалось бы, независимые концепции, так что изучение еще одного воплощения монады занимает очень мало времени. Например, вам не нужно изучать, как CompletableFuture работает в Java 8, как только вы поймете, что это монада, вы точно знаете, как она работает и что вы можете ожидать от ее семантики. А потом вы слышите о RxJava, который звучит очень по-разному, но поскольку Observable является монадой, добавить особо нечего. Есть множество других примеров монад, с которыми вы уже сталкивались, не зная об этом. Поэтому этот раздел будет полезным обновлением, даже если вы фактически не используете RxJava.

ФУНКТОРЫ

Прежде чем объяснить, что такое монада, давайте рассмотрим более простую конструкцию, называемую функтором . Функтор — это типизированная структура данных, которая инкапсулирует некоторые значения. С синтаксической точки зрения функтор представляет собой контейнер со следующим API:

1
2
3
4
5
6
7
import java.util.function.Function;
  
interface Functor<T> {
      
    <R> Functor<R> map(Function<T, R> f);
      
}

Но простого синтаксиса недостаточно, чтобы понять, что такое функтор. Единственная операция, которую предоставляет функтор, это map() которая принимает функцию f . Эта функция получает все, что находится внутри блока, преобразует его и упаковывает результат как есть во второй функтор. Пожалуйста, прочитайте это внимательно. Functor<T> всегда является неизменным контейнером, поэтому map никогда не изменяет исходный объект, на котором он был выполнен. Вместо этого он возвращает результат (или результаты — наберитесь терпения), завернутый в новый функтор, возможно, другого типа R Кроме того, функторы не должны выполнять никаких действий при применении функции тождества, то есть map(x -> x) . Такой шаблон всегда должен возвращать либо один и тот же функтор, либо равный экземпляр.

Часто Functor<T> сравнивается с блоком, содержащим экземпляр T где единственный способ взаимодействия с этим значением — его преобразование. Однако идиоматического способа разворачивания или выхода из функтора не существует. Значения всегда остаются в контексте функтора. Почему функторы полезны? Они обобщают несколько общих идиом, таких как коллекции, обещания, дополнительные функции и т. Д., С помощью единого унифицированного API, который работает во всех из них. Позвольте мне представить несколько функторов, которые помогут вам лучше освоить этот API:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
interface Functor<T,F extends Functor<?,?>> {
    <R> F map(Function<T,R> f);
}
  
class Identity<T> implements Functor<T,Identity<?>> {
  
    private final T value;
  
    Identity(T value) { this.value = value; }
  
    public <R> Identity<R> map(Function<T,R> f) {
        final R result = f.apply(value);
        return new Identity<>(result);
    }
      
}

Для компиляции Identity требовался дополнительный параметр типа F В предыдущем примере вы увидели простейший функтор, просто содержащий значение. Все, что вы можете сделать с этим значением, это преобразовать его в метод map , но нет способа извлечь его. Это считается за рамками чистого функтора. Единственный способ взаимодействия с функтором — это применение последовательностей преобразований, безопасных для типов:

1
2
Identity<String> idString = new Identity<>("abc");
Identity<Integer> idInt = idString.map(String::length);

Или бегло, так же, как вы пишете функции:

1
2
3
4
5
6
Identity<byte[]> idBytes = new Identity<>(customer)
        .map(Customer::getAddress)
        .map(Address::street)
        .map((String s) -> s.substring(0, 3))
        .map(String::toLowerCase)
        .map(String::getBytes);

С этой точки зрения отображение на функторе мало чем отличается от простого вызова связанных функций:

1
2
3
4
5
6
byte[] bytes = customer
        .getAddress()
        .street()
        .substring(0, 3)
        .toLowerCase()
        .getBytes();

Зачем вам вообще такая многословная упаковка, которая не только не дает никакой дополнительной ценности, но и не способна извлечь содержимое обратно? Что ж, получается, что вы можете смоделировать несколько других концепций, используя эту необработанную абстракцию функтора. Например, java.util.Optional<T> начиная с Java 8, является функтором с методом map() . Давайте реализуем это с нуля:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class FOptional<T> implements Functor<T,FOptional<?>> {
  
    private final T valueOrNull;
  
    private FOptional(T valueOrNull) {
        this.valueOrNull = valueOrNull;
    }
  
    public <R> FOptional<R> map(Function<T,R> f) {
        if (valueOrNull == null)
            return empty();
        else
            return of(f.apply(valueOrNull));
    }
  
    public static <T> FOptional<T> of(T a) {
        return new FOptional<T>(a);
    }
  
    public static <T> FOptional<T> empty() {
        return new FOptional<T>(null);
    }
  
}

Теперь это становится интересным. FOptional<T> может содержать значение, но также может быть и пустым. Это безопасный от типа способ кодирования null . Существует два способа создания FOptional — путем предоставления значения или создания empty() экземпляра. В обоих случаях, как и в Identity , FOptional является неизменным, и мы можем взаимодействовать только со значением изнутри. FOptional том, что функция преобразования f может не применяться ни к какому значению, если оно пустое. Это означает, что функтор не обязательно должен содержать одно значение типа T Он может также обернуть произвольное количество значений, как List … functor:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
import com.google.common.collect.ImmutableList;
  
class FList<T> implements Functor<T, FList<?>> {
  
    private final ImmutableList<T> list;
  
    FList(Iterable<T> value) {
        this.list = ImmutableList.copyOf(value);
    }
  
    @Override
    public <R> FList<?> map(Function<T, R> f) {
        ArrayList<R> result = new ArrayList<R>(list.size());
        for (T t : list) {
            result.add(f.apply(t));
        }
        return new FList<>(result);
    }
}

API остается тем же: вы берете функтор в преобразовании T -> R — но поведение сильно отличается. Теперь мы применяем преобразование к каждому элементу в FList , декларативно преобразовывая весь список. Так что если у вас есть список customers и вы хотите список их улиц, это так просто:

1
2
3
4
5
6
7
import static java.util.Arrays.asList;
  
FList<Customer> customers = new FList<>(asList(cust1, cust2));
  
FList<String> streets = customers
        .map(Customer::getAddress)
        .map(Address::street);

Это уже не так просто, как сказать customers.getAddress().street() , вы не можете вызывать getAddress() для коллекции клиентов, вы должны вызывать getAddress() для каждого отдельного клиента, а затем помещать его обратно в коллекцию. Кстати, Groovy обнаружил, что этот шаблон настолько распространен, что у него фактически есть синтаксический сахар для этого: customer*.getAddress()*.street() . Этот оператор, известный как точка распространения, на самом деле является замаскированной map . Может быть, вам интересно, почему я перебираю list вручную внутри map а не использую Stream s из Java 8: list.stream().map(f).collect(toList()) ? Это звонит в колокол? Что если я скажу вам, что java.util.stream.Stream<T> на Java также является функтором? И, кстати, тоже монада?

Теперь вы должны увидеть первые преимущества функторов — они абстрагируют внутреннее представление и предоставляют согласованный, простой в использовании API для различных структур данных. В качестве последнего примера приведу функтор обещаний , аналогичный Future . Promise «обещает», что значение станет доступным однажды. Это еще не так, возможно, потому что некоторые фоновые вычисления были созданы или мы ожидаем внешнего события. Но это появится когда-нибудь в будущем. Механика выполнения Promise<T> не интересна, но функторная природа такова:

1
2
3
4
5
6
7
Promise<Customer> customer = //...
Promise<byte[]> bytes = customer
        .map(Customer::getAddress)
        .map(Address::street)
        .map((String s) -> s.substring(0, 3))
        .map(String::toLowerCase)
        .map(String::getBytes);

Выглядит знакомо? В этом все дело! Реализация функтора Promise выходит за рамки этой статьи и даже не важна. Достаточно сказать, что мы очень близки к реализации CompletableFuture из Java 8, и мы почти обнаружили Observable из RxJava. Но вернемся к функторам. Promise<Customer> пока не имеет значения Customer . Это обещает иметь такую ​​ценность в будущем. Но мы все еще можем отобразить такой функтор, как мы это делали с FOptional и FList — синтаксис и семантика абсолютно одинаковы. Поведение следует за тем, что представляет функтор. При вызове customer.map(Customer::getAddress) Promise<Address> что означает, что map не блокируется. customer.map() не будет ждать завершения основного обещания customer . Вместо этого он возвращает другое обещание другого типа. Когда исходное обещание завершается, нижестоящее обещание применяет функцию, переданную в map() и передает результат в нисходящем направлении. Внезапно наш функтор позволяет нам передавать асинхронные вычисления неблокирующим образом. Но вам не нужно это понимать или изучать — поскольку Promise является функтором, он должен следовать синтаксису и законам.

Есть много других замечательных примеров функторов, например, представляющих значение или ошибку композиционным способом. Но самое время взглянуть на монады.

От функторов до монад

Я полагаю, вы понимаете, как работают функторы и почему они являются полезной абстракцией. Но функторы не настолько универсальны, как можно было бы ожидать. Что произойдет, если ваша функция преобразования (переданная в качестве аргумента map() ) возвращает экземпляр функтора, а не простое значение? Ну, функтор — это просто ценность, так что ничего плохого не происходит. Все, что было возвращено, помещается обратно в функтор, поэтому все ведет себя согласованно. Однако представьте, что у вас есть этот удобный метод для разбора String s:

1
2
3
4
5
6
7
8
FOptional<Integer> tryParse(String s) {
    try {
        final int i = Integer.parseInt(s);
        return FOptional.of(i);
    } catch (NumberFormatException e) {
        return FOptional.empty();
    }
}

Исключением являются побочные эффекты, которые подрывают систему типов и функциональную чистоту. В чисто функциональных языках нет места для исключений, ведь мы никогда не слышали о создании исключений во время математических занятий, верно? Ошибки и недопустимые условия представлены явно с использованием значений и оболочек. Например, tryParse() принимает String но не просто возвращает int или автоматически генерирует исключение во время выполнения. Мы явно tryParse() через систему типов, что tryParse() может потерпеть неудачу, нет ничего исключительного или ошибочного в наличии искаженной строки. Этот полуотказ представлен необязательным результатом. Интересно, что Java проверила исключения, те, которые должны быть объявлены и обработаны, поэтому в некотором смысле Java чище в этом отношении, она не скрывает побочные эффекты. Но, к лучшему или худшему, проверенные исключения часто не приветствуются в Java, поэтому давайте вернемся к tryParse() . Кажется полезным написать tryParse со String уже обернутой в FOptional :

1
2
FOptional<String> str = FOptional.of("42");
FOptional<FOptional<Integer>> num = str.map(this::tryParse);

Это не должно быть сюрпризом. Если tryParse() вернет int вы получите FOptional<Integer> num , но поскольку функция map() возвращает сам FOptional<Integer> , она дважды оборачивается в неуклюжий FOptional<FOptional<Integer>> . Пожалуйста, внимательно посмотрите на типы, вы должны понять, почему мы получили эту двойную упаковку здесь. Помимо ужасного вида, наличие функтора в составе руин функтора и плавное сцепление:

1
2
3
4
5
6
7
FOptional<Integer> num1 = //...
FOptional<FOptional<Integer>> num2 = //...
  
FOptional<Date> date1 = num1.map(t -> new Date(t));
  
//doesn't compile!
FOptional<Date> date2 = num2.map(t -> new Date(t));

Здесь мы пытаемся отобразить содержимое FOptional , превратив int в + Date +. Имея функцию int -> Date мы можем легко преобразовать ее из Functor<Integer> в Functor<Date> , мы знаем, как это работает. Но в случае num2 ситуация усложняется. То, что num2.map() получает в качестве входных данных, больше не является int а FOoption<Integer> и, очевидно, java.util.Date не имеет такого конструктора. Мы сломали наш функтор, дважды обмотав его. Однако наличие функции, которая возвращает функтор, а не простое значение, настолько распространено (как tryParse() ), что мы не можем просто игнорировать такое требование. Один из подходов состоит в том, чтобы ввести специальный join() метод join() который «выравнивает» вложенные функторы:

1
FOptional<Integer> num3 = num2.join()

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

1
2
3
interface Monad<T,M extends Monad<?,?>> extends Functor<T,M> {
    M flatMap(Function<T,M> f);
}

Мы просто пришли к выводу, что flatMap — это просто синтаксический сахар, позволяющий улучшить композицию. Но метод flatMap (часто называемый bind или >>= из Haskell) имеет все значение, поскольку он позволяет составлять сложные преобразования в чистом, функциональном стиле. Если FOptional был экземпляром монады, синтаксический анализ неожиданно работает, как и ожидалось:

1
2
FOptional<Integer> num = FOptional.of(42);
FOptional<Integer> answer = num.flatMap(this::tryParse);

Монады не нуждаются в реализации map , она может быть легко реализована поверх flatMap() . На самом деле flatMap является основным оператором, который обеспечивает совершенно новую вселенную преобразований. Очевидно, что, как и в случае с функторами, синтаксического соответствия недостаточно, чтобы назвать некоторый класс монадой, оператор flatMap() должен следовать законам монад, но они довольно интуитивно понятны, как ассоциативность flatMap() и идентичность. Последнее требует, чтобы m(x).flatMap(f) таким же, как f(x) для любой монады, содержащей значение x и для любой функции f . Мы не будем углубляться в теорию монад, а сосредоточимся на практических последствиях. Монады сияют, когда их внутренняя структура не тривиальна, например, монада Promise которая будет иметь значение в будущем. Можете ли вы догадаться из системы типов, как Promise будет вести себя в следующей программе? Сначала все методы, которые потенциально могут занять некоторое время, возвращают Promise :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
import java.time.DayOfWeek;
  
  
Promise<Customer> loadCustomer(int id) {
    //...
}
  
Promise<Basket> readBasket(Customer customer) {
    //...
}
  
Promise<BigDecimal> calculateDiscount(Basket basket, DayOfWeek dow) {
    //...
}

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

1
2
3
4
Promise<BigDecimal> discount =
    loadCustomer(42)
        .flatMap(this::readBasket)
        .flatMap(b -> calculateDiscount(b, DayOfWeek.FRIDAY));

Это становится интересным. flatMap() должен сохранять монадический тип, поэтому все промежуточные объекты — Promise . Речь идет не только о порядке типов — предыдущая программа внезапно становится полностью асинхронной! loadCustomer() возвращает Promise поэтому он не блокируется. readBasket() принимает все, что есть у Promise (будет), и применяет функцию, возвращающую другое Promise и так далее, и так далее. По сути, мы создали асинхронный конвейер вычислений, в котором выполнение одного шага в фоновом режиме автоматически запускает следующий шаг.

Изучение flatMap()

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

01
02
03
04
05
06
07
08
09
10
import java.time.LocalDate;
import java.time.Month;
  
  
Monad<Month> month = //...
Monad<Integer> dayOfMonth = //...
  
Monad<LocalDate> date = month.flatMap((Month m) ->
        dayOfMonth
                .map((int d) -> LocalDate.of(2016, m, d)));

Пожалуйста, найдите время, чтобы изучить предыдущий псевдокод. Я не использую настоящую монаду, такую ​​как Promise или List чтобы подчеркнуть основную концепцию. У нас есть две независимые монады, одна типа Month а другая типа Integer . Чтобы построить из них LocalDate , мы должны создать вложенное преобразование, которое имеет доступ к внутренним LocalDate обеих монад. flatMap типы, особенно убедившись, что вы понимаете, почему мы используем flatMap в одном месте и map() в другом. Подумайте, как бы вы структурировали этот код, если бы у вас была и третья Monad<Year> . Этот шаблон применения функции двух аргументов (в нашем случае m и d ) настолько распространен, что в Haskell есть специальная вспомогательная функция с именем liftM2 которая выполняет именно это преобразование, реализованное поверх map и flatMap . В псевдосинтаксисе Java это будет выглядеть примерно так:

1
2
3
4
5
Monad<R> liftM2(Monad<T1> t1, Monad<T2> t2, BiFunction<T1, T2, R> fun) {
    return t1.flatMap((T1 tv1) ->
            t2.map((T2 tv2) -> fun.apply(tv1, tv2))
    );
}

Вам не нужно реализовывать этот метод для каждой монады, достаточно flatMap() , более того, он работает согласованно для всех монад. liftM2 чрезвычайно полезен, когда вы рассматриваете, как его можно использовать с различными монадами. Например, listM2(list1, list2, function) будет применять function к каждой возможной паре элементов из list1 и list2 (декартово произведение). С другой стороны, для опций она будет применять функцию только тогда, когда оба опциона не пусты. Более того, для монады Promise функция будет выполняться асинхронно, когда оба Promise будут выполнены. Это означает, что мы только что изобрели простой механизм синхронизации ( join() в алгоритмах fork-join) из двух асинхронных шагов.

Другим полезным оператором, который мы можем легко построить поверх flatMap() является filter(Predicate<T>) который берет все, что находится внутри монады, и полностью отбрасывает его, если он не соответствует определенному предикату. В некотором смысле это похоже на map но вместо map 1-к-1 мы имеем 1-к-0-или-1. И снова filter() имеет одинаковую семантику для каждой монады, но довольно удивительную функциональность, в зависимости от того, какую монаду мы на самом деле используем. Очевидно, это позволяет отфильтровывать определенные элементы из списка:

1
2
3
     
FList<Customer> vips =
    customers.filter(c -> c.totalOrders > 1_000);

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

Из списка монад в список монад

Другой полезный оператор, который происходит от flatMap()sequence() . Вы можете легко догадаться, что он делает, просто посмотрев на сигнатуру типа:

1
Monad<Iterable<T>> sequence(Iterable<Monad<T>> moands)

Часто у нас есть несколько монад одного типа, и мы хотим иметь одну монаду из списка этого типа. Это может звучать абстрактно для вас, но это очень полезно. Представьте, что вы хотите одновременно загрузить несколько клиентов из базы данных по идентификатору, поэтому вы несколько раз использовали loadCustomer(id) для разных идентификаторов, каждый вызов возвращал Promise<Customer> . Теперь у вас есть список Promise но вам действительно нужен список клиентов, например, для отображения в веб-браузере. sequence() (в RxJava sequence() называется concat() или merge() , в зависимости от варианта использования), оператор построен именно для этого:

1
2
3
4
5
6
7
FList<Promise<Customer>> custPromises = FList
    .of(1, 2, 3)
    .map(database::loadCustomer);
  
Promise<FList<Customer>> customers = custPromises.sequence();
  
customers.map((FList<Customer> c) -> ...);

Имея FList<Integer> представляющий идентификаторы клиентов, мы map его (вы видите, как помогает то, что FList является функтором?), FList database.loadCustomer(id) для каждого идентификатора. Это приводит к довольно неудобному списку Promise s. sequence() спасает день, но, опять же, это не просто синтаксический сахар. Предыдущий код полностью неблокируемый. Для различных типов монад sequence() все еще имеет смысл, но в другом вычислительном контексте. Например, он может изменить FList<FOptional<T>> на FOptional<FList<T>> . И, кстати, вы можете реализовать sequence() (как и map() ) поверх flatMap() .

Это всего лишь верхушка айсберга, когда дело доходит до полезности flatMap() и монад в целом. Несмотря на то, что они пришли из довольно туманной теории категорий, монады оказались чрезвычайно полезной абстракцией даже в объектно-ориентированных языках программирования, таких как Java. Возможность составлять функции, возвращающие монады, настолько универсально полезна, что десятки несвязанных классов следуют монадическому поведению.

Более того, как только вы инкапсулируете данные в монаде, часто трудно получить их явно. Такая операция не является частью поведения монады и часто приводит к неидиоматическому коду. Например, Promise.get() в Promise<T> может технически вернуть T , но только путем блокировки, тогда как все операторы, основанные на flatMap() , не являются блокирующими. Другим примером является FOptional.get() который может завершиться ошибкой, поскольку FOptional может быть пустым. Даже FList.get(idx) который просматривает определенный элемент из списка, звучит неловко, потому что вы можете довольно часто заменять циклы на map() .

Надеюсь, теперь вы понимаете, почему монады так популярны в наши дни. Даже в объектно-ориентированном (-иш) языке, таком как Java, они являются весьма полезной абстракцией.

Ссылка: Примеры функторов и монад на простой Java от нашего партнера по JCG Томаша Нуркевича из блога Java и соседей .