null

Функциональные интерфейсы в Java: Supplier, Consumer, Predicate и Function. Для чего они нужны и как их применять на практике?

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

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

Java предоставляет около 30-ти (+/-) встроенных функциональных интерфейсов. В этой статье мы рассмотрим четыре наиболее часто используемые: Поставщик (Supplier), Потребитель (Consumer), Предикат (Predicate) и Функция (Function).

Лямбда-выражения (Lambda expressions) - это способ определения экземпляра функционального интерфейса с помощью краткого синтаксиса. Чтобы создать лямбда-выражение, вы сначала указываете аргументы, которые принимает абстрактный метод функционального интерфейса, затем символ стрелки ->, а затем тело метода. Тело может быть одним выражением или блоком кода, заключенным в фигурные скобки.

Ссылки на методы (Method references) - это еще один способ инстанцирования функциональных интерфейсов. Они позволяют ссылаться на существующий метод, который соответствует сигнатуре функционального интерфейса. Это полезно, когда вы хотите повторно использовать существующий метод, который уже имеет желаемое поведение.

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

Вот несколько преимуществ использования функциональных интерфейсов:

  • Краткий код: Функциональные интерфейсы позволяют создавать более сжатый код, делая его при этом читаемым и понятным.
  • Компонуемый код: Функциональные интерфейсы могут быть объединены вместе для создания разнообразных и комплексных моделей поведения. Например, можно объединить предикат и функцию чтобы добиться более сложного/гибкого поведения фильтрации значений или объектов.
  • Улучшенная производительность: Функциональные интерфейсы могут использоваться для написания более производительного кода, поскольку они могут быть оптимизированы компилятором Java и JIT-компилятором.
  • Гибкость: Функциональные интерфейсы позволяют писать код, который является более гибким и адаптируемым к изменяющимся требованиям. Например, можно заменить один функциональный интерфейс другим без изменения остального кода.
  • Параллельная обработка: Функциональные интерфейсы могут использоваться для написания кода, который может выполняться параллельно, что может повысить производительность в многопроцессорных системах.

И так, начнем рассматривать функциональные интерфейсы с интерфейса под названием "Поставщик".

Интерфейс "Поставщик" (Supplier Interface)

Этот интерфейс не принимает никаких входных аргументов, он просто возвращает значение, основанное на определенном типе. Это похоже на функцию без параметров, с возвращаемым типом.

Рассмотрим пример:

public class SupplierInterface {
    Supplier<String>  stringSupplier = () -> "Hello world!"; //Используется лямбда-выражение
    Supplier<Integer> integerSupplier = () -> 777; //Используется лямбда-выражение
    Supplier<Double>  doubleSupplier = () -> Math.random(); //Используется лямбда-выражение
    Supplier<Double>  doubleSupplierUsingMethodReference = Math::random; //Используется ссылка на метод
    
    @Test
    public void supplierTest() {
        //Вызов методов функционального интерфейса
        System.out.println(stringSupplier.get());
        System.out.println(integerSupplier.get());
        System.out.println(doubleSupplier.get());
        System.out.println(doubleSupplierUsingMethodReference.get());
    }
}

Результат выполнения этого кода:

Hello world!
777
0.4569124246255986
0.7057874541029637

Первый оператор System.out.println() выводит строку "Hello world!", которая возвращается методом get() интерфейса stringSupplier. Второй оператор выводит целочисленное значение 777, которое возвращается методом integerSupplier. Третий оператор выводит случайное значение типа double от 0.0 до 1.0, которое возвращается методом doubleSupplier. Четвертый оператор также выводит случайное значение типа double от 0.0 до 1.0, но возвращается с использованием ссылки на метод Math.random().

Интерфейс "Потребитель" (Consumer Interface)

Интерфейс "Потребитель" принимает входные данные, но не возвращает значение. Это как функция с параметром, без возвращаемого типа. Существует разновидность этого интерфейса - интерфейс BiConsumer, который принимает два входных параметра (отсюда и Bi в названии) и так же ничего не возвращает. С помощью метода andThen можно объединять несколько "Потребителей" в цепочку. При выполнении цепочки сначала будет выполнен первый потребитель, а затем второй. Таким образом, поток выполнения будет перемещаться по цепочке слева направо.

