Перейти к основному содержимому

Введение в Вавр

· 13 мин. чтения

1. Обзор

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

Vavr — это функциональная библиотека для Java 8+, предоставляющая неизменяемые типы данных и функциональные управляющие структуры.

1.1. Зависимость от Maven

Чтобы использовать Vavr, вам нужно добавить зависимость:

<dependency>
<groupId>io.vavr</groupId>
<artifactId>vavr</artifactId>
<version>0.9.0</version>
</dependency>

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

2. Вариант

Основная цель Option — исключить проверки на null в нашем коде за счет использования системы типов Java.

Option — это объектный контейнер в Vavr с такой же конечной целью, как и у Optional в Java 8. Option Vavr реализует Serializable, Iterable и имеет более богатый API .

Поскольку любая ссылка на объект в Java может иметь нулевое значение, нам обычно приходится проверять на нулевое значение с помощью операторов if перед ее использованием. Эти проверки делают код надежным и стабильным:

@Test
public void givenValue_whenNullCheckNeeded_thenCorrect() {
Object possibleNullObj = null;
if (possibleNullObj == null) {
possibleNullObj = "someDefaultValue";
}
assertNotNull(possibleNullObj);
}

Без проверок приложение может вылететь из-за простого NPE:

@Test(expected = NullPointerException.class)
public void givenValue_whenNullCheckNeeded_thenCorrect2() {
Object possibleNullObj = null;
assertEquals("somevalue", possibleNullObj.toString());
}

Однако проверки делают код многословным и менее читабельным , особенно когда операторы if оказываются вложенными несколько раз.

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

С Option нулевое значение будет оцениваться как экземпляр None , а ненулевое значение будет оцениваться как экземпляр Some :

@Test
public void givenValue_whenCreatesOption_thenCorrect() {
Option<Object> noneOption = Option.of(null);
Option<Object> someOption = Option.of("val");

assertEquals("None", noneOption.toString());
assertEquals("Some(val)", someOption.toString());
}

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

Обратите внимание, что нам не нужно было выполнять проверку перед вызовом toString , но нам не приходилось иметь дело с NullPointerException , как мы это делали раньше. Опция toString возвращает нам значимые значения при каждом вызове.

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

@Test
public void givenNull_whenCreatesOption_thenCorrect() {
String name = null;
Option<String> nameOption = Option.of(name);

assertEquals("foreach", nameOption.getOrElse("foreach"));
}

Или ненулевое значение:

@Test
public void givenNonNull_whenCreatesOption_thenCorrect() {
String name = "foreach";
Option<String> nameOption = Option.of(name);

assertEquals("foreach", nameOption.getOrElse("notforeach"));
}

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

3. Кортеж

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

Vavr привносит кортежи в Java 8. Кортежи бывают типа Tuple1, Tuple2 и Tuple8 в зависимости от количества элементов, которые они должны принимать.

В настоящее время существует верхний предел в восемь элементов. Мы обращаемся к элементам кортежа, например кортежу ._n, где n аналогично понятию индекса в массивах:

public void whenCreatesTuple_thenCorrect1() {
Tuple2<String, Integer> java8 = Tuple.of("Java", 8);
String element1 = java8._1;
int element2 = java8._2();

assertEquals("Java", element1);
assertEquals(8, element2);
}

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

@Test
public void whenCreatesTuple_thenCorrect2() {
Tuple3<String, Integer, Double> java8 = Tuple.of("Java", 8, 1.8);
String element1 = java8._1;
int element2 = java8._2();
double element3 = java8._3();

assertEquals("Java", element1);
assertEquals(8, element2);
assertEquals(1.8, element3, 0.1);
}

Место кортежа заключается в хранении фиксированной группы объектов любого типа, которые лучше обрабатываются как единое целое и могут передаваться. Более очевидный вариант использования — возврат более одного объекта из функции или метода в Java.

4. Попробуйте

В Vavr Try — это контейнер для вычислений , которые могут привести к исключению.

Поскольку Option оборачивает объект, допускающий значение NULL, так что нам не нужно явно заботиться о нулях с помощью проверок if , Try оборачивает вычисления, чтобы нам не нужно было явно заботиться об исключениях с помощью блоков try-catch .

