1. Въведение
Тази статия е ръководство за различни функционални интерфейси, присъстващи в Java 8, техните общи случаи на употреба и използване в стандартната библиотека JDK.
2. Ламбди в Java 8
Java 8 донесе мощно ново синтактично подобрение под формата на ламбда изрази. Ламбда е анонимна функция, която може да се обработва като първокласен гражданин на език, например предадена на или върната от метод.
Преди Java 8 обикновено създавате клас за всеки случай, в който трябва да капсулирате единична функционалност. Това предполагаше много ненужен шаблон, за да се определи нещо, което служи като примитивно представяне на функцията.
Lambdas, функционалните интерфейси и най-добрите практики за работа с тях, като цяло, са описани в статията „Lambda Expressions and Functional Interfaces: Tips and Best Practices“. Това ръководство се фокусира върху някои конкретни функционални интерфейси, които присъстват в пакета 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); }
Това ни позволява да генерираме лениво аргумента за извикване на тази функция, като използваме изпълнение на доставчик . Това може да бъде полезно, ако генерирането на този аргумент отнема значително време. Ще симулираме това, като използваме метода sleepUninterruptibly на Guava :
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 Consumer — DoubleConsumer, 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. Заключение
В тази статия описахме различни функционални интерфейси, присъстващи в Java 8 API, които могат да се използват като ламбда изрази. Изходният код на статията е достъпен в GitHub.