Рассмотрим пример:

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class ConsumerInterface {

    Consumer<String> upperCaseConsumer = (text) -> System.out.println(text.toUpperCase());
    Consumer<String> lowerCaseConsumer = (text) -> System.out.println(text.toLowerCase());
    Consumer<Double> logOfTenConsumer  = (number) -> System.out.println(Math.log10(number));

    BiConsumer<Integer, Integer> powConsumer = (base, power) -> System.out.println(Math.pow(base, power));

    @BeforeEach
    public void setup(TestInfo testInfo) {
        System.out.println("Test name: " + testInfo.getDisplayName());
        System.out.println();
    }

    @Order(1)
    @Test
    public void consumerTest() {
        upperCaseConsumer.accept("Hello world!");
        lowerCaseConsumer.accept("Hello world again!");
        logOfTenConsumer.accept(1000.00);
    }
 
    @Order(2)
    @Test
    public void biConsumerTest() {
        powConsumer.accept(4,3);
    }

    @Order(3)
    @Test
    public void consumersChainTest() {
        upperCaseConsumer
            .andThen(lowerCaseConsumer)
            .accept("Hello world for the last time!");
    }
}

Результат выполнения этого кода:

Первый тестовый метод consumerTest() вызывает три экземпляра функционального интерфейса Consumer с разными аргументами. Первый Consumer печатает аргумент в верхнем регистре, второй - в нижнем, а третий печатает результат вычисления десятичного логарифма от 1000:

Test name: consumerTest

HELLO WORLD!
hello world again!
3.0

Второй тестовый метод biConsumerTest() вызывает экземпляр функционального интерфейса BiConsumer, который выводит результат возведения числа 4 в 3-ю степень:

Test name: biConsumerTest

64.0

Наконец, третий метод тестирования consumersChainTest() демонстрирует, что будет если связать в цепочку несколько экземпляров Consumer с помощью метода andThen.

Одна и та же строка будет напечатана сначала в верхнем, а затем в нижнем регистрах:

Test name: consumerChainTest

HELLO WORLD FOR THE LAST TIME!
hello world for the last time!

 

Интефейс "Предикат" (Predicate Interface)

Предикат принимает один входной аргумент и возвращает булево значение true или false. Это как функция с параметром, которая возвращает исключительно boolean.

По аналогии с BiConsumer, интерфейс BiPredicate принимает два параметра и возвращает булево значение.

Рассмотрим работу этого интерфейса на примере:

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class PredicateInterface {

    String  someText = "Hello world!";
    Predicate<String> containsPredicate = (text) -> someText.contains(text);
    BiPredicate<String, String> containsBiPredicate   = (text, pattern) -> text.contains(pattern);
    BiPredicate<String, String> containsBiPredicateUsingMethodReference = String::contains; 
    
    @BeforeEach
    public void setup(TestInfo testInfo) {
        System.out.println("Test name: " + testInfo.getDisplayName());
        System.out.println();
    }

    @Order(1)
    @Test
    public void predicateTest() {
        boolean result = containsPredicate.test("world"); //проверяем что текст, хранящийся в переменной someText содержит слово "world"
        boolean resultOfNegate = containsPredicate.negate().test("world"); //negate - это обратная операция, то есть в данном случае - "не содержит".
        boolean andResult = containsBiPredicate.and(containsBiPredicate.negate()).test("world", "world"); //Логическое И.
        boolean orResult = containsBiPredicate.or(containsBiPredicate.negate()).test("world", "world"); //Логическое ИЛИ
        System.out.println(result);
        System.out.println(resultOfNegate);
        System.out.println(andResult);
        System.out.println(orResult);
    }

    @Order(2)
    @Test
    public void predicateListTest() {
        List<Predicate<String>> predicateList = new ArrayList<>();
        predicateList.add(containsPredicate);
        predicateList.add(containsPredicate.negate());
        predicateList
            .forEach(predicate -> System.out.println(predicate.test("world")));
    }
    @Order(3)
    @Test
    public void biPredicateTest() {
        boolean result = containsBiPredicate.test("Hello world again!", "world");
        System.out.println(result);
    }
}

Результат выполнения этого кода:

В методе predicateTest() предикат containsPredicate используется для проверки того, содержит ли в себе строка "Hello world!" слово "world". Результат этого теста верен. Затем метод negate() используется для создания нового предиката, который проверяет обратное утверждение,- слово "world" не содержится в исходной строке. Результат этого теста является ложным.

Метод and() используется для создания нового предиката, который проверяет, содержит ли И одновременно не содержит ли строка "world" в себе слово "world". Это логически невозможно, поэтому результат теста является ложным. Метод or() используется для создания нового предиката, который проверяет, содержит ИЛИ не содержит ли строка "world" в себе слово "world". В данном случае первое условие выполняется (содержит), поэтому результат теста верен.

Test name: predicateTest

true
false
false
true

В методе predicateListTest() создается список из двух предикатов: containsPredicate и его версии с отрицанием. Затем метод forEach используется для проверки того, является ли каждый из этих предикатов истинным для строки "world". Первый предикат равен true (так как текст "Hello world!" в someText содержит в себе "world" ), а второй предикат равен false:

