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

Функциональные интерфейсы в Java 8

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

1. Введение

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

2. Лямбды в Java 8

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

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

В статье «Лямбда-выражения и функциональные интерфейсы: советы и лучшие практики» более подробно описаны функциональные интерфейсы и лучшие практики работы с лямбда-выражениями. В этом руководстве основное внимание уделяется некоторым конкретным функциональным интерфейсам, присутствующим в пакете java.util.function .

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

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

Любой интерфейс с SAM (Single Abstract Method) является функциональным интерфейсом , и его реализация может рассматриваться как лямбда-выражение.

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

4. Функции

Самый простой и общий случай лямбды — это функциональный интерфейс с методом, который получает одно значение и возвращает другое. Эта функция с одним аргументом представлена интерфейсом Function , который параметризуется типами своего аргумента и возвращаемым значением:

public interface Function<T, R> {}

Одним из вариантов использования типа Function в стандартной библиотеке является метод Map.computeIfAbsent . Этот метод возвращает значение из карты по ключу, но вычисляет значение, если ключ еще не присутствует в карте. Для вычисления значения используется переданная реализация функции:

Map<String, Integer> nameMap = new HashMap<>();
Integer value = nameMap.computeIfAbsent("John", s -> s.length());

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

Помните, что объект, для которого мы вызываем метод, фактически является неявным первым аргументом метода. Это позволяет нам привести ссылку длины метода экземпляра к интерфейсу Function :

Integer value = nameMap.computeIfAbsent("John", String::length);

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

Function<Integer, String> intToString = Object::toString;
Function<String, String> quote = s -> "'" + s + "'";

Function<Integer, String> quoteIntToString = quote.compose(intToString);

assertEquals("'5'", quoteIntToString.apply(5));

Функция quoteIntToString представляет собой комбинацию функции цитаты , применяемой к результату функции intToString .

5. Специализации примитивных функций

Поскольку примитивный тип не может быть аргументом универсального типа, существуют версии интерфейса Function для наиболее часто используемых примитивных типов double , int , long и их комбинаций в типах аргументов и возвращаемых значений:

  • IntFunction , LongFunction , DoubleFunction: аргументы имеют указанный тип, тип возвращаемого значения параметризован
  • ToIntFunction , ToLongFunction , ToDoubleFunction: возвращаемый тип имеет заданный тип, аргументы параметризуются
  • DoubleToIntFunction , DoubleToLongFunction , IntToDoubleFunction , IntToLongFunction , LongToIntFunction , LongToDoubleFunction: с типом аргумента и возвращаемого значения, определенными как примитивные типы, как указано их именами

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

@FunctionalInterface
public interface ShortToByteFunction {

byte applyAsByte(short s);

}

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

public byte[] transformArray(short[] array, ShortToByteFunction function) {
byte[] transformedArray = new byte[array.length];
for (int i = 0; i < array.length; i++) {
transformedArray[i] = function.applyAsByte(array[i]);
}
return transformedArray;
}

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

short[] array = {(short) 1, (short) 2, (short) 3};
byte[] transformedArray = transformArray(array, s -> (byte) (s * 2));

byte[] expectedArray = {(byte) 2, (byte) 4, (byte) 6};
assertArrayEquals(expectedArray, transformedArray);

6. Специализации двухкомпонентных функций

Чтобы определить лямбды с двумя аргументами, мы должны использовать дополнительные интерфейсы, которые содержат ключевое слово « Bi» в своих именах: BiFunction , ToDoubleBiFunction , ToIntBiFunction и ToLongBiFunction .

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

Одним из типичных примеров использования этого интерфейса в стандартном API является метод Map.replaceAll , который позволяет заменить все значения на карте некоторым вычисляемым значением.

Давайте воспользуемся реализацией BiFunction , которая получает ключ и старое значение, чтобы вычислить новое значение зарплаты и вернуть его.

Map<String, Integer> salaries = new HashMap<>();
salaries.put("John", 40000);
salaries.put("Freddy", 30000);
salaries.put("Samuel", 50000);

salaries.replaceAll((name, oldValue) ->
name.equals("Freddy") ? oldValue : oldValue + 10000);

7. Поставщики

Функциональный интерфейс Поставщика — это еще одна специализация Функции , которая не принимает никаких аргументов. Обычно мы используем его для ленивой генерации значений. Например, давайте определим функцию, которая возводит в квадрат двойное значение. Он получит не само значение, а Поставщик этого значения:

public double squareLazy(Supplier<Double> lazyValue) {
return Math.pow(lazyValue.get(), 2);
}

Это позволяет нам лениво генерировать аргумент для вызова этой функции, используя реализацию Supplier . Это может быть полезно, если генерация аргумента занимает значительное время. Мы смоделируем это с помощью метода sleepUninterruptably из Guava :

