Функциональное программирование на Java с Vavr
Многие слышали про такие функциональные языки, как 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 combine
= (x, y) -> x + y; // функция объединения
final int sum = List.of(1, 2, 3)
.fold(zero, combine); // вызываем свертку
Здесь следует отметить один важный момент. У нас имеются функциональные структуры данных, а это значит, что мы не можем менять их состояние. Как реализован наш список? Массивы нам точно не подходят.
Linked List в качестве списка по умолчанию
Сделаем односвязный список с неизмеяемыми объектами. Получится примерно так:
List list = List.of(1, 2, 3);
У каждого элемента списка есть два основных метода: получение головного элемента (head) и всех остальных (tail).
list.head(); // 1
list.tail(); // List(2, 3)
Теперь, если мы хотим поменять первый элемент в списке (с 1 на 0), то нам надо создать новый список с переиспользованием уже готовых частей.
final List tailList = list.tail(); // получаем хвост списка
tailList.prepend(0); // добавляем элемент в начало списка
И все! Так как наши объекты в листе неизменны, мы получаем потокобезопасную и переиспользуемую коллекцию. Элементы нашего списка могут быть применены в любом месте приложения и это совершенно безопасно!
Очередь
Еще одной крайне полезной структурой данных является очередь. Как сделать очередь для построения эффективных и надежных программ в функциональном стиле? Например, мы можем взять уже известные нам структуры данных: два списка и кортеж.
Queue queue = Queue.of(1, 2, 3)
.enqueue(4)
.enqueue(5);
Когда первый заканчивается, мы разворачиваем второй и используем его для чтения.
Важно помнить, что очередь должна быть неизменной, как и все остальные структуры. Но какая польза от очереди, которая не меняется? На самом деле, есть хитрость. В качестве принимаемого значения очереди мы получаем кортеж из двух элементов. Первый: нужный элемент очереди, второй: то, что что стало с очередью без этого элемента.
System.out.println(queue); // Queue(1, 2, 3, 4, 5)
Tuple2> 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 combineName =
(lastName, firstName) -> firstName + " " + lastName;
System.out.println(combineName.apply("Griffin", "Peter")); // Peter Griffin
В функциях библиотеки vavr мы можем делать очень много крутых вещей. По функционалу они уходят далеко вперед от стандартных нам Function, BiFunction и т.д. Например, каррирование. Каррирование — это построение функций по частям. Посмотрим на примере:
// Создаем базовую функцию
Function2 combineName =
(lastName, firstName) -> firstName + " " + lastName;
// На основе базовой строим новую функцию с одним переданным элементом
Function1 makeGriffinName = combineName
.curried()
.apply("Griffin");
// Работаем как с полноценной функцией
System.out.println(makeGriffinName.apply("Peter")); // Peter Griffin
System.out.println(makeGriffinName.apply("Lois")); // Lois Griffin
Как вы видите, достаточно лаконично. Метод curried устроен крайне просто, но может принести огромную пользу.
@Override
default Function1> curried() {
return t1 -> t2 -> apply(t1, t2);
}
В наборе Function есть еще множество полезных методов. Например, можно кэшировать возвращаемый результат функции:
Function0 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, достаточно настроить всю логику в одном месте.
final int i = 1;
String s = Match(1993).of(
Case($(42), () -> "one"),
Case($(anyOf(isIn(1990, 1991, 1992), is(1993))), "two"),
Case($(), "?")
);
System.out.println(s); // one
Обратите внимание, Case написано с большой буквы, т.к. case является ключевым словом и уже занято.
Вывод
На мой взгляд библиотека очень крутая, но стоит применять ее крайне аккуратно. Она может отлично проявить себя в event-driven разработке. Однако, чрезмерное и бездумное ее использование в стандартном императивном программировании, основанном на пуле потоков, может принести много головной боли. Кроме того, часто в наших проектах используются Spring и Hibernate, которые не всегда готовы к подобному применению. Перед импортом библиотеки в свой проект необходимо четкое понимание, как и зачем она будет использована. О чем я и расскажу в одной из своих следующих статей.