Возьмем, к примеру, следующий код:

@Test(expected = ArithmeticException.class)
public void givenBadCode_whenThrowsException_thenCorrect() {
int i = 1 / 0;
}

Без блоков try-catch приложение вылетит. Чтобы избежать этого, вам нужно будет обернуть оператор в блок try-catch . С помощью Vavr мы можем обернуть тот же код в экземпляр Try и получить результат:

@Test
public void givenBadCode_whenTryHandles_thenCorrect() {
Try<Integer> result = Try.of(() -> 1 / 0);

assertTrue(result.isFailure());
}

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

В приведенном выше фрагменте мы решили просто проверить успех или неудачу. Мы также можем выбрать возврат значения по умолчанию:

@Test
public void givenBadCode_whenTryHandles_thenCorrect2() {
Try<Integer> computation = Try.of(() -> 1 / 0);
int errorSentinel = result.getOrElse(-1);

assertEquals(-1, errorSentinel);
}

Или даже явно генерировать исключение по нашему выбору:

@Test(expected = ArithmeticException.class)
public void givenBadCode_whenTryHandles_thenCorrect3() {
Try<Integer> result = Try.of(() -> 1 / 0);
result.getOrElseThrow(ArithmeticException::new);
}

Во всех вышеперечисленных случаях мы можем контролировать то, что происходит после вычисления, благодаря Try Vavr .

5. Функциональные интерфейсы

С появлением Java 8 функциональные интерфейсы стали встроенными и более простыми в использовании, особенно в сочетании с лямбда-выражениями.

Однако Java 8 предоставляет только две основные функции. Один принимает только один параметр и выдает результат:

@Test
public void givenJava8Function_whenWorks_thenCorrect() {
Function<Integer, Integer> square = (num) -> num * num;
int result = square.apply(2);

assertEquals(4, result);
}

Второй принимает только два параметра и выдает результат:

@Test
public void givenJava8BiFunction_whenWorks_thenCorrect() {
BiFunction<Integer, Integer, Integer> sum =
(num1, num2) -> num1 + num2;
int result = sum.apply(5, 7);

assertEquals(12, result);
}

С другой стороны, Vavr расширяет идею функциональных интерфейсов в Java, поддерживая до восьми параметров и добавляя в API методы для запоминания, композиции и каррирования.

Так же, как кортежи, эти функциональные интерфейсы названы в соответствии с количеством параметров, которые они принимают: Function0 , Function1 , Function2 и т. д. С Vavr мы бы написали две вышеупомянутые функции следующим образом:

@Test
public void givenVavrFunction_whenWorks_thenCorrect() {
Function1<Integer, Integer> square = (num) -> num * num;
int result = square.apply(2);

assertEquals(4, result);
}

и это:

@Test
public void givenVavrBiFunction_whenWorks_thenCorrect() {
Function2<Integer, Integer, Integer> sum =
(num1, num2) -> num1 + num2;
int result = sum.apply(5, 7);

assertEquals(12, result);
}

Когда нет параметра, но нам все еще нужен вывод, в Java 8 нам нужно будет использовать тип Supplier , в Vavr Function0 поможет:

@Test
public void whenCreatesFunction_thenCorrect0() {
Function0<String> getClazzName = () -> this.getClass().getName();
String clazzName = getClazzName.apply();

assertEquals("com.foreach.vavr.VavrTest", clazzName);
}

Как насчет функции с пятью параметрами, это всего лишь вопрос использования Function5 :

@Test
public void whenCreatesFunction_thenCorrect5() {
Function5<String, String, String, String, String, String> concat =
(a, b, c, d, e) -> a + b + c + d + e;
String finalString = concat.apply(
"Hello ", "world", "! ", "Learn ", "Vavr");

assertEquals("Hello world! Learn Vavr", finalString);
}

Мы также можем объединить статический фабричный метод FunctionN.of для любой из функций, чтобы создать функцию Vavr из ссылки на метод. Например, если у нас есть следующий метод суммы :

public int sum(int a, int b) {
return a + b;
}

Мы можем создать из него функцию следующим образом:

