Interfejsy funkcjonalne w Javie 8

1. Wstęp

Ten artykuł jest przewodnikiem po różnych interfejsach funkcjonalnych obecnych w Javie 8, ich ogólnych przypadkach użycia i zastosowaniu w standardowej bibliotece JDK.

2. Lambdy w Javie 8

Java 8 przyniosła nowe, potężne ulepszenie składni w postaci wyrażeń lambda. Lambda to anonimowa funkcja, która może być obsługiwana jako obywatel języka pierwszej klasy, na przykład przekazywana lub zwracana z metody.

Przed Java 8 zwykle tworzyło się klasę dla każdego przypadku, w którym potrzebna byłaby hermetyzacja pojedynczego elementu funkcjonalności. To implikowało wiele niepotrzebnego standardowego kodu do zdefiniowania czegoś, co służyło jako prymitywna reprezentacja funkcji.

Lambdy, interfejsy funkcjonalne i ogólnie dobre praktyki pracy z nimi są opisane w artykule „Wyrażenia lambda i interfejsy funkcjonalne: porady i najlepsze praktyki”. Ten przewodnik skupia się na niektórych konkretnych interfejsach funkcjonalnych, które są obecne w pakiecie java.util.function .

3. Interfejsy funkcjonalne

Zaleca się, aby wszystkie interfejsy funkcjonalne miały informacyjną adnotację @FunctionalInterface . To nie tylko jasno określa cel tego interfejsu, ale także pozwala kompilatorowi wygenerować błąd, jeśli interfejs z adnotacjami nie spełnia warunków.

Każdy interfejs z SAM (Single Abstract Method) jest interfejsem funkcjonalnym , a jego implementacja może być traktowana jako wyrażenia lambda.

Zauważ, że domyślne metody Java 8 nie są abstrakcyjne i nie liczą się: interfejs funkcjonalny może nadal mieć wiele metod domyślnych . Możesz to zaobserwować, przeglądając dokumentację funkcji .

4. Funkcje

Najprostszym i najbardziej ogólnym przypadkiem lambdy jest interfejs funkcjonalny z metodą, która przyjmuje jedną wartość i zwraca inną. Ta funkcja pojedynczego argumentu jest reprezentowana przez interfejs funkcji , który jest sparametryzowany przez typy argumentu i wartość zwracaną:

public interface Function { … }

Jednym z zastosowań typu Function w bibliotece standardowej jest metoda Map.computeIfAbsent , która zwraca wartość z mapy według klucza, ale oblicza wartość, jeśli klucz nie jest już obecny w mapie. Aby obliczyć wartość, używa przekazanej implementacji funkcji:

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

W tym przypadku wartość zostanie obliczona przez zastosowanie funkcji do klawisza, umieszczona w mapie, a także zwrócona z wywołania metody. Nawiasem mówiąc, możemy zastąpić lambdę referencją do metody, która pasuje do przekazanych i zwróconych typów wartości .

Pamiętaj, że obiekt, na którym wywoływana jest metoda, jest w rzeczywistości niejawnym pierwszym argumentem metody, który umożliwia rzutowanie odwołania do długości metody instancji na interfejs funkcji :

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

Funkcja interfejs ma również domyślny tworzenia, metodę, która pozwala na połączenie kilku funkcji w jednym i wykonywać je kolejno:

Function intToString = Object::toString; Function quote = s -> "'" + s + "'"; Function quoteIntToString = quote.compose(intToString); assertEquals("'5'", quoteIntToString.apply(5));

Funkcja quoteIntToString to połączenie funkcji quote zastosowanej do wyniku funkcji intToString .

5. Specjalizacje funkcji pierwotnych

Ponieważ typ pierwotny nie może być argumentem typu ogólnego, istnieją wersje interfejsu funkcji dla najczęściej używanych typów pierwotnych double , int , long oraz ich kombinacje w typach argumentowych i zwracanych:

  • IntFunction , LongFunction , DoubleFunction: argumenty są określonego typu, typ zwracany jest sparametryzowany
  • ToIntFunction , ToLongFunction , ToDoubleFunction: typ zwracany jest określonego typu, argumenty są sparametryzowane
  • DoubleToIntFunction , DoubleToLongFunction , IntToDoubleFunction , IntToLongFunction , LongToIntFunction , LongToDoubleFunction - posiadające zarówno argument, jak i typ zwracany zdefiniowane jako typy pierwotne, zgodnie z ich nazwami

