Interfaces fonctionnelles en Java 8

Interfaces fonctionnelles sous Java 8

1. introduction

Cet article décrit 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.

Lectures complémentaires:

Itératif pour diffuser en Java

L'article explique comment convertir un Iterable en Stream et pourquoi l'interface Iterable ne le prend pas directement en charge.

Read more

Comment utiliser la logique if / else dans les flux Java 8

Apprenez à appliquer la logique if / else aux flux Java 8.

Read more

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 comme un citoyen de langage de premier ordre, par exemple transmise à une méthode ou renvoyée par celle-ci.

Avant Java 8, vous créiez généralement une classe pour chaque cas dans lequel vous deviez encapsuler une seule fonctionnalité. Cela impliquait beaucoup de code passe-partout inutile pour définir quelque chose servant de représentation de fonction primitive.

Les lambdas, les interfaces fonctionnelles et les meilleures pratiques pour travailler avec elles, en général, sont décrites dans l'article“Lambda Expressions and Functional Interfaces: Tips and Best Practices”. Ce guide se concentre sur certaines interfaces fonctionnelles particulières présentes dans le packagejava.util.function.

3. Interfaces fonctionnelles

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

Any interface with a SAM(Single Abstract Method) is a functional interface et son implémentation peuvent être traités comme des expressions lambda.

Notez que les méthodesdefault de Java 8 ne sont pasabstract et ne comptent pas: une interface fonctionnelle peut toujours avoir plusieurs méthodesdefault. Vous pouvez observer cela en regardant lesFunction’sdocumentation.

4. Les 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 retourne une autre. Cette fonction d'un seul argument est représentée par l'interfaceFunction qui est paramétrée par les types de son argument et une valeur de retour:

public interface Function { … }

L'une des utilisations du typeFunction dans la bibliothèque standard est la méthodeMap.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 de fonction 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 dans une carte et également renvoyée à partir d'un appel de méthode. Au fait,we may replace the lambda with a method reference that matches passed and returned value types.

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 méthode d'instancelength vers une interfaceFunction:

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

L'interfaceFunction a également une méthodecompose par défaut qui permet de combiner plusieurs fonctions en une 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 fonctionquoteIntToString est une combinaison de la fonctionquote appliquée à un résultat de la fonctionintToString.

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'interfaceFunction pour les types primitifs les plus utilisésdouble,int,long et leurs combinaisons dans les types d'argument et de retour:

  • Les argumentsIntFunction,LongFunction,DoubleFunction: sont du type spécifié, le type de retour est paramétré

  • Le type de retourToIntFunction,ToLongFunction,ToDoubleFunction: 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 unshort et renvoie unbyte, 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 deshort en un tableau debyte en utilisant une règle définie par unShortToByteFunction:

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 métrages en un tableau d’octets multiplié 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 les lambdas avec deux arguments, nous devons utiliser des interfaces supplémentaires qui contiennent le mot clé «Bi” dans leurs noms:BiFunction,ToDoubleBiFunction,ToIntBiFunction etToLongBiFunction .

BiFunction a à la fois des arguments et un type de retour générés, tandis queToDoubleBiFunction et 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éthodeMap.replaceAll, qui permet de remplacer toutes les valeurs d'une carte par une valeur calculée.

Utilisons une implémentationBiFunction 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

L'interface fonctionnelle deSupplier est encore une autre spécialisation deFunction qui ne prend aucun argument. Il est généralement utilisé pour la génération de valeurs paresseuses. Par exemple, définissons une fonction qui met au carré une valeur dedouble. Il ne recevra pas une valeur elle-même, mais unSupplier de cette valeur:

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

Cela nous permet de générer paresseusement l'argument pour l'invocation de cette fonction en utilisant une implémentationSupplier. Cela peut être utile si la génération de cet argument prend beaucoup de temps. Nous allons simuler cela en utilisant la méthodesleepUninterruptibly de Guava:

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

Double valueSquared = squareLazy(lazyValue);

Un autre cas d'utilisation pour le fournisseur est la définition d'une logique pour la génération de séquence. Pour le démontrer, utilisons une méthode statiqueStream.generate pour créer unStream de nombres de Fibonacci:

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

La fonction transmise à la méthodeStream.generate implémente l'interface fonctionnelle deSupplier. Notez que pour être utile en tant que générateur, lesSupplier ont généralement besoin d'une sorte d'état externe. Dans ce cas, son état est composé de deux derniers numéros de séquence de Fibonacci.

Pour implémenter cet état, nous utilisons un tableau au lieu de quelques variables, carall external variables used inside the lambda have to be effectively final.

Les autres spécialisations de l'interface fonctionnelle deSupplier incluentBooleanSupplier,DoubleSupplier,LongSupplier etIntSupplier, dont les types de retour sont les primitives correspondantes.