@Test
public void whenCreatesFunctionFromMethodRef_thenCorrect() {
Function2<Integer, Integer, Integer> sum = Function2.of(this::sum);
int summed = sum.apply(5, 6);

assertEquals(11, summed);
}

6. Коллекции

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

Коллекции Java изменяемы, что делает их отличным источником сбоев программы , особенно при наличии параллелизма. Интерфейс Collection предоставляет такие методы:

interface Collection<E> {
void clear();
}

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

Такой класс не только не дает нулевых предельных преимуществ, но и снижает производительность класса, лазейки которого он пытается заполнить.

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

Другие существующие тактики добавления неизменяемости коллекций в Java по-прежнему создают больше проблем, а именно исключения:

@Test(expected = UnsupportedOperationException.class)
public void whenImmutableCollectionThrows_thenCorrect() {
java.util.List<String> wordList = Arrays.asList("abracadabra");
java.util.List<String> list = Collections.unmodifiableList(wordList);
list.add("boom");
}

Все вышеперечисленные проблемы отсутствуют в коллекциях Vavr.

Чтобы создать список в Vavr:

@Test
public void whenCreatesVavrList_thenCorrect() {
List<Integer> intList = List.of(1, 2, 3);

assertEquals(3, intList.length());
assertEquals(new Integer(1), intList.get(0));
assertEquals(new Integer(2), intList.get(1));
assertEquals(new Integer(3), intList.get(2));
}

Также доступны API для выполнения вычислений в списке на месте:

@Test
public void whenSumsVavrList_thenCorrect() {
int sum = List.of(1, 2, 3).sum().intValue();

assertEquals(6, sum);
}

Коллекции Vavr предлагают большинство общих классов Java Collections Framework, и на самом деле реализованы все функции.

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

Полный обзор коллекций Vavr выходит за рамки этой статьи.

7. Проверка

Vavr переносит концепцию Applicative Functor в Java из мира функционального программирования. Проще говоря, аппликативный функтор позволяет нам выполнять последовательность действий, накапливая результаты .

Класс vavr.control.Validation способствует накоплению ошибок. Помните, что обычно программа завершается, как только возникает ошибка.

Однако проверка продолжает обрабатывать и накапливать ошибки, чтобы программа обрабатывала их как пакет.

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

public class Person {
private String name;
private int age;

// standard constructors, setters and getters, toString
}

Затем мы создаем класс с именем PersonValidator . Каждое поле будет проверено одним методом, а другой метод можно использовать для объединения всех результатов в один экземпляр проверки :

class PersonValidator {
String NAME_ERR = "Invalid characters in name: ";
String AGE_ERR = "Age must be at least 0";

public Validation<Seq<String>, Person> validatePerson(
String name, int age) {
return Validation.combine(
validateName(name), validateAge(age)).ap(Person::new);
}

private Validation<String, String> validateName(String name) {
String invalidChars = name.replaceAll("[a-zA-Z ]", "");
return invalidChars.isEmpty() ?
Validation.valid(name)
: Validation.invalid(NAME_ERR + invalidChars);
}

private Validation<String, Integer> validateAge(int age) {
return age < 0 ? Validation.invalid(AGE_ERR)
: Validation.valid(age);
}
}

Правило для возраста состоит в том, что оно должно быть целым числом больше 0, а правило для имени состоит в том, что оно не должно содержать специальных символов:

@Test
public void whenValidationWorks_thenCorrect() {
PersonValidator personValidator = new PersonValidator();

Validation<List<String>, Person> valid =
personValidator.validatePerson("John Doe", 30);

Validation<List<String>, Person> invalid =
personValidator.validatePerson("John? Doe!4", -1);

assertEquals(
"Valid(Person [name=John Doe, age=30])",
valid.toString());

assertEquals(
"Invalid(List(Invalid characters in name: ?!4,
Age must be at least 0))",
invalid.toString());
}

Допустимое значение содержится в экземпляре Validation.Valid , список ошибок проверки содержится в экземпляре Validation.Invalid . Таким образом, любой метод проверки должен возвращать одно из двух.

Внутри Validation.Valid — это экземпляр Person , а внутри Validation.Invalid — список ошибок.

8. Ленивый

