Функциональные коллекции в Java с Vavr: обзор и применение

ffaa244fbb5fea445751efa1d73189c5.png

Приветствую всех, кто устал от бесконечных проверок на null, громоздких блоков try-catch и мутирующих коллекций. Если вы когда-нибудь мечтали о том, чтобы привнести в Java немного функциональности, то я рад рассказать вам о библиотеке Vavr.

С появлением Java 8 мы наконец-то получили лямбда-выражения и Stream API. Это было как глоток свежего воздуха после долгих лет императивного программирования. Однако, по сравнению с другими ЯП, вроде Scala или Haskell, Java всё ещё ощущается как язык, созданный для ООП, а не для функционального программирования.

Функциональное программирование предлагает нам:

  • Неизменяемость: объекты не меняют своего состояния после создания.

  • Чистые функции: результат функции зависит только от её входных данных и не имеет побочных эффектов.

  • Функции как объекты первого класса: функции можно передавать, возвращать и хранить в переменных.

Vavr стремится привнести эти концепции в Java.

Установка

Для Maven:

Добавляем вpom.xml следующую зависимость:


    
        io.vavr
        vavr
        0.10.4
    

Для Gradle:

В build.gradle добавьте:

dependencies {
    implementation "io.vavr:vavr:0.10.4"
}

Обзор синтаксиса Vavr

Кортежи

Кортежи позволяют объединять несколько значений различных типов в одну неизменяемую структуру без необходимости создавать отдельный класс.

import io.vavr.Tuple;
import io.vavr.Tuple2;

Tuple2 user = Tuple.of("Alice", 30);

// Доступ к элементам
String name = user._1;
Integer age = user._2;

Можно создавать кортежи с количеством элементов до 8 Tuple8.

Функции: композиция, каррирование, мемоизация

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

Композиция функций позволяет объединять функции в цепочку, где выход одного метода становится входом другого:

import io.vavr.Function1;

Function1 multiplyBy2 = x -> x * 2;
Function1 subtract5 = x -> x - 5;

Function1 combined = multiplyBy2.andThen(subtract5);

int result = combined.apply(10); // (10 * 2) - 5 = 15

Каррирование превращает функцию с несколькими аргументами в последовательность функций с одним аргументом:

import io.vavr.Function3;

Function3 sum = (a, b, c) -> a + b + c;
Function1>> curriedSum = sum.curried();

int result = curriedSum.apply(1).apply(2).apply(3); // 6

Мемоизация кэширует результат функции для определённых аргументов, что может повысить производительность при повторных вызовах:

import io.vavr.Function1;

Function1 factorial = Function1.of(this::computeFactorial).memoized();

int result1 = factorial.apply(5); // Вычисляет и кэширует результат
int result2 = factorial.apply(5); // Возвращает кэшированный результат

// Реализация функции факториала
private int computeFactorial(int n) {
    if (n == 0) return 1;
    return n * computeFactorial(n - 1);
}

Функциональные типы

Option заменяет использование null, представляя значение, которое может быть присутствующим Some или отсутствующим None:

import io.vavr.control.Option;

Option maybeUsername = getUsername();

maybeUsername.map(String::toUpperCase)
             .peek(name -> System.out.println("Hello, " + name))
             .onEmpty(() -> System.out.println("No user logged in"));

Try позволяет обрабатывать операции, которые могут выбросить исключение, в функциональном стиле:

import io.vavr.control.Try;

Try parsedNumber = Try.of(() -> Integer.parseInt("123"));

parsedNumber.onSuccess(num -> System.out.println("Parsed number: " + num))
            .onFailure(ex -> System.err.println("Failed to parse number: " + ex.getMessage()));

Lazy обеспечивает ленивое вычисление и кэширование результата:

import io.vavr.Lazy;

Lazy randomValue = Lazy.of(Math::random);

System.out.println(randomValue.isEvaluated()); // false
double value = randomValue.get(); // Вычисляет и возвращает значение
System.out.println(randomValue.isEvaluated()); // true

Either представляет значение одного из двух возможных типов: Left (обычно ошибка) или Right (обычно успешный результат):

import io.vavr.control.Either;

Either divisionResult = divide(10, 2);

divisionResult.peek(result -> System.out.println("Result: " + result))
              .peekLeft(error -> System.err.println("Error: " + error));

// Реализация метода divide
public Either divide(int dividend, int divisor) {
    if (divisor == 0) {
        return Either.left("Cannot divide by zero");
    } else {
        return Either.right(dividend / divisor);
    }
}

Future используется для асинхронных операций, позволяя работать с их результатами в функциональном стиле:

import io.vavr.concurrent.Future;

Future futureResult = Future.of(() -> longRunningOperation());

futureResult.onSuccess(result -> System.out.println("Operation completed: " + result))
            .onFailure(ex -> System.err.println("Operation failed: " + ex.getMessage()));

Validation используется для накопления ошибок при валидации данных, вместо остановки после первой ошибки:

import io.vavr.collection.Seq;
import io.vavr.control.Validation;

