Interfaces fonctionnelles dans Java 8

1. Introduction

Cet article est un guide des différentes interfaces fonctionnelles présentes dans Java 8, leurs cas d'utilisation généraux et leur utilisation dans la bibliothèque JDK standard.

2. Lambdas dans Java 8

Java 8 a apporté une nouvelle amélioration syntaxique puissante sous la forme d'expressions lambda. Un lambda est une fonction anonyme qui peut être gérée en tant que citoyen de première classe, par exemple transmise ou renvoyée par une méthode.

Avant Java 8, vous créiez généralement une classe pour chaque cas où vous deviez encapsuler une seule fonctionnalité. Cela impliquait beaucoup de code standard inutile pour définir quelque chose qui servait de représentation de fonction primitive.

Les lambdas, les interfaces fonctionnelles et les meilleures pratiques de travail avec elles, en général, sont décrites dans l'article «Expressions Lambda et interfaces fonctionnelles: conseils et bonnes pratiques». Ce guide se concentre sur certaines interfaces fonctionnelles particulières présentes dans le package java.util.function .

3. Interfaces fonctionnelles

Toutes les interfaces fonctionnelles sont recommandées pour avoir une annotation informative @FunctionalInterface . Cela communique non seulement clairement le but de cette interface, mais permet également à un compilateur de générer une erreur si l'interface annotée ne remplit pas les conditions.

Toute interface avec une SAM (Single Abstract Method) est une interface fonctionnelle , et son implémentation peut être traitée comme des expressions lambda.

Notez que les méthodes par défaut de Java 8 ne sont pas abstraites et ne comptent pas: une interface fonctionnelle peut toujours avoir plusieurs méthodes par défaut . Vous pouvez observer cela en consultant la documentation de Function .

4. Fonctions

Le cas le plus simple et le plus général d'un lambda est une interface fonctionnelle avec une méthode qui reçoit une valeur et en renvoie une autre. Cette fonction d'un seul argument est représentée par l' interface Function qui est paramétrée par les types de son argument et une valeur de retour:

public interface Function { … }

L'une des utilisations du type Function dans la bibliothèque standard est la méthode Map.computeIfAbsent qui renvoie une valeur d'une carte par clé mais calcule une valeur si une clé n'est pas déjà présente dans une carte. Pour calculer une valeur, il utilise l'implémentation Function passée:

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

Une valeur, dans ce cas, sera calculée en appliquant une fonction à une clé, placée à l'intérieur d'une carte et également renvoyée par un appel de méthode. En passant, nous pouvons remplacer le lambda par une référence de méthode qui correspond aux types de valeur passés et retournés .

Rappelez-vous qu'un objet sur lequel la méthode est invoquée est, en fait, le premier argument implicite d'une méthode, ce qui permet de convertir une référence de longueur de méthode d'instance vers une interface Function :

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

L' interface Function a également une méthode de composition par défaut qui permet de combiner plusieurs fonctions en une seule et de les exécuter séquentiellement:

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

La fonction quoteIntToString est une combinaison de la fonction quote appliquée à un résultat de la fonction intToString .

5. Spécialisations des fonctions primitives

Puisqu'un type primitif ne peut pas être un argument de type générique, il existe des versions de l' interface Function pour les types primitifs les plus utilisés double , int , long et leurs combinaisons dans les types d'argument et de retour:

  • IntFunction , LongFunction , DoubleFunction: les arguments sont du type spécifié, le type de retour est paramétré
  • ToIntFunction , ToLongFunction , ToDoubleFunction: le type de retour est du type spécifié, les arguments sont paramétrés
  • DoubleToIntFunction , DoubleToLongFunction , IntToDoubleFunction , IntToLongFunction , LongToIntFunction , LongToDoubleFunction - ayant à la fois l'argument et le type de retour définis comme types primitifs, comme spécifié par leurs noms

Il n'y a pas d'interface fonctionnelle prête à l'emploi pour, par exemple, une fonction qui prend un court et renvoie un octet , mais rien ne vous empêche d'écrire la vôtre:

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

Nous pouvons maintenant écrire une méthode qui transforme un tableau de short en un tableau d' octets en utilisant une règle définie par un 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; }

Voici comment nous pourrions l'utiliser pour transformer un tableau de courts-circuits en tableau d'octets multipliés par 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. Spécialisations des fonctions à deux zones

Pour définir des lambdas avec deux arguments, nous devons utiliser des interfaces supplémentaires qui contiennent le mot-clé « Bi» dans leurs noms: BiFunction , ToDoubleBiFunction , ToIntBiFunction et ToLongBiFunction .

BiFunction a à la fois des arguments et un type de retour générés, tandis que ToDoubleBiFunction et d'autres vous permettent de renvoyer une valeur primitive.

L'un des exemples typiques d'utilisation de cette interface dans l'API standard est la méthode Map.replaceAll , qui permet de remplacer toutes les valeurs d'une carte par une valeur calculée.

Utilisons une implémentation BiFunction qui reçoit une clé et une ancienne valeur pour calculer une nouvelle valeur pour le salaire et la renvoyer.

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

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

Toutes les interfaces fonctionnelles ne sont pas apparues dans Java 8. De nombreuses interfaces des versions précédentes de Java sont conformes aux contraintes d'une FunctionalInterface et peuvent être utilisées comme lambdas. Les interfaces Runnable et Callable utilisées dans les API d'accès concurrentiel en sont un bon exemple. Dans Java 8, ces interfaces sont également marquées d'une annotation @FunctionalInterface . Cela nous permet de simplifier considérablement le code d'accès concurrentiel:

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

12. Conclusion

Dans cet article, nous avons décrit différentes interfaces fonctionnelles présentes dans l'API Java 8 qui peuvent être utilisées comme expressions lambda. Le code source de l'article est disponible à l'adresse over sur GitHub.