Supplier<Double> lazyValue = () -> {
Uninterruptibles.sleepUninterruptibly(1000, TimeUnit.MILLISECONDS);
return 9d;
};

Double valueSquared = squareLazy(lazyValue);

Другой вариант использования поставщика — определение логики генерации последовательности. Чтобы продемонстрировать это, давайте воспользуемся статическим методом Stream.generate для создания потока чисел Фибоначчи:

int[] fibs = {0, 1};
Stream<Integer> fibonacci = Stream.generate(() -> {
int result = fibs[1];
int fib3 = fibs[0] + fibs[1];
fibs[0] = fibs[1];
fibs[1] = fib3;
return result;
});

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

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

К другим специализациям функционального интерфейса Supplier относятся BooleanSupplier , DoubleSupplier , LongSupplier и IntSupplier , возвращаемые типы которых являются соответствующими примитивами.

8. Потребители

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

Например, давайте поприветствуем всех в списке имен, напечатав приветствие в консоли. Лямбда, переданная методу List.forEach , реализует функциональный интерфейс Consumer :

List<String> names = Arrays.asList("John", "Freddy", "Samuel");
names.forEach(name -> System.out.println("Hello, " + name));

Существуют также специализированные версии ConsumerDoubleConsumer , IntConsumer и LongConsumer — которые получают примитивные значения в качестве аргументов. Более интересен интерфейс BiConsumer . Один из вариантов его использования — перебор записей карты:

Map<String, Integer> ages = new HashMap<>();
ages.put("John", 25);
ages.put("Freddy", 24);
ages.put("Samuel", 30);

ages.forEach((name, age) -> System.out.println(name + " is " + age + " years old"));

Другой набор специализированных версий BiConsumer состоит из ObjDoubleConsumer , ObjIntConsumer и ObjLongConsumer, которые получают два аргумента; один из аргументов является обобщенным, а другой является примитивным типом.

9. Предикаты

В математической логике предикат — это функция, которая получает значение и возвращает логическое значение.

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

List<String> names = Arrays.asList("Angela", "Aaron", "Bob", "Claire", "David");

List<String> namesWithA = names.stream()
.filter(name -> name.startsWith("A"))
.collect(Collectors.toList());

В приведенном выше коде мы фильтруем список с помощью Stream API и сохраняем только имена, начинающиеся с буквы «А». Реализация Predicate инкапсулирует логику фильтрации.

Как и во всех предыдущих примерах, существуют версии этой функции IntPredicate , DoublePredicate и LongPredicate , которые получают примитивные значения.

10. Операторы

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

List<String> names = Arrays.asList("bob", "josh", "megan");

names.replaceAll(name -> name.toUpperCase());

Функция List.replaceAll возвращает значение void , поскольку она заменяет значения на месте. Чтобы соответствовать цели, лямбда, используемая для преобразования значений списка, должна возвращать тот же тип результата, который она получает. Вот почему здесь полезен UnaryOperator .

Конечно, вместо name -> name.toUpperCase() мы можем просто использовать ссылку на метод:

names.replaceAll(String::toUpperCase);

Одним из наиболее интересных вариантов использования BinaryOperator является операция сокращения. Предположим, мы хотим агрегировать набор целых чисел в виде суммы всех значений. С Stream API мы могли бы сделать это с помощью коллектора , но более общий способ сделать это — использовать метод сокращения :

List<Integer> values = Arrays.asList(3, 5, 8, 9, 12);

int sum = values.stream()
.reduce(0, (i1, i2) -> i1 + i2);

Метод сокращения получает начальное значение аккумулятора и функцию BinaryOperator . Аргументы этой функции представляют собой пару значений одного типа; сама функция также содержит логику для их объединения в одно значение того же типа. Передаваемая функция должна быть ассоциативной , а это значит, что порядок агрегации значения не имеет значения, т.е. должно выполняться следующее условие:

op.apply(a, op.apply(b, c)) == op.apply(op.apply(a, b), c)

Ассоциативность операторной функции BinaryOperator позволяет легко распараллелить процесс редукции.

Конечно, существуют также специализации UnaryOperator и BinaryOperator , которые можно использовать с примитивными значениями, а именно DoubleUnaryOperator , IntUnaryOperator , LongUnaryOperator , DoubleBinaryOperator , IntBinaryOperator и LongBinaryOperator .

11. Устаревшие функциональные интерфейсы

Не все функциональные интерфейсы появились в Java 8. Многие интерфейсы из предыдущих версий Java соответствуют ограничениям FunctionalInterface , и мы можем использовать их как лямбда-выражения. Яркими примерами являются интерфейсы Runnable и Callable , которые используются в параллельных API. В Java 8 эти интерфейсы также отмечены аннотацией @FunctionalInterface . Это позволяет нам значительно упростить код параллелизма:

Thread thread = new Thread(() -> System.out.println("Hello From Another Thread"));
thread.start();

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

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