8. Les consommateurs

Contrairement auxSupplier, leConsumer accepte un argument généré et ne renvoie rien. C'est une fonction qui représente les effets secondaires.

Par exemple, saluons tout le monde dans une liste de noms en imprimant le message d'accueil dans la console. Le lambda passé à la méthodeList.forEach implémente l'interface fonctionnelle deConsumer:

List names = Arrays.asList("John", "Freddy", "Samuel");
names.forEach(name -> System.out.println("Hello, " + name));

Il existe également des versions spécialisées desConsumer -DoubleConsumer,IntConsumer etLongConsumer - qui reçoivent des valeurs primitives comme arguments. L'interfaceBiConsumer est plus intéressante. L’un de ses cas d’utilisation consiste à parcourir les entrées d’une carte:

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

Un autre ensemble de versions spécialisées deBiConsumer est composé deObjDoubleConsumer,ObjIntConsumer etObjLongConsumer qui reçoivent deux arguments dont l'un est généré et l'autre est de type primitif.

9. Prédicats

En logique mathématique, un prédicat est une fonction qui reçoit une valeur et retourne une valeur booléenne.

L'interface fonctionnelle dePredicate est une spécialisation d'unFunction qui reçoit une valeur générée et renvoie un booléen. Un cas d'utilisation typique du lambdaPredicate est de filtrer une collection de valeurs:

List names = Arrays.asList("Angela", "Aaron", "Bob", "Claire", "David");

List namesWithA = names.stream()
  .filter(name -> name.startsWith("A"))
  .collect(Collectors.toList());

Dans le code ci-dessus, nous filtrons une liste à l'aide de l'APIStream et ne conservons que les noms commençant par la lettre «A». La logique de filtrage est encapsulée dans l'implémentationPredicate.

Comme dans tous les exemples précédents, il existe des versionsIntPredicate,DoublePredicate etLongPredicate de cette fonction qui reçoivent des valeurs primitives.

10. Les opérateurs

Les interfacesOperator sont des cas particuliers d'une fonction qui reçoit et renvoie le même type de valeur. L'interfaceUnaryOperator reçoit un seul argument. L’un de ses cas d’utilisation dans l’API Collections consiste à remplacer toutes les valeurs d’une liste par des valeurs calculées du même type:

List names = Arrays.asList("bob", "josh", "megan");

names.replaceAll(name -> name.toUpperCase());

La fonctionList.replaceAll renvoievoid, car elle remplace les valeurs en place. Pour répondre à cet objectif, le lambda utilisé pour transformer les valeurs d’une liste doit renvoyer le même type de résultat qu’il le reçoit. C'est pourquoi leUnaryOperator est utile ici.

Bien sûr, au lieu dename → name.toUpperCase(), vous pouvez simplement utiliser une référence de méthode:

names.replaceAll(String::toUpperCase);

L'un des cas d'utilisation les plus intéressants d'unBinaryOperator est une opération de réduction. Supposons que nous voulions agréger une collection d'entiers en une somme de toutes les valeurs. Avec l'APIStream, nous pourrions le faire en utilisant un collecteur,, mais une façon plus générique de le faire serait d'utiliser la méthodereduce:

List values = Arrays.asList(3, 5, 8, 9, 12);

int sum = values.stream()
  .reduce(0, (i1, i2) -> i1 + i2);

La méthodereduce reçoit une valeur initiale d'accumulateur et une fonctionBinaryOperator. Les arguments de cette fonction sont une paire de valeurs du même type, et une fonction elle-même contient une logique pour les associer dans une valeur unique du même type. Passed function must be associative, ce qui signifie que l'ordre d'agrégation des valeurs n'a pas d'importance, c'est-à-dire la condition suivante devrait être remplie:

op.apply(a, op.apply(b, c)) == op.apply(op.apply(a, b), c)

La propriété associative d'une fonction opérateurBinaryOperator permet de paralléliser facilement le processus de réduction.

Bien sûr, il existe également des spécialisations deUnaryOperator etBinaryOperator qui peuvent être utilisées avec des valeurs primitives, à savoirDoubleUnaryOperator,IntUnaryOperator,LongUnaryOperator,DoubleBinaryOperator,IntBinaryOperator etLongBinaryOperator.

11. Interfaces fonctionnelles héritées

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'unFunctionalInterface et peuvent être utilisées comme lambdas. Les interfacesRunnable etCallable utilisées dans les API d'accès concurrent en sont un exemple frappant. Dans Java 8, ces interfaces sont également marquées d'une annotation@FunctionalInterface. Cela nous permet de simplifier grandement le code de concurrence:

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 pouvant être utilisées en tant qu'expressions lambda. Le code source de l'article est disponibleover on GitHub.