Nie ma gotowego interfejsu funkcjonalnego dla, powiedzmy, funkcji, która pobiera skrót i zwraca bajt , ale nic nie stoi na przeszkodzie , aby napisać własny:

@FunctionalInterface public interface ShortToByteFunction { byte applyAsByte(short s); }

Teraz możemy napisać metodę, która przekształci tablicę short na tablicę bajtów przy użyciu reguły zdefiniowanej przez 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; }

Oto jak możemy go użyć do przekształcenia tablicy shortów na tablicę bajtów pomnożoną przez 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. Specjalizacje funkcji dwóch Arity

Aby zdefiniować lambdy z dwoma argumentami, musimy użyć dodatkowych interfejsów, które w swoich nazwach zawierają słowo kluczowe „ Bi” : BiFunction , ToDoubleBiFunction , ToIntBiFunction i ToLongBiFunction .

BiFunction ma generowane zarówno argumenty, jak i typ zwracania, podczas gdy ToDoubleBiFunction i inne umożliwiają zwrócenie wartości pierwotnej.

Jednym z typowych przykładów wykorzystania tego interfejsu w standardowym API jest metoda Map.replaceAll , która umożliwia zastąpienie wszystkich wartości w mapie jakąś wartością obliczoną.

Użyjmy BiFunction realizację że otrzymuje klucz i starą wartość, aby obliczyć nową wartość wynagrodzenia i zwrot.

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. Dostawcy

Dostawca interfejs funkcjonalny to kolejna funkcja specjalizacja że nie podejmie żadnych argumentów. Zwykle jest używany do leniwego generowania wartości. Na przykład zdefiniujmy funkcję, która podnosi do kwadratu podwójną wartość. Otrzyma nie samą wartość, ale Dostawcę o tej wartości:

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

To pozwala nam leniwie generować argument do wywołania tej funkcji przy użyciu implementacji dostawcy . Może to być przydatne, jeśli generowanie tego argumentu zajmuje dużo czasu. Zasymulujemy to za pomocą metody snu Guawy nieprzerwanie :

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

Innym przypadkiem użycia dostawcy jest zdefiniowanie logiki generowania sekwencji. Aby to zademonstrować, użyjmy statycznej metody Stream.generate , aby utworzyć strumień liczb Fibonacciego:

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; });

Funkcja przekazana do metody Stream.generate implementuje interfejs funkcjonalny dostawcy . Zauważ, że aby być użytecznym jako generator, Dostawca zwykle potrzebuje jakiegoś stanu zewnętrznego. W tym przypadku jego stan składa się z dwóch ostatnich numerów sekwencji Fibonacciego.

Aby zaimplementować ten stan, używamy tablicy zamiast kilku zmiennych, ponieważ wszystkie zewnętrzne zmienne użyte wewnątrz lambdy muszą być efektywnie ostateczne .

Inne specjalizacje funkcjonalnego interfejsu dostawcy obejmują BooleanSupplier , DoubleSupplier , LongSupplier i IntSupplier , których typy zwracane są odpowiadającymi im prymitywami.

8. Konsumenci

W przeciwieństwie do Dostawcę The Consumer akceptuje generified argument i zwraca niczego. Jest to funkcja reprezentująca skutki uboczne.

Na przykład powitajmy wszystkich na liście nazwisk, drukując powitanie w konsoli. Lambda przekazana do metody List.forEach implementuje interfejs funkcjonalny Consumer :

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

Nie wszystkie interfejsy funkcjonalne pojawiły się w Javie 8. Wiele interfejsów z poprzednich wersji Java jest zgodnych z ograniczeniami FunctionalInterface i może być używanych jako lambdy. Wybitnym przykładem są interfejsy Runnable i Callable , które są używane w interfejsach API współbieżności. W Javie 8 te interfejsy są również oznaczone adnotacją @FunctionalInterface . To pozwala nam znacznie uprościć kod współbieżności:

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

12. Wniosek

W tym artykule opisaliśmy różne interfejsy funkcjonalne obecne w Java 8 API, których można używać jako wyrażeń lambda. Kod źródłowy artykułu jest dostępny na GitHub.