Funktionsschnittstellen in Java 8

1. Einleitung

Dieser Artikel ist eine Anleitung zu verschiedenen in Java 8 vorhandenen Funktionsschnittstellen, ihren allgemeinen Anwendungsfällen und der Verwendung in der Standard-JDK-Bibliothek.

2. Lambdas in Java 8

Java 8 brachte eine mächtige neue syntaktische Verbesserung in Form von Lambda-Ausdrücken. Ein Lambda ist eine anonyme Funktion, die als erstklassiger Sprachbürger behandelt werden kann, beispielsweise an eine Methode übergeben oder von dieser zurückgegeben wird.

Vor Java 8 haben Sie normalerweise eine Klasse für jeden Fall erstellt, in dem Sie eine einzelne Funktionalität kapseln mussten. Dies implizierte eine Menge unnötigen Boilerplate-Codes, um etwas zu definieren, das als primitive Funktionsdarstellung diente.

Lambdas, funktionale Schnittstellen und Best Practices für die Arbeit mit ihnen im Allgemeinen werden im Artikel „Lambda-Ausdrücke und funktionale Schnittstellen: Tipps und Best Practices“ beschrieben. Dieses Handbuch konzentriert sich auf einige bestimmte Funktionsschnittstellen, die im Paket java.util.function enthalten sind.

3. Funktionale Schnittstellen

Allen funktionalen Schnittstellen wird empfohlen, eine informative @ FunctionalInterface- Annotation zu haben. Dies kommuniziert nicht nur klar den Zweck dieser Schnittstelle, sondern ermöglicht es einem Compiler auch, einen Fehler zu generieren, wenn die mit Anmerkungen versehene Schnittstelle die Bedingungen nicht erfüllt.

Jede Schnittstelle mit einer SAM (Single Abstract Method) ist eine funktionale Schnittstelle , und ihre Implementierung kann als Lambda-Ausdruck behandelt werden.

Beachten Sie, dass die Standardmethoden von Java 8 nicht abstrakt sind und nicht zählen: Eine funktionale Schnittstelle verfügt möglicherweise immer noch über mehrere Standardmethoden . Sie können dies anhand der Dokumentation der Funktion beobachten .

4. Funktionen

Der einfachste und allgemeinste Fall eines Lambda ist eine funktionale Schnittstelle mit einer Methode, die einen Wert empfängt und einen anderen zurückgibt. Diese Funktion eines einzelnen Arguments wird durch die Funktionsschnittstelle dargestellt , die durch die Typen ihres Arguments und einen Rückgabewert parametrisiert wird:

public interface Function { … }

Eine der Verwendungen des Funktionstyps in der Standardbibliothek ist die Map.computeIfAbsent- Methode, die einen Wert von einer Karte nach Schlüssel zurückgibt, aber einen Wert berechnet, wenn ein Schlüssel nicht bereits in einer Karte vorhanden ist. Um einen Wert zu berechnen, wird die übergebene Funktionsimplementierung verwendet:

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

In diesem Fall wird ein Wert berechnet, indem eine Funktion auf einen Schlüssel angewendet, in eine Karte eingefügt und auch von einem Methodenaufruf zurückgegeben wird. Übrigens können wir das Lambda durch eine Methodenreferenz ersetzen, die den übergebenen und zurückgegebenen Werttypen entspricht .

Denken Sie daran, dass ein Objekt, für das die Methode aufgerufen wird, tatsächlich das implizite erste Argument einer Methode ist, mit dem eine Längenreferenz für eine Instanzmethode in eine Funktionsschnittstelle umgewandelt werden kann:

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

Die Funktionsschnittstelle verfügt außerdem über eine Standard- Compose- Methode, mit der mehrere Funktionen zu einer kombiniert und nacheinander ausgeführt werden können:

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

Die Funktion quoteIntToString ist eine Kombination der Anführungszeichenfunktion , die auf ein Ergebnis der Funktion intToString angewendet wird.

5. Primitive Funktionsspezialisierungen

Da ein primitiver Typ kein generisches Typargument sein kann, gibt es Versionen der Funktionsschnittstelle für die am häufigsten verwendeten primitiven Typen double , int , long und deren Kombinationen in Argument- und Rückgabetypen:

  • IntFunction , LongFunction , DoubleFunction: Argumente sind vom angegebenen Typ, der Rückgabetyp ist parametrisiert
  • ToIntFunction , ToLongFunction , ToDoubleFunction: Der Rückgabetyp ist vom angegebenen Typ, Argumente werden parametrisiert
  • DoubleToIntFunction , DoubleToLongFunction , IntToDoubleFunction , IntToLongFunction , LongToIntFunction , LongToDoubleFunction - wobei sowohl Argument als auch Rückgabetyp als primitive Typen definiert sind, wie durch ihre Namen angegeben

Es gibt keine sofort einsatzbereite Funktionsschnittstelle für beispielsweise eine Funktion, die einen Kurzschluss benötigt und ein Byte zurückgibt , aber nichts hindert Sie daran, Ihre eigene zu schreiben:

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

Jetzt können wir eine Methode schreiben, die ein Array von Short in ein Array von Bytes umwandelt, indem wir eine Regel verwenden, die durch eine ShortToByteFunction definiert ist :

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

Hier ist, wie wir es verwenden könnten, um ein Array von Kurzschlüssen in ein Array von Bytes multipliziert mit 2 zu transformieren:

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. Spezialisierungen für Zwei-Arien-Funktionen

Um Lambdas mit zwei Argumenten zu definieren, müssen wir zusätzliche Schnittstellen verwenden, deren Namen das Schlüsselwort „ Bi“ enthalten : BiFunction , ToDoubleBiFunction , ToIntBiFunction und ToLongBiFunction .

In BiFunction werden sowohl Argumente als auch ein Rückgabetyp generiert, während mit ToDoubleBiFunction und anderen ein primitiver Wert zurückgegeben werden kann.

Eines der typischen Beispiele für die Verwendung dieser Schnittstelle in der Standard-API ist die Map.replaceAll- Methode, mit der alle Werte in einer Map durch einen berechneten Wert ersetzt werden können.

Verwenden wir eine BiFunction- Implementierung, die einen Schlüssel und einen alten Wert erhält, um einen neuen Wert für das Gehalt zu berechnen und zurückzugeben.

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

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

Nicht alle funktionalen Schnittstellen wurden in Java 8 angezeigt. Viele Schnittstellen aus früheren Java-Versionen entsprechen den Einschränkungen eines FunctionalInterface und können als Lambdas verwendet werden. Ein prominentes Beispiel sind die Runnable- und Callable- Schnittstellen, die in Parallelitäts-APIs verwendet werden. In Java 8 sind diese Schnittstellen auch mit einer @ FunctionalInterface- Annotation gekennzeichnet. Dies ermöglicht es uns, den Parallelitätscode erheblich zu vereinfachen:

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

12. Schlussfolgerung

In diesem Artikel haben wir verschiedene funktionale Schnittstellen beschrieben, die in der Java 8-API vorhanden sind und als Lambda-Ausdrücke verwendet werden können. Der Quellcode für den Artikel ist auf GitHub verfügbar.