Test name: predicateListTest

true
false

В методе biPredicateTest() экземпляр интерфейса containsBiPredicate используется для проверки того, содержит ли строка "Hello world again!" слово "world". Результат этого теста верен.

Test name: biPredicateTest

true

 

Интерфейс "Функция" (Function Interface)

Интерфейс Function принимает входные данные и возвращает определенный тип, подобно функции с параметрами и возвращаемым типом. Первый параметр в объявлении интерфейса определяет тип входного аргумента, а второй - тип возвращаемого значения. Также существуют интерфейсы BiFunction, который принимает два входных параметра и UnaryOperator, который принимает и возвращает один и тот же тип.

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

По традиции рассмотрим пример :)

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class FunctionInterface {
    //При объявлении Функции сначала указывается тип входного аргумента, затем тип возвращаемого значения
    Function<String, String>  toUpperCase = (text) -> text.toUpperCase(); //Принимает String, возвращает тоже String
    Function<String, String>  toLowerCase = (text) -> text.toLowerCase();
    Function<Integer, Double> log10 = (number) -> Math.log10(number); //Принимает Integer, возвращает Double
    //То же самое, но вместо лямбда-функций применяются ссылки на методы
    Function<String, String>  toUpperCaseUsingMethodReference = String::toUpperCase;
    Function<String, String>  toLowerCaseUsingMethodReference = String::toLowerCase;
    Function<Integer, Double> log10UsingMethodReference = Math::log10;
    //Пример с BiFunction (Тип 1-го входного аргумента, Тип 2-го входного аргумента, Тип возвращаемого значения)
    BiFunction<Integer, Integer, Integer> powerOf = (base, power) -> (int) Math.pow(base, power);
    //Пример с UnaryOperator (Тип входного аргумента и возвращаемого значения - одинаковые)
    UnaryOperator<String> appendText = (text) -> "I am appending: " + text;
  
    @BeforeEach
    public void setup(TestInfo testInfo) {
        System.out.println("Test name: " + testInfo.getDisplayName());
        System.out.println();
    }
 
    @Order(1)
    @Test
    public void functionTest() {
        String upperCaseResult = toUpperCase.apply("Hello world!");
        Double log10Result = log10.apply(5000);
        System.out.println(upperCaseResult);
        System.out.println(log10Result);
    }

    @Order(2)
    @Test
    public void functionChainWithAndThen() {
        String chainResult1 = toUpperCase.andThen(toLowerCase).apply("heLLo WorLD!");
        String chainResult2 = toLowerCase.andThen(toUpperCase).apply("heLLo WorLD secOND tiME!");
        System.out.println(chainResult1);
        System.out.println(chainResult2);
    }

    @Order(3)
    @Test
    public void functionChainWithCompose() {
        String chainResult1 = toUpperCase.compose(toLowerCase).apply("heLLo WorLD!");
        String chainResult2 = toLowerCase.compose(toUpperCase).apply("heLLo WorLD secOND tiME!");
        System.out.println(chainResult1);
        System.out.println(chainResult2);
    }

    @Order(4)
    @Test
    public void biFunctionTest() {
        int result = powerOf.apply(5, 5);
        System.out.println("5-ть в 5-ой степени равно: " + result);
    }

    @Order(5)
    @Test
    public void unaryOperatorTest(){
        System.out.println(appendText.apply("Hello world with Unary Operator!"));
    }
}

Результат выполнения:

1) Метод functionTest():

Test name: functionTest

 "HELLO WORLD!" 
 
 3.698970004336019.

2) Во время выполнения тестового метода functionChainWithAndThen() два экземпляра функции (toUpperCase и toLowerCase) объединяются с помощью метода andThen(), и результирующая функция применяется к входным значениям. Результатом будет строка "hello world!" и строка "HELLO WORLD SECOND TIME!".

3) В тестовом методе functionChainWithCompose() два экземпляра Function (toUpperCase и toLowerCase) объединяются с помощью метода compose(), и полученная функция применяется к входным значениям. Метод compose() похож на andThen(), но он применяет вначале вторую функцию, а затем первую. Поэтому результатом будет строка "HELLO WORLD!" и строка "hello world second time!".

4) Результат выполнения метода biFunctionTest():

Test name: biFunctionTest

5-ть в 5-ой степени равно: 3125

5) Результат выполнения метода unaryOperatorTest():

Test name: unaryOperatorTest

I am appending: Hello world with Unary Operator!

 

Функциональные интерфейсы Java являются мощным инструментом, позволяющим разработчикам писать производительный и компонуемый код. Мы рассмотрели четыре наиболее популярных функциональных интерфейса: Поставщик (Supplier), Потребитель (Consumer), Предикат (Predicate) и Функция (Function).

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