Il serait peu pratique d’écrire votre propre interface fonctionnelle chaque fois que vous souhaitez utiliser une lambda. Heureusement, un grand nombre d’interfaces fonctionnelles à usage général sont fournies pour vous. Nous les couvrons dans cette section.
Les interfaces fonctionnelles de base du Tableau 8.4 sont fournies dans le package java.util.function
. Nous aborderons les génériques dans le chapitre suivant, mais pour l’instant, vous devez simplement savoir que <T>
permet à l’interface de prendre un objet d’un type spécifié. Si un second paramètre de type est nécessaire, nous utilisons la lettre suivante, U
. Si un type de retour distinct est nécessaire, nous choisissons R
pour return comme type générique.
Interface fonctionnelle | Type de retour | Nom de méthode | Nombre de paramètres |
---|---|---|---|
Supplier<T> | T | get() | 0 |
Consumer<T> | void | accept(T) | 1 (T) |
BiConsumer<T, U> | void | accept(T,U) | 2 (T, U) |
Predicate<T> | boolean | test(T) | 1 (T) |
BiPredicate<T, U> | boolean | test(T,U) | 2 (T, U) |
Function<T, R> | R | apply(T) | 1 (T) |
BiFunction<T, U, R> | R | apply(T,U) | 2 (T, U) |
UnaryOperator<T> | T | apply(T) | 1 (T) |
BinaryOperator<T> | T | apply(T,T) | 2 (T, T) |
Vous apprendrez d’autres interfaces fonctionnelles plus tard dans ce livre.
Dans le chapitre suivant, nous couvrirons Comparator. Dans le chapitre sur la concurrence, nous discuterons de Runnable et Callable.
Examinons comment implémenter chacune de ces interfaces. Puisque les lambdas et les références de méthodes apparaissent partout, nous montrerons une implémentation utilisant les deux lorsque c’est possible. Après avoir présenté les interfaces, nous couvrirons également certaines méthodes de commodité disponibles sur ces interfaces.
Implémenter Supplier
Un Supplier est utilisé lorsque vous voulez générer ou fournir des valeurs sans en prendre aucune. L’interface Supplier est définie comme suit :
@FunctionalInterface
public interface Supplier<T> {
T get();
}
Vous pouvez créer un objet LocalDate en utilisant la méthode de fabrique now(). Cet exemple montre comment utiliser un Supplier pour appeler cette fabrique :
Supplier<LocalDate> s1 = LocalDate::now;
Supplier<LocalDate> s2 = () -> LocalDate.now();
LocalDate d1 = s1.get();
LocalDate d2 = s2.get();
System.out.println(d1); // 2022-02-20
System.out.println(d2); // 2022-02-20
Cet exemple imprime une date deux fois. C’est aussi une bonne occasion de revoir les références de méthodes statiques. La référence de méthode LocalDate::now est utilisée pour créer un Supplier à assigner à une variable intermédiaire s1. Un Supplier est souvent utilisé lors de la construction de nouveaux objets. Par exemple, nous pouvons imprimer deux objets StringBuilder vides :
Supplier<StringBuilder> s1 = StringBuilder::new;
Supplier<StringBuilder> s2 = () -> new StringBuilder();
System.out.println(s1.get()); // Chaîne vide
System.out.println(s2.get()); // Chaîne vide
Cette fois, nous avons utilisé une référence de constructeur pour créer l’objet. Nous avons utilisé des génériques pour déclarer quel type de Supplier nous utilisons. Cela peut être un peu long à lire. Pouvez-vous comprendre ce que fait le code suivant ? Procédez simplement étape par étape :
Supplier<ArrayList<String>> s3 = ArrayList::new;
ArrayList<String> a1 = s3.get();
System.out.println(a1); // []
Nous avons un Supplier d’un certain type. Ce type se trouve être ArrayList<String>. Ainsi, appeler get() crée une nouvelle instance d’ArrayList<String>, qui est le type générique du Supplier—en d’autres termes, un générique qui contient un autre générique. Assurez-vous d’examiner attentivement le code quand ce genre de chose se présente.
Remarquez comment nous avons appelé get() sur l’interface fonctionnelle. Que se passerait-il si nous essayions d’imprimer s3 lui-même ?
System.out.println(s3);
Le code imprime quelque chose comme :
interfacefonctionnelle.IntegrationsInternes$Lambda$1/0x0000000800066840@4909b8da
C’est le résultat de l’appel de toString() sur une lambda. Beurk. Cela signifie quelque chose. Notre classe de test s’appelle IntegrationsInternes, et elle se trouve dans un package que nous avons créé nommé interfacefonctionnelle. Ensuite vient $$, ce qui signifie que la classe n’existe pas dans un fichier du système de fichiers. Elle n’existe qu’en mémoire. Vous n’avez pas à vous soucier du reste.
Implémenter Consumer et BiConsumer
Vous utilisez un Consumer lorsque vous voulez faire quelque chose avec un paramètre mais ne rien renvoyer. BiConsumer fait la même chose, sauf qu’il prend deux paramètres. Les interfaces sont définies comme suit :
@FunctionalInterface
public interface Consumer<T> {
void accept(T t);
// méthode par défaut omise
}
@FunctionalInterface
public interface BiConsumer<T, U> {
void accept(T t, U u);
// méthode par défaut omise
}
Vous remarquerez ce modèle. Bi signifie deux. Cela vient du latin, mais vous pouvez vous en souvenir à partir de mots français comme binaire (0 ou 1) ou bicyclette (deux roues). Ajoutez toujours un autre paramètre lorsque vous voyez Bi.
L’impression est une utilisation courante de l’interface Consumer :
Consumer<String> c1 = System.out::println;
Consumer<String> c2 = x -> System.out.println(x);
c1.accept("Marie"); // Marie
c2.accept("Marie"); // Marie
BiConsumer est appelé avec deux paramètres. Ils n’ont pas besoin d’être du même type. Par exemple, nous pouvons mettre une clé et une valeur dans une map en utilisant cette interface :
var map = new HashMap<String, Integer>();
BiConsumer<String, Integer> b1 = map::put;
BiConsumer<String, Integer> b2 = (k, v) -> map.put(k, v);
b1.accept("poulet", 7);
b2.accept("poussin", 1);
System.out.println(map); // {poulet=7, poussin=1}
La sortie est {poulet=7, poussin=1}, ce qui montre que les deux implémentations de BiConsumer ont été appelées. Lors de la déclaration de b1, nous avons utilisé une référence de méthode d’instance sur un objet car nous voulons appeler une méthode sur la variable locale map. Le code pour instancier b1 est un peu plus court que le code pour b2. C’est probablement pourquoi les références de méthodes sont si appréciées.
Dans un autre exemple, nous utilisons le même type pour les deux paramètres génériques :
var map = new HashMap<String, String>();
BiConsumer<String, String> b1 = map::put;
BiConsumer<String, String> b2 = (k, v) -> map.put(k, v);
b1.accept("poulet", "Cot cot");
b2.accept("poussin", "Piou piou");
System.out.println(map); // {poulet=Cot cot, poussin=Piou piou}
Cela montre qu’un BiConsumer peut utiliser le même type pour les paramètres génériques T et U.
Implémenter Predicate et BiPredicate
Predicate est souvent utilisé lors du filtrage ou de la mise en correspondance. Les deux sont des opérations courantes. Un BiPredicate est comme un Predicate, sauf qu’il prend deux paramètres au lieu d’un. Les interfaces sont définies comme suit :
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
// méthodes par défaut et statiques omises
}
@FunctionalInterface
public interface BiPredicate<T, U> {
boolean test(T t, U u);
// méthodes par défaut omises
}
Vous pouvez utiliser un Predicate pour tester une condition.
Predicate<String> p1 = String::isEmpty;
Predicate<String> p2 = x -> x.isEmpty();
System.out.println(p1.test("")); // true
System.out.println(p2.test("")); // true
Cela imprime true deux fois. Plus intéressant est un BiPredicate. Cet exemple imprime également true deux fois :
BiPredicate<String, String> b1 = String::startsWith;
BiPredicate<String, String> b2 =
(chaine, prefixe) -> chaine.startsWith(prefixe);
System.out.println(b1.test("poulet", "pou")); // true
System.out.println(b2.test("poulet", "pou")); // true
La référence de méthode inclut à la fois la variable d’instance et le paramètre pour startsWith(). C’est un bon exemple de la façon dont les références de méthodes économisent beaucoup de frappe. L’inconvénient est qu’elles sont moins explicites, et vous devez vraiment comprendre ce qui se passe !
Implémenter Function et BiFunction
Une Function est responsable de transformer un paramètre en une valeur d’un type potentiellement différent et de la renvoyer. De même, une BiFunction est responsable de transformer deux paramètres en une valeur et de la renvoyer. Les interfaces sont définies comme suit :
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
// méthodes par défaut et statiques omises
}
@FunctionalInterface
public interface BiFunction<T, U, R> {
R apply(T t, U u);
// méthode par défaut omise
}
Par exemple, cette fonction convertit une chaîne en sa longueur :
Function<String, Integer> f1 = String::length;
Function<String, Integer> f2 = x -> x.length();
System.out.println(f1.apply("piou")); // 4
System.out.println(f2.apply("piou")); // 4
Cette fonction transforme une chaîne en un entier. Techniquement, elle transforme la chaîne en un int, qui est autoboxé en un Integer. Les types n’ont pas besoin d’être différents. L’exemple suivant combine deux objets String et produit une autre chaîne :
BiFunction<String, String, String> b1 = String::concat;
BiFunction<String, String, String> b2 =
(chaine, aAjouter) -> chaine.concat(aAjouter);
System.out.println(b1.apply("bébé ", "poussin")); // bébé poussin
System.out.println(b2.apply("bébé ", "poussin")); // bébé poussin
Les deux premiers types dans BiFunction sont les types d’entrée. Le troisième est le type de résultat. Pour la référence de méthode, le premier paramètre est l’instance sur laquelle concat() est appelée, et le second est passé à concat().
Implémenter UnaryOperator et BinaryOperator
UnaryOperator et BinaryOperator sont des cas particuliers de Function. Ils nécessitent que tous les paramètres de type soient du même type. Un UnaryOperator transforme sa valeur en une valeur du même type. Par exemple, l’incrémentation de un est une opération unaire. En fait, UnaryOperator étend Function. Un BinaryOperator fusionne deux valeurs en une du même type. L’addition de deux nombres est une opération binaire. De même, BinaryOperator étend BiFunction. Les interfaces sont définies comme suit :
@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {
// méthode statique omise
}
@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T, T, T> {
// méthodes statiques omises
}
Cela signifie que les signatures de méthodes ressemblent à ceci :
T apply(T t); // UnaryOperator
T apply(T t1, T t2); // BinaryOperator
Dans la Javadoc, vous remarquerez que ces méthodes sont héritées de la superclasse Function/BiFunction. Les déclarations génériques sur la sous-classe sont ce qui force le type à être le même. Pour l’exemple unaire, remarquez comment le type de retour est du même type que le paramètre.
UnaryOperator<String> u1 = String::toUpperCase;
UnaryOperator<String> u2 = x -> x.toUpperCase();
System.out.println(u1.apply("piou")); // PIOU
System.out.println(u2.apply("piou")); // PIOU
Cela imprime PIOU deux fois. Nous n’avons pas besoin de spécifier le type de retour dans les génériques car UnaryOperator exige qu’il soit du même type que le paramètre. Et voici l’exemple binaire :
BinaryOperator<String> b1 = String::concat;
BinaryOperator<String> b2 = (chaine, aAjouter) -> chaine.concat(aAjouter);
System.out.println(b1.apply("bébé ", "poussin")); // bébé poussin
System.out.println(b2.apply("bébé ", "poussin")); // bébé poussin
Notez que cela fait la même chose que l’exemple BiFunction. Le code est plus concis, ce qui montre l’importance d’utiliser la meilleure interface fonctionnelle. C’est agréable d’avoir un type générique spécifié au lieu de trois.
Vérifier les Interfaces Fonctionnelles
Il est vraiment important de connaître le nombre de paramètres, les types, la valeur de retour et le nom de méthode pour chacune des interfaces fonctionnelles. Maintenant serait un bon moment pour mémoriser le Tableau 8.4 si vous ne l’avez pas déjà fait. Faisons quelques exemples pour s’entraîner.
Quelle interface fonctionnelle utiliseriez-vous dans ces trois situations ?
- Renvoie une chaîne sans prendre de paramètres
- Renvoie un booléen et prend une chaîne
- Renvoie un entier et prend deux entiers
Prêt ? Réfléchissez à vos réponses avant de continuer. Vraiment. Vous devez savoir cela parfaitement. D’accord. Le premier est un Supplier<String> car il génère un objet et ne prend aucun paramètre. Le deuxième est un Function<String,Boolean> car il prend un paramètre et renvoie un autre type. C’est un peu délicat. Vous pourriez penser que c’est un Predicate<String>. Notez qu’un Predicate renvoie un boolean primitif et non un objet Boolean.
Enfin, le troisième est soit un BinaryOperator<Integer> soit un BiFunction<Integer,Integer,Integer>. Puisque BinaryOperator est un cas particulier de BiFunction, les deux sont des réponses correctes. BinaryOperator<Integer> est la meilleure réponse des deux car elle est plus spécifique.
Essayons cet exercice à nouveau mais avec du code. C’est plus difficile avec du code. La première chose à faire est de regarder combien de paramètres la lambda prend et s’il y a une valeur de retour. Quelle interface fonctionnelle utiliseriez-vous pour remplir les blancs pour ces lignes ?
_________ <List> ex1 = x -> "".equals(x.get(0));
_________ <Long> ex2 = (Long l) -> System.out.println(l);
_________ <String, String> ex3 = (s1, s2) -> false;
Encore une fois, réfléchissez aux réponses avant de continuer. Prêt ? La ligne 1 passe un paramètre List à la lambda et renvoie un booléen. Cela nous indique que c’est un Predicate ou une Function. Puisque la déclaration générique n’a qu’un seul paramètre, c’est un Predicate.
La ligne 2 passe un paramètre Long à la lambda et ne renvoie rien. Cela nous indique que c’est un Consumer. La ligne 3 prend deux paramètres et renvoie un booléen. Lorsque vous voyez un booléen renvoyé, pensez Predicate à moins que les génériques ne spécifient un type de retour Boolean. Dans ce cas, il y a deux paramètres, donc c’est un BiPredicate.
Trouvez-vous ces exercices faciles ? Sinon, revoyez le Tableau 8.4 à nouveau. Nous ne plaisantons pas. Vous devez très bien connaître ce tableau. Maintenant que vous venez d’étudier le tableau, nous allons jouer à “identifier l’erreur”. Ces exercices sont destinés à être délicats :
Function<List<String>> ex1 = x -> x.get(0); // NE COMPILE PAS
UnaryOperator<Long> ex2 = (Long l) -> 3.14; // NE COMPILE PAS
La ligne 1 prétend être une Function. Une Function doit spécifier deux types génériques : le type de paramètre d’entrée et le type de valeur de retour. Le type de valeur de retour est manquant, ce qui fait que le code ne compile pas. La ligne 2 est un UnaryOperator, qui renvoie le même type que celui qu’il reçoit. L’exemple renvoie un double au lieu d’un Long, ce qui fait que le code ne compile pas.
Utiliser les Méthodes de Commodité sur les Interfaces Fonctionnelles
Par définition, toutes les interfaces fonctionnelles ont une seule méthode abstraite. Cela ne signifie pas qu’elles ne peuvent avoir qu’une seule méthode, cependant. Plusieurs des interfaces fonctionnelles courantes fournissent un certain nombre de méthodes d’interface par défaut utiles.
Le Tableau 8.5 montre les méthodes de commodité sur les interfaces fonctionnelles intégrées que vous devez connaître. Toutes ces méthodes facilitent la modification ou la combinaison d’interfaces fonctionnelles du même type. Notez que le Tableau 8.5 ne montre que les interfaces principales. Les interfaces BiConsumer, BiFunction et BiPredicate ont des méthodes similaires disponibles.
Instance d’interface | Type de retour de méthode | Nom de méthode | Paramètres de méthode |
---|---|---|---|
Consumer | Consumer | andThen() | Consumer |
Function | Function | andThen() | Function |
Function | Function | compose() | Function |
Predicate | Predicate | and() | Predicate |
Predicate | Predicate | negate() | — |
Predicate | Predicate | or() | Predicate |
Commençons par ces deux variables Predicate :
Predicate<String> oeuf = s -> s.contains("oeuf");
Predicate<String> brun = s -> s.contains("brun");
Maintenant, nous voulons un Predicate pour les œufs bruns et un autre pour toutes les autres couleurs d’œufs. Nous pourrions écrire cela à la main, comme montré ici :
Predicate<String> oeufsBruns = s -> s.contains("oeuf") && s.contains("brun");
Predicate<String> autresOeufs = s -> s.contains("oeuf") && !s.contains("brun");
Cela fonctionne, mais ce n’est pas idéal. C’est un peu long à lire, et contient des duplications. Et si nous décidons que la lettre e devrait être majuscule dans “oeuf” ? Nous devrions la changer dans trois variables : oeuf, oeufsBruns et autresOeufs. Une meilleure façon de gérer cette situation est d’utiliser deux des méthodes par défaut sur Predicate.
Predicate<String> oeufsBruns = oeuf.and(brun);
Predicate<String> autresOeufs = oeuf.and(brun.negate());
Parfait ! Maintenant, nous réutilisons la logique des variables Predicate d’origine pour construire deux nouvelles. C’est plus court et plus clair sur la relation entre les variables. Nous pouvons également changer l’orthographe de “oeuf” à un seul endroit, et les deux autres objets auront une nouvelle logique car ils y font référence.
Passons à Consumer, examinons la méthode andThen(), qui exécute deux interfaces fonctionnelles en séquence :
Consumer<String> c1 = x -> System.out.print("1: " + x);
Consumer<String> c2 = x -> System.out.print(",2: " + x);
Consumer<String> combined = c1.andThen(c2);
combined.accept("Tanguy"); // 1: Tanguy,2: Tanguy
Notez comment le même paramètre est passé à la fois à c1 et c2. Cela montre que les instances de Consumer sont exécutées en séquence et sont indépendantes l’une de l’autre. En revanche, la méthode compose() sur Function enchaîne les interfaces fonctionnelles. Cependant, elle passe la sortie de l’une à l’entrée de l’autre.
Function<Integer, Integer> before = x -> x + 1;
Function<Integer, Integer> after = x -> x * 2;
Function<Integer, Integer> combined = after.compose(before);
System.out.println(combined.apply(3)); // 8
Cette fois, before s’exécute en premier, transformant le 3 en 4. Ensuite, after s’exécute, doublant ce nombre à 8. Toutes les méthodes de cette section sont utiles pour simplifier votre code lorsque vous travaillez avec des interfaces fonctionnelles.
Apprendre les Interfaces Fonctionnelles pour les Primitifs
Il existe également un grand nombre d’interfaces fonctionnelles spéciales pour les primitifs. Celles-ci sont utiles lorsque nous couvrons les streams et les optionals.
La plupart d’entre elles sont pour les types double, int et long. Il y a une exception, qui est BooleanSupplier. Nous couvrons cela avant d’introduire les interfaces fonctionnelles pour double, int et long.
Interfaces Fonctionnelles pour boolean
BooleanSupplier est un type séparé. Il a une méthode à implémenter :
@FunctionalInterface
public interface BooleanSupplier {
boolean getAsBoolean();
}
Cela fonctionne exactement comme vous vous y attendriez d’une interface fonctionnelle. Voici un exemple :
BooleanSupplier b1 = () -> true;
BooleanSupplier b2 = () -> Math.random() > .5;
System.out.println(b1.getAsBoolean()); // true
System.out.println(b2.getAsBoolean()); // false
Les lignes 1 et 2 créent chacune un BooleanSupplier, qui est la seule interface fonctionnelle pour boolean. La ligne 3 imprime true, puisque c’est le résultat de b1. La ligne 4 imprime true ou false, selon la valeur aléatoire générée.
Interfaces Fonctionnelles pour double, int, et long
La plupart des interfaces fonctionnelles sont pour double, int, et long. Le Tableau 8.6 montre l’équivalent du Tableau 8.4 pour ces primitifs. Vous pourrez appliquer ce que vous avez appris au Tableau 8.4 à ce nouveau tableau.
Interfaces fonctionnelles | Type de retour | Méthode abstraite unique | Nombre de paramètres |
---|---|---|---|
DoubleSupplier IntSupplier LongSupplier | double int long | getAsDouble getAsInt getAsLong | 0 |
DoubleConsumer IntConsumer LongConsumer | void | accept | 1 (double) 1 (int) 1 (long) |
DoublePredicate IntPredicate LongPredicate | boolean | test | 1 (double) 1 (int) 1 (long) |
DoubleFunction<R> IntFunction<R> LongFunction<R> | R | apply | 1 (double) 1 (int) 1 (long) |
DoubleUnaryOperator IntUnaryOperator LongUnaryOperator | double int long | applyAsDouble applyAsInt applyAsLong | 1 (double) 1 (int) 1 (long) |
DoubleBinaryOperator IntBinaryOperator LongBinaryOperator | double int long | applyAsDouble applyAsInt applyAsLong | 2 (double, double) 2 (int, int) 2 (long, long) |
Il y a quelques différences à noter entre le Tableau 8.4 et le Tableau 8.6 :
- Les génériques disparaissent de certaines interfaces, et à la place le nom du type nous indique quel type primitif est impliqué. Dans d’autres cas, comme IntFunction, seul le générique de type de retour est nécessaire car nous convertissons un int primitif en un objet.
- La méthode abstraite unique est souvent renommée lorsqu’un type primitif est renvoyé.
En plus des équivalents du Tableau 8.4, certaines interfaces sont spécifiques aux primitifs. Le Tableau 8.7 les liste.
Nous utilisons des interfaces fonctionnelles depuis un moment maintenant, donc vous devriez bien comprendre comment lire le tableau. Faisons un exemple pour être sûr. Quelle interface fonctionnelle utiliseriez-vous pour remplir le blanc et faire compiler le code suivant ?
var d = 1.0;
___________________f1 = x -> 1;
f1.applyAsInt(d);
Quand vous voyez une question comme celle-ci, cherchez des indices. Vous pouvez voir que l’interface fonctionnelle en question prend un paramètre double et renvoie un int. Vous pouvez également voir qu’elle a une méthode abstraite unique nommée applyAsInt. Les interfaces fonctionnelles DoubleToIntFunction et ToIntFunction répondent à ces trois critères.