Lazy — это контейнер, представляющий значение, вычисляемое лениво, т. е. вычисление откладывается до тех пор, пока не потребуется результат. Кроме того, оцененное значение кэшируется или запоминается и возвращается снова и снова каждый раз, когда оно необходимо, без повторения вычислений:

@Test
public void givenFunction_whenEvaluatesWithLazy_thenCorrect() {
Lazy<Double> lazy = Lazy.of(Math::random);
assertFalse(lazy.isEvaluated());

double val1 = lazy.get();
assertTrue(lazy.isEvaluated());

double val2 = lazy.get();
assertEquals(val1, val2, 0.1);
}

В приведенном выше примере функция, которую мы оцениваем, — это Math.random . Обратите внимание, что во второй строке мы проверяем значение и понимаем, что функция еще не выполнена. Это потому, что мы до сих пор не проявили интереса к возвращаемому значению.

В третьей строке кода мы проявляем интерес к значению вычисления, вызывая Lazy.get . В этот момент функция выполняется, и Lazy.evaluated возвращает значение true.

Мы также продолжаем и подтверждаем бит запоминания Lazy , пытаясь снова получить значение. Если бы предоставленная нами функция была выполнена снова, мы бы точно получили другое случайное число.

Однако Lazy снова лениво возвращает первоначально вычисленное значение, поскольку окончательное утверждение подтверждается.

9. Сопоставление с образцом

Сопоставление с образцом — родная концепция почти всех языков функционального программирования. В Java пока такого нет.

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

@Test
public void whenIfWorksAsMatcher_thenCorrect() {
int input = 3;
String output;
if (input == 0) {
output = "zero";
}
if (input == 1) {
output = "one";
}
if (input == 2) {
output = "two";
}
if (input == 3) {
output = "three";
}
else {
output = "unknown";
}

assertEquals("three", output);
}

Мы можем внезапно увидеть код, занимающий несколько строк, при проверке только трех случаев. Каждая проверка занимает три строки кода. Что, если бы нам пришлось проверить до сотни случаев, это было бы около 300 строк, а не красиво!

Другой альтернативой является использование оператора switch :

@Test
public void whenSwitchWorksAsMatcher_thenCorrect() {
int input = 2;
String output;
switch (input) {
case 0:
output = "zero";
break;
case 1:
output = "one";
break;
case 2:
output = "two";
break;
case 3:
output = "three";
break;
default:
output = "unknown";
break;
}

assertEquals("two", output);
}

Не лучше. У нас по-прежнему в среднем 3 строки на чек. Много путаницы и потенциальных ошибок. Забыть предложение break не является проблемой во время компиляции, но позже может привести к трудно обнаруживаемым ошибкам.

В Vavr мы заменяем весь блок switch методом Match . Каждый оператор case или if заменяется вызовом метода Case .

Наконец, атомарные шаблоны, такие как $() , заменяют условие, которое затем оценивает выражение или значение. Мы также предоставляем это как второй параметр Case :

@Test
public void whenMatchworks_thenCorrect() {
int input = 2;
String output = Match(input).of(
Case($(1), "one"),
Case($(2), "two"),
Case($(3), "three"),
Case($(), "?"));

assertEquals("two", output);
}

Обратите внимание, насколько компактен код: в среднем на проверку приходится всего одна строка. API сопоставления с образцом намного мощнее этого и может делать более сложные вещи.

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

Match(arg).of(
Case($(isIn("-h", "--help")), o -> run(this::displayHelp)),
Case($(isIn("-v", "--version")), o -> run(this::displayVersion)),
Case($(), o -> run(() -> {
throw new IllegalArgumentException(arg);
}))
);

Некоторым пользователям может быть более знакома сокращенная версия (-v), а другим — полная версия (-версия). Хороший дизайнер должен учитывать все эти случаи.

Без необходимости в нескольких операторах if мы позаботились о нескольких условиях. Мы узнаем больше о предикатах, множественных условиях и побочных эффектах при сопоставлении с образцом в отдельной статье.

10. Заключение

В этой статье мы представили Vavr, популярную библиотеку функционального программирования для Java 8. Мы рассмотрели основные функции, которые мы можем быстро адаптировать для улучшения нашего кода.

Полный исходный код этой статьи доступен в проекте Github .