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

1. Введение

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

2. Лямбды в Java 8

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

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

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

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

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

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

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

4. Функции

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

public interface Function { … }

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

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

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

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

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

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

Function intToString = Object::toString; Function quote = s -> "'" + s + "'"; Function 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); }

Теперь мы можем написать метод, который преобразует массив short в массив байтов, используя правило, определенное функцией 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 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. Поставщики

The Supplier functional interface is yet another Function specialization that does not take any arguments. It is typically used for lazy generation of values. For instance, let's define a function that squares a double value. It will receive not a value itself, but a Supplier of this value:

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

This allows us to lazily generate the argument for invocation of this function using a Supplier implementation. This can be useful if the generation of this argument takes a considerable amount of time. We'll simulate that using Guava's sleepUninterruptibly method:

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

Another use case for the Supplier is defining a logic for sequence generation. To demonstrate it, let’s use a static Stream.generate method to create a Stream of Fibonacci numbers:

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

The function that is passed to the Stream.generate method implements the Supplier functional interface. Notice that to be useful as a generator, the Supplier usually needs some sort of external state. In this case, its state is comprised of two last Fibonacci sequence numbers.

To implement this state, we use an array instead of a couple of variables, because all external variables used inside the lambda have to be effectively final.

Other specializations of Supplier functional interface include BooleanSupplier, DoubleSupplier, LongSupplier and IntSupplier, whose return types are corresponding primitives.

8. Consumers

As opposed to the Supplier, the Consumer accepts a generified argument and returns nothing. It is a function that is representing side effects.

For instance, let’s greet everybody in a list of names by printing the greeting in the console. The lambda passed to the List.forEach method implements the Consumer functional interface:

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

There are also specialized versions of the ConsumerDoubleConsumer, IntConsumer and LongConsumer — that receive primitive values as arguments. More interesting is the BiConsumer interface. One of its use cases is iterating through the entries of a map:

Map 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"));

Another set of specialized BiConsumer versions is comprised of ObjDoubleConsumer, ObjIntConsumer, and ObjLongConsumer which receive two arguments one of which is generified, and another is a primitive type.

9. Predicates

In mathematical logic, a predicate is a function that receives a value and returns a boolean value.

The Predicate functional interface is a specialization of a Function that receives a generified value and returns a boolean. A typical use case of the Predicate lambda is to filter a collection of values:

List names = Arrays.asList("Angela", "Aaron", "Bob", "Claire", "David"); List namesWithA = names.stream() .filter(name -> name.startsWith("A")) .collect(Collectors.toList());

In the code above we filter a list using the Stream API and keep only names that start with the letter “A”. The filtering logic is encapsulated in the Predicate implementation.

As in all previous examples, there are IntPredicate, DoublePredicate and LongPredicate versions of this function that receive primitive values.

10. Operators

Operator interfaces are special cases of a function that receive and return the same value type. The UnaryOperator interface receives a single argument. One of its use cases in the Collections API is to replace all values in a list with some computed values of the same type:

List names = Arrays.asList("bob", "josh", "megan"); names.replaceAll(name -> name.toUpperCase());

The List.replaceAll function returns void, as it replaces the values in place. To fit the purpose, the lambda used to transform the values of a list has to return the same result type as it receives. This is why the UnaryOperator is useful here.

Of course, instead of name -> name.toUpperCase(), you can simply use a method reference:

names.replaceAll(String::toUpperCase);

One of the most interesting use cases of a BinaryOperator is a reduction operation. Suppose we want to aggregate a collection of integers in a sum of all values. With Stream API, we could do this using a collector, but a more generic way to do it would be to use the reduce method:

List values = Arrays.asList(3, 5, 8, 9, 12); int sum = values.stream() .reduce(0, (i1, i2) -> i1 + i2); 

The reduce method receives an initial accumulator value and a BinaryOperator function. The arguments of this function are a pair of values of the same type, and a function itself contains a logic for joining them in a single value of the same type. Passed function must be associative, which means that the order of value aggregation does not matter, i.e. the following condition should hold:

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

The associative property of a BinaryOperator operator function allows to easily parallelize the reduction process.

Of course, there are also specializations of UnaryOperator and BinaryOperator that can be used with primitive values, namely DoubleUnaryOperator, IntUnaryOperator, LongUnaryOperator, DoubleBinaryOperator, IntBinaryOperator, and LongBinaryOperator.

11. Legacy Functional Interfaces

Не все функциональные интерфейсы появились в 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.