Validation, User> userValidation = Validation.combine(
    validateName(""),
    validateAge(-5)
).ap(User::new);

if (userValidation.isValid()) {
    User user = userValidation.get();
} else {
    Seq errors = userValidation.getError();
    errors.forEach(System.err::println);
}

// Реализация методов валидации
public Validation validateName(String name) {
    return (name != null && !name.trim().isEmpty())
        ? Validation.valid(name)
        : Validation.invalid("Name cannot be empty");
}

public Validation validateAge(int age) {
    return (age > 0)
        ? Validation.valid(age)
        : Validation.invalid("Age must be positive");
}

Функциональные коллекции

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

List

Неизменяемый список с функциональными методами:

import io.vavr.collection.List;

List fruits = List.of("apple", "banana", "orange");

List uppercaseFruits = fruits.map(String::toUpperCase);

System.out.println(uppercaseFruits); // [APPLE, BANANA, ORANGE]

Stream

Ленивая последовательность, которая может быть бесконечной:

import io.vavr.collection.Stream;

Stream naturalNumbers = Stream.from(1);

Stream evenNumbers = naturalNumbers.filter(n -> n % 2 == 0);

evenNumbers.take(5).forEach(System.out::println); // 2, 4, 6, 8, 10

Map

Неизменяемый ассоциативный массив:

import io.vavr.collection.HashMap;

HashMap wordCounts = HashMap.of("hello", 1, "world", 2);

wordCounts = wordCounts.put("hello", wordCounts.get("hello").get() + 1);

System.out.println(wordCounts); // HashMap((hello, 2), (world, 2))

Set

Неизменяемое множество:

import io.vavr.collection.HashSet;

HashSet colors = HashSet.of("red", "green", "blue");

HashSet moreColors = colors.add("yellow").remove("green");

System.out.println(moreColors); // HashSet(red, blue, yellow)

Примеры использования Vavr

Обработка ошибок с помощью Try и Either

Ситуация: есть метод, который может выбросить исключение, и хочется обработать его без использования try-catch:

import io.vavr.control.Try;

Try fileContent = Try.of(() -> readFile("path/to/file.txt"));

fileContent.onSuccess(content -> System.out.println("File content: " + content))
           .onFailure(ex -> System.err.println("Error reading file: " + ex.getMessage()));

Или с использованием Either для более явной обработки ошибок:

import io.vavr.control.Either;

Either result = readFile("path/to/file.txt");

result.peek(content -> System.out.println("File content: " + content))
      .peekLeft(error -> System.err.println("Error: " + error));

// Реализация метода readFile
public Either readFile(String path) {
    try {
        String content = new String(Files.readAllBytes(Paths.get(path)));
        return Either.right(content);
    } catch (IOException e) {
        return Either.left("Failed to read file: " + e.getMessage());
    }
}

Option для работы с потенциально отсутствующими значениями

Допустим, мы получаем значение из внешнего источника, которое может быть null:

Option maybeEmail = Option.of(getUserEmail());

maybeEmail.filter(email -> email.contains("@"))
          .peek(email -> System.out.println("Valid email: " + email))
          .onEmpty(() -> System.out.println("Invalid or missing email"));

Future для асинхронных вычислений

Допустим, нужно выполнить несколько независимых асинхронных операций и дождаться их результатов:

Future future1 = Future.of(() -> fetchDataFromService1());
Future future2 = Future.of(() -> fetchDataFromService2());

Future> combinedFuture = Future.sequence(List.of(future1, future2));

combinedFuture.onSuccess(results -> {
    String result1 = results.get(0);
    String result2 = results.get(1);
    System.out.println("Results: " + result1 + ", " + result2);
}).onFailure(ex -> System.err.println("Error fetching data: " + ex.getMessage()));

Паттерн-матчинг в Java с Vavr

Паттерн-матчинг позволяет обрабатывать разные варианты данных:

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

Object input = getInput();

String output = Match(input).of(
    Case($(instanceOf(Integer.class).and(i -> (Integer) i > 0)), "Positive integer"),
    Case($(instanceOf(Integer.class).and(i -> (Integer) i < 0)), "Negative integer"),
    Case($(instanceOf(String.class)), str -> "String: " + str),
    Case($(), "Unknown type")
);

System.out.println(output);

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

  • Начните с малого: используйте Option вместо null, Try вместо try-catch.

  • Постепенно вводите функциональные коллекции: заменяйте мутабельные коллекции на неизменяемые аналоги из Vavr.

Доп.ресурсы:

  • Официальная документация Vavr: vavr.io

  • GitHub репозиторий Vavr: github.com/vavr-io/vavr

  • Книга: «Functional Programming in Java» Венката Субраманиама

Всем новичкам в Java рекомендую присоединиться к открытому уроку, на котором участники познакомятся с Java на примере пинг-понга. Игровой проект поможет вам лучше понять связь между написанием кода и результатом его выполнения, даже если вы никогда не программировали. Записаться можно на странице курса.

© Habrahabr.ru