Как стать автором
Обновить

Функциональное программирование на Java с Vavr

Время на прочтение8 мин
Количество просмотров15K
Многие слышали про такие функциональные языки, как Haskell и Clojure. Но есть и такие языки, как, например, Scala. Он совмещает в себе как ООП, так и функциональный подход. А что насчет старой доброй Java? Можно ли на ней писать программы в функциональном стиле и на сколько это может быть больно? Да, есть Java 8 и лямбды со стримами. Это большой шаг для языка, но этого все еще мало. Можно ли что-то придумать в такой ситуации? Оказывается да.



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

Рассмотрим функциональные структуры более подробно. Любая такая структура должна удовлетворять как минимум двум условиям:

  • immutable — структура должна быть неизменяемой. Это означает, что мы фиксируем состояние объекта на этапе создания и оставляем его таковым до конца его существования. Явный пример нарушения условия: стандартный ArrayList.
  • persistent — структура должна храниться в памяти максимально долго. Если мы создали какой-то объект, то вместо создания нового с таким же состоянием, мы должны использовать готовый. Говоря более формально, такие структуры при модификации сохраняют все свои предыдущие состояния. Ссылки на эти состояния должны оставаться полностью работоспособными.

Очевидно, что нам нужно какое-то стороннее решение. И такое решение есть: библиотека Vavr. На сегодняшний день это самая популярная библиотека на Java для работы в функциональном стиле. Далее я опишу основные фишки библиотеки. Многие, но далеко не все, примеры и описания были взяты из официальной документации.

Основные структуры данных библиотеки vavr


Кортеж


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

Tuple tuple = Tuple.of(1, "blablabla", .0, 42L); // (1, blablabla, 0.0, 42)

Получение нужного элемента происходит из вызова поля с номером элемента в кортеже.

((Tuple4) tuple)._1 // 1

Обратите внимание: индексация кортежей начинается с 1! Кроме того, для получения нужного элемента мы должны преобразовать наш объект к нужному типу с соответствующим набором методов. В примере выше мы использовали кортеж из 4 элементов, а значит преобразование должно быть в тип Tuple4. На самом деле, никто не мешает нам изначально сделать нужный тип.

Tuple4 tuple = Tuple.of(1, "blablabla", .0, 42L); // (1, blablabla, 0.0, 42)
System.out.println(tuple._1); // 1

Топ 3 коллекций vavr


Список


Создать список с vavr очень просто. Даже проще, чем без vavr.

List.of(1, 2, 3)

Что мы можем сделать с таким списком? Ну во-первых, мы можем превратить его в стандартный java список.

final boolean containThree = List.of(1, 2, 3)
       .asJava()
       .stream()
       .anyMatch(x -> x == 3);

Но на самом деле в этом нет большой необходимости, т.к. мы можем сделать, например, так:

final boolean containThree = List.of(1, 2, 3)
       .find(x -> x == 1)
       .isDefined();

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

// рассчет суммы
final int zero = 0; // нейтральный элемент
final BiFunction<Integer, Integer, Integer> combine
       = (x, y) -> x + y; // функция объединения
final int sum = List.of(1, 2, 3)
       .fold(zero, combine); // вызываем свертку

Здесь следует отметить один важный момент. У нас имеются функциональные структуры данных, а это значит, что мы не можем менять их состояние. Как реализован наш список? Массивы нам точно не подходят.

Linked List в качестве списка по умолчанию

Сделаем односвязный список с неизмеяемыми объектами. Получится примерно так:

image

Пример в коде
List list = List.of(1, 2, 3);


У каждого элемента списка есть два основных метода: получение головного элемента (head) и всех остальных (tail).

Пример в коде
list.head(); // 1
list.tail(); // List(2, 3)


Теперь, если мы хотим поменять первый элемент в списке (с 1 на 0), то нам надо создать новый список с переиспользованием уже готовых частей.

image
Пример в коде
final List tailList = list.tail(); // получаем хвост списка
tailList.prepend(0); // добавляем элемент в начало списка


И все! Так как наши объекты в листе неизменны, мы получаем потокобезопасную и переиспользуемую коллекцию. Элементы нашего списка могут быть применены в любом месте приложения и это совершенно безопасно!

Очередь


Еще одной крайне полезной структурой данных является очередь. Как сделать очередь для построения эффективных и надежных программ в функциональном стиле? Например, мы можем взять уже известные нам структуры данных: два списка и кортеж.

image

Пример в коде
Queue<Integer> queue = Queue.of(1, 2, 3)
       .enqueue(4)
       .enqueue(5);


Когда первый заканчивается, мы разворачиваем второй и используем его для чтения.

image

image

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

System.out.println(queue); // Queue(1, 2, 3, 4, 5)
Tuple2<Integer, Queue<Integer>> tuple2 = queue.dequeue();
System.out.println(tuple2._1); // 1
System.out.println(tuple2._2); // Queue(2, 3, 4, 5)

Стримы


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

Кто-то может сказать, что в Java 8 уже есть полноценные стримы и новые нам совсем не нужны. Так ли это?

Для начала, давайте убедимся, что java stream — не функциональная структура данных. Проверим структуру на изменяемость. Для этого создадим такой небольшой стрим:
IntStream standardStream = IntStream.range(1, 10);

Сделаем перебор всех элементов в стриме:

standardStream.forEach(System.out::print);

В ответ получаем вывод в консоль: 123456789. Давайте повторим операцию перебора:

standardStream.forEach(System.out::print);

Упс, произошла такая ошибка:

java.lang.IllegalStateException: stream has already been operated upon or closed

