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

1. Вступ

Ця стаття - посібник із різних функціональних інтерфейсів, присутніх у Java 8, їх загальних випадків використання та використання у стандартній бібліотеці JDK.

2. Лямбди в Java 8

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

До Java 8 ви зазвичай створювали клас для кожного випадку, коли вам потрібно було інкапсулювати єдину частину функціональності. Це передбачало багато непотрібного типового коду для визначення чогось, що служило примітивним представленням функції.

Лямбди, функціональні інтерфейси та найкращі практики роботи з ними, загалом, описані в статті «Лямбда-вирази та функціональні інтерфейси: поради та найкращі практики». Цей посібник зосереджений на деяких конкретних функціональних інтерфейсах, які є в пакеті java.util.function .

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

На всіх функціональних інтерфейсах рекомендується мати інформативну анотацію @FunctionalInterface . Це не тільки чітко повідомляє про призначення цього інтерфейсу, але також дозволяє компілятору генерувати помилку, якщо анотований інтерфейс не відповідає умовам.

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

Зауважте, що методи Java 8 за замовчуванням не є абстрактними і не враховуються: функціональний інтерфейс все одно може мати кілька методів за замовчуванням . Ви можете спостерігати це, переглянувши документацію до функції .

4. Функції

Найбільш простим і загальним випадком лямбда є функціональний інтерфейс з методом, який отримує одне значення, а повертає інше. Ця функція окремого аргументу представлена інтерфейсом функції, який параметризується типами його аргументу та поверненим значенням:

public interface 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 - це комбінація функції quote, застосована до результату функції intToString .

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

Оскільки примітивний тип не може бути загальним аргументом типу, існують версії інтерфейсу функції для більшості використовуваних примітивних типів 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 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 lazyValue) { return Math.pow(lazyValue.get(), 2); }

Це дозволяє нам ліниво генерувати аргумент для виклику цієї функції за допомогою реалізації постачальника . Це може бути корисно, якщо генерація цього аргументу займає значну кількість часу. Ми будемо імітувати, використовуючи метод Guava's sleepUninterruptibly :

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 відповідають обмеженням Функціонального Інтерфейсу і можуть використовуватися як лямбди. Яскравим прикладом є інтерфейси Runnable та Callable , які використовуються в API одночасності. У Java 8 ці інтерфейси також позначені анотацією @FunctionalInterface . Це дозволяє нам значно спростити паралельний код:

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

12. Висновок

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