- Introduction
- Pour en savoir plus:
- Itérable pour diffuser en Java
- Comment utiliser la logique if/else dans les flux Java 8
- Lambdas en Java 8
- Interfaces fonctionnelles
- Functions
- Spécialisations de fonctions primitives
- Spécialisations de fonctions à deux arités
- Fournisseurs
- Consommateurs
- Prédicats
- Opérateurs
- Interfaces fonctionnelles héritées
- Conclusion
Introduction
Ce tutoriel est un guide des différentes interfaces fonctionnelles présentes dans Java 8, ainsi que de leurs cas d’utilisation généraux et de leur utilisation dans la bibliothèque JDK standard.
Pour en savoir plus:
Itérable pour diffuser en Java
Comment utiliser la logique if/else dans les flux Java 8
Lambdas en Java 8
Java 8 a apporté une nouvelle amélioration syntaxique puissante sous la forme d’expressions lambda. Un lambda est une fonction anonyme que nous pouvons gérer en tant que citoyen linguistique de première classe. Par exemple, nous pouvons le transmettre ou le renvoyer d’une méthode.
Avant Java 8, nous créions généralement une classe pour chaque cas où nous devions encapsuler une seule fonctionnalité. Cela impliquait beaucoup de code passe-partout inutile pour définir quelque chose qui servait de représentation de fonction primitive.
L’article « Expressions Lambda et Interfaces Fonctionnelles: Conseils et Bonnes Pratiques » décrit plus en détail les interfaces fonctionnelles et les meilleures pratiques de travail avec les lambdas. Ce guide se concentre sur certaines interfaces fonctionnelles particulières présentes dans java.util.ensemble de fonctions.
Interfaces fonctionnelles
Il est recommandé que toutes les interfaces fonctionnelles aient une annotation @FunctionalInterface informative. Cela communique clairement le but de l’interface et permet également à un compilateur de générer une erreur si l’interface annotée ne remplit pas les conditions.
Toute interface avec une méthode 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. Nous pouvons l’observer en regardant la documentation de la fonction.
Functions
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 de fonction, qui est paramétrée par les types de son argument et une valeur de retour:
public interface Function<T, R> { … }
L’une des utilisations du type de fonction dans la bibliothèque standard est la Carte.Méthode computeIfAbsent. Cette méthode 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 la fonction passée :
Map<String, Integer> nameMap = new HashMap<>();Integer value = nameMap.computeIfAbsent("John", s -> s.length());
Dans ce cas, nous calculerons une valeur en appliquant une fonction à une clé, placée dans une carte, et également renvoyée par un appel de méthode. Nous pouvons remplacer le lambda par une référence de méthode qui correspond aux types de valeurs passés et renvoyés.
Rappelez-vous qu’un objet sur lequel nous invoquons la méthode est en fait le premier argument implicite d’une méthode. Cela nous permet de lancer une référence de longueur de méthode d’instance vers une interface de fonction:
Integer value = nameMap.computeIfAbsent("John", String::length);
L’interface de fonction a également une méthode de composition par défaut qui nous permet de combiner plusieurs fonctions en une seule et de les exécuter séquentiellement:
Function<Integer, String> intToString = Object::toString;Function<String, String> quote = s -> "'" + s + "'";Function<Integer, String> 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.
Spécialisations de fonctions primitives
Comme un type primitif ne peut pas être un argument de type générique, il existe des versions de l’interface de fonction 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: arguments are of specified type, return type is parameterized
- ToIntFunction, ToLongFunction, ToDoubleFunction: return type is of specified type, arguments are parameterized
- DoubleToIntFunction, DoubleToLongFunction, IntToDoubleFunction, IntToLongFunction, LongToIntFunction, LongToDoubleFunction: ayant à la fois le type d’argument et le type de retour définis comme des types primitifs, comme spécifié par leurs noms
À titre d’exemple, il n’y a pas d’interface fonctionnelle prête à l’emploi pour une fonction qui prend un court et renvoie un octet, mais rien ne nous empêche d’écrire le nôtre:
@FunctionalInterfacepublic interface ShortToByteFunction { byte applyAsByte(short s);}
Maintenant, nous pouvons écrire une méthode qui transforme un tableau de court en un tableau d’octets en utilisant une règle définie par une fonction ShortToByteFunction:
public byte transformArray(short array, ShortToByteFunction function) { byte transformedArray = new byte; for (int i = 0; i < array.length; i++) { transformedArray = function.applyAsByte(array); } 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);
Spécialisations de fonctions à deux arités
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éralisé, tandis que ToDoubleBiFunction et d’autres nous permettent de renvoyer une valeur primitive.
Un des exemples typiques d’utilisation de cette interface dans l’API standard se trouve dans la carte.Méthode replaceAll, qui permet de remplacer toutes les valeurs d’une carte par une valeur calculée.
Utilisons une implémentation de bifonctions qui reçoit une clé et une ancienne valeur pour calculer une nouvelle valeur pour le salaire et la renvoyer.
Map<String, Integer> 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);
Fournisseurs
L’interface fonctionnelle du fournisseur est une autre spécialisation de fonction qui ne prend aucun argument. Nous l’utilisons généralement pour la génération paresseuse de valeurs. Par exemple, définissons une fonction qui place une valeur double. Il ne recevra pas de valeur elle-même, mais un fournisseur de cette valeur:
public double squareLazy(Supplier<Double> lazyValue) { return Math.pow(lazyValue.get(), 2);}
Cela nous permet de générer paresseusement l’argument d’invocation de cette fonction à l’aide d’une implémentation Fournisseur. Cela peut être utile si la génération de l’argument prend beaucoup de temps. Nous allons simuler cela en utilisant la méthode sleepUninterruptibly de Guava:
Supplier<Double> lazyValue = () -> { Uninterruptibles.sleepUninterruptibly(1000, TimeUnit.MILLISECONDS); return 9d;};Double valueSquared = squareLazy(lazyValue);
Un autre cas d’utilisation pour le fournisseur est la définition de la logique pour la génération de séquences. Pour le démontrer, utilisons un flux statique.méthode generate pour créer un flux de nombres de Fibonacci:
int fibs = {0, 1};Stream<Integer> fibonacci = Stream.generate(() -> { int result = fibs; int fib3 = fibs + fibs; fibs = fibs; fibs = fib3; return result;});
La fonction que nous passons au flux.la méthode generate implémente l’interface fonctionnelle du fournisseur. Notez que pour être utile en tant que générateur, le fournisseur a généralement besoin d’une sorte d’état externe. Dans ce cas, son état comprend les deux derniers numéros de séquence de Fibonacci.
Pour implémenter cet état, nous utilisons un tableau au lieu de quelques variables car toutes les variables externes utilisées dans le lambda doivent être effectivement finales.
D’autres spécialisations de l’interface fonctionnelle Fournisseur incluent BooleanSupplier, DoubleSupplier, LongSupplier et IntSupplier, dont les types de retour sont des primitives correspondantes.
Consommateurs
Contrairement au Fournisseur, le Consommateur accepte un argument généralisé 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 est passé à la liste.La méthode forEach implémente l’interface fonctionnelle du consommateur :
List<String> names = Arrays.asList("John", "Freddy", "Samuel");names.forEach(name -> System.out.println("Hello, " + name));
Il existe également des versions spécialisées du consommateur – DoubleConsumer, IntConsumer et LongConsumer – qui reçoivent des valeurs primitives comme arguments. Plus intéressant est l’interface Biconsommateur. Un de ses cas d’utilisation consiste à parcourir les entrées d’une carte:
Map<String, Integer> 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 biconsommatrices spécialisées est composé de ObjDoubleConsumer, ObjIntConsumer et ObjLongConsumer, qui reçoivent deux arguments; l’un des arguments est généralisé et l’autre est un type primitif.
Prédicats
En logique mathématique, un prédicat est une fonction qui reçoit une valeur et renvoie une valeur booléenne.
L’interface fonctionnelle du prédicat est une spécialisation d’une fonction qui reçoit une valeur générifiée et renvoie un booléen. Un cas d’utilisation typique du prédicat lambda consiste à filtrer une collection de valeurs:
List<String> names = Arrays.asList("Angela", "Aaron", "Bob", "Claire", "David");List<String> namesWithA = names.stream() .filter(name -> name.startsWith("A")) .collect(Collectors.toList());
Dans le code ci-dessus, nous filtrons une liste à l’aide de l’API Stream et ne conservons que les noms commençant par la lettre « A”. L’implémentation de prédicat encapsule la logique de filtrage.
Comme dans tous les exemples précédents, il existe des versions IntPredicate, DoublePredicate et LongPredicate de cette fonction qui reçoivent des valeurs primitives.
Opérateurs
Les interfaces opérateurs sont des cas particuliers d’une fonction qui reçoit et renvoie le même type de valeur. L’interface UnaryOperator reçoit un seul argument. 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<String> names = Arrays.asList("bob", "josh", "megan");names.replaceAll(name -> name.toUpperCase());
La liste.La fonction replaceAll renvoie void car elle remplace les valeurs en place. Pour répondre à l’objectif, le lambda utilisé pour transformer les valeurs d’une liste doit renvoyer le même type de résultat qu’il reçoit. C’est pourquoi l’opérateur unaire est utile ici.
Bien sûr, au lieu de name ->name.toUpperCase(), on peut simplement utiliser une référence de méthode:
names.replaceAll(String::toUpperCase);
L’un des cas d’utilisation les plus intéressants d’un opérateur binaire est une opération de réduction. Supposons que nous souhaitions agréger une collection d’entiers dans une somme de toutes les valeurs. Avec l’API Stream, nous pourrions le faire en utilisant un collecteur, mais une façon plus générique de le faire serait d’utiliser la méthode reduce:
List<Integer> values = Arrays.asList(3, 5, 8, 9, 12);int sum = values.stream() .reduce(0, (i1, i2) -> i1 + i2);
La méthode reduce reçoit une valeur d’accumulateur initiale et une fonction d’opérateur binaire. Les arguments de cette fonction sont une paire de valeurs du même type; la fonction elle-même contient également une logique pour les joindre en une seule valeur du même type. La fonction passée doit être associative, ce qui signifie que l’ordre d’agrégation des valeurs n’a pas d’importance, c’est-à-dire que la condition suivante doit tenir:
op.apply(a, op.apply(b, c)) == op.apply(op.apply(a, b), c)
La propriété associative d’une fonction d’opérateur BinaryOperator nous permet de paralléliser facilement le processus de réduction.
Bien sûr, il existe également des spécialisations de UnaryOperator et BinaryOperator qui peuvent être utilisées avec des valeurs primitives, à savoir DoubleUnaryOperator, IntUnaryOperator, LongUnaryOperator, DoubleBinaryOperator, IntBinaryOperator et LongBinaryOperator.
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’une interface fonctionnelle, et nous pouvons les utiliser comme lambdas. Des exemples importants incluent les interfaces exécutables et appelables utilisées dans les API de concurrence. En Java 8, ces interfaces sont également marquées d’une annotation @FunctionalInterface. Cela nous permet de simplifier considérablement le code de concurrence :
Thread thread = new Thread(() -> System.out.println("Hello From Another Thread"));thread.start();