Дело в том, что стандартные стримы — это просто некоторая абстракция над итератором. Хоть стримы внешне и кажутся крайне независимыми и мощными, но минусы итераторов никуда не делись.

Например, в определении стрима ничего не сказано про ограничение количества элементов. К сожалению, в итераторе оно есть, а значит есть и в стандартных стримах.

К счастью, библиотека vavr решает эти проблемы. Убедимся в этом:

Stream stream = Stream.range(1, 10);
stream.forEach(System.out::print);
stream.forEach(System.out::print);

В ответ получаем 123456789123456789. Что означает первая операция не “испортила” наш стрим.

Попробуем теперь создать бесконечный стрим:

Stream infiniteStream = Stream.from(1);
System.out.println(infiniteStream); // Stream(1, ?)

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

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

Stream oddNumbers = Stream
       .from(1, 2) // от 1 с шагом 2
       .map(x -> x + " "); // форматирование
// пример использования
oddNumbers.take(5)
       .forEach(System.out::print); // 1 3 5 7 9
oddNumbers.take(10)
       .forEach(System.out::print); // 1 3 5 7 9 11 13 15 17 19

Вот так просто.

Общая структура коллекций


После того как мы обсудили основные структуры, пришло время посмотреть на общую архитектуру функциональных коллекций vavr:



Каждый элемент структуры может быть использован как итерируемый:

StringBuilder builder = new StringBuilder();
for (String word : List.of("one", "two", "tree")) {
   if (builder.length() > 0) {
       builder.append(", ");
   }
   builder.append(word);
}
System.out.println(builder.toString()); // one, two, tree

Но стоит дважды подумать и посмотреть доку перед использованием for. Библиотека позволяет делать привычные вещи проще.

System.out.println(List.of("one", "two", "tree").mkString(", ")); // one, two, tree

Работа с функциями


Библиотека имеет ряд функций (8 штук) и полезные методы работы с ними. Они представляют собой обычные функциональные интерфейсы с множеством интересных методов. Название функций зависит от количества принимаемых аргументов (от 0 до 8). Например, Function0 не принимает аргументов, Function1 принимает один аргумент, Function2 принимает два и т.д.

Function2<String, String, String> combineName =
       (lastName, firstName) -> firstName + " " + lastName;
System.out.println(combineName.apply("Griffin", "Peter")); // Peter Griffin

В функциях библиотеки vavr мы можем делать очень много крутых вещей. По функционалу они уходят далеко вперед от стандартных нам Function, BiFunction и т.д. Например, каррирование. Каррирование — это построение функций по частям. Посмотрим на примере:

// Создаем базовую функцию
Function2<String, String, String> combineName =
       (lastName, firstName) -> firstName + " " + lastName;
// На основе базовой строим новую функцию с одним переданным элементом
Function1<String, String> makeGriffinName = combineName
       .curried()
       .apply("Griffin");
// Работаем как с полноценной функцией
System.out.println(makeGriffinName.apply("Peter")); // Peter Griffin
System.out.println(makeGriffinName.apply("Lois")); // Lois Griffin

Как вы видите, достаточно лаконично. Метод curried устроен крайне просто, но может принести огромную пользу.

Реализация метода curried
@Override
default Function1<T1, Function1<T2, R>> curried() {
   return t1 -> t2 -> apply(t1, t2);
}


В наборе Function есть еще множество полезных методов. Например, можно кэшировать возвращаемый результат функции:

Function0<Double> hashCache =
       Function0.of(Math::random).memoized();

double randomValue1 = hashCache.apply();
double randomValue2 = hashCache.apply();

System.out.println(randomValue1 == randomValue2); // true


Борьба с исключениями


Как мы говорили ранее, процесс программирования должен быть безопасным. Для этого необходимо избегать различных посторонних эффектов. Исключения (exceptions) являются явными их генераторами.

Для безопасной обработки исключений в функциональном стиле можно использовать класс Try. На самом деле, это типичная монада. Углубляться в теорию для использования вовсе не обязательно. Достаточно посмотреть простой пример:

Try.of(() -> 4 / 0)
       .onFailure(System.out::println)
       .onSuccess(System.out::println);

Как видно из примера все достаточно просто. Мы просто вешаем событие на потенциальную ошибку и не выносим ее за пределы вычислений.

Pattern matching


Часто возникает ситуация, в которой нам необходимо проверять значение переменной и моделировать поведение программы в зависимости от результата. Как раз в таких ситуациях на помощь приходит замечательный механизм поиска по шаблону. Больше не надо писать кучу if else, достаточно настроить всю логику в одном месте.

import static io.vavr.API.*;
import static io.vavr.Predicates.*;

public class PatternMatchingDemo {
    public static void main(String[] args) {
        String s = Match(1993).of(
                Case($(42), () -> "one"),
                Case($(anyOf(isIn(1990, 1991, 1992), is(1993))), "two"),
                Case($(), "?")
        );
        System.out.println(s); // two
    }
}

Обратите внимание, Case написано с большой буквы, т.к. case является ключевым словом и уже занято.

Вывод


На мой взгляд библиотека очень крутая, но стоит применять ее крайне аккуратно. Она может отлично проявить себя в event-driven разработке. Однако, чрезмерное и бездумное ее использование в стандартном императивном программировании, основанном на пуле потоков, может принести много головной боли. Кроме того, часто в наших проектах используются Spring и Hibernate, которые не всегда готовы к подобному применению. Перед импортом библиотеки в свой проект необходимо четкое понимание, как и зачем она будет использована. О чем я и расскажу в одной из своих следующих статей.
Теги:
Хабы:
Всего голосов 19: ↑18 и ↓1+17
Комментарии16

Публикации