Programmation Java
Lambdas, références de méthode et interfaces fonctionnelles en Java
Intermédiaire9 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Avec les lambdas, Java est devenu un langage où l'on transmet un comportement aussi facilement qu'une donnée. Ce guide explique ce qu'est réellement une lambda — une instance d'interface fonctionnelle —, comment le compilateur en infère le type, les interfaces prédéfinies de java.util.function, les quatre sortes de références de méthode, les règles de capture des variables et la façon dont les lambdas sont compilées. Vous saurez écrire, lire et concevoir des API autour des lambdas en toute confiance.
Ce que vous allez apprendre
- Expliquer comment une expression lambda est typée par rapport à une interface fonctionnelle
- Choisir la bonne interface de java.util.function, spécialisations pour les primitifs comprises
- Passer d'une lambda à l'une des quatre formes de référence de méthode, et inversement
- Appliquer la règle « effectivement final » et comprendre pourquoi une lambda ne peut pas modifier les variables locales capturées
- Composer fonctions, prédicats et comparateurs, et concevoir des méthodes qui acceptent un comportement
Java 8 a introduit les expressions lambda et, avec elles, une nouvelle façon d'écrire du code Java : le comportement est devenu un argument comme un autre. Tris, filtres, politiques de retry, gestionnaires d'événements et corps de threads se sont réduits à une ligne au lieu d'une classe anonyme. La syntaxe est simple ; ce qui déroute, c'est le modèle de typage (une lambda n'a pas de type propre), les règles de capture et le catalogue toujours plus fourni des interfaces de java.util.function. Ce guide couvre ces fondations, sur lesquelles reposent l'API Stream et une bonne partie du Java moderne. Le code cible Java 21.
Ce qu'est une lambda
Une expression lambda est une manière concise de créer une instance d'interface fonctionnelle — une interface qui possède exactement une méthode abstraite. Les paramètres et le corps de la lambda deviennent l'implémentation de cette méthode.
// Before: an anonymous class
Comparator<String> byLength = new Comparator<String>() {
@Override
public int compare(String a, String b) {
return Integer.compare(a.length(), b.length());
}
};
// After: a lambda
Comparator<String> byLength = (a, b) -> Integer.compare(a.length(), b.length());
La forme générale est parameters -> body. Les types des paramètres sont généralement inférés ; un paramètre unique se passe de parenthèses ; un corps réduit à une expression se passe d'accolades et de return ; un corps en bloc exige les deux. Depuis Java 11, var est autorisé pour les types de paramètres, surtout pour pouvoir leur attacher des annotations.
Runnable hello = () -> System.out.println("hi"); // no parameters
Function<String, Integer> len = s -> s.length(); // one parameter, expression body
BiFunction<Integer, Integer, Integer> add = (Integer a, Integer b) -> { // explicit types, block body
int sum = a + b;
return sum;
};
Le typage par la cible
Une lambda n'a pas de type intrinsèque. (a, b) -> a + b pourrait être un BinaryOperator<Integer>, une BiFunction<String, String, String> ou un Comparator<Integer> (un mauvais). Le compilateur détermine le type cible d'après le contexte — la variable affectée, le paramètre de méthode, le type de retour, un cast — et vérifie la lambda par rapport à l'unique méthode abstraite de cette interface. C'est pourquoi on ne peut pas écrire var f = x -> x + 1; (il n'y a pas de cible), et pourquoi des méthodes surchargées acceptant des interfaces fonctionnelles différentes exigent parfois un cast ou des types de paramètres explicites pour lever l'ambiguïté.
Toute interface à méthode abstraite unique convient, y compris les vôtres et les plus anciennes comme Runnable, Callable, Comparator et ActionListener. Annotez les vôtres avec @FunctionalInterface : l'annotation n'est pas obligatoire, mais le compilateur rejettera alors l'ajout d'une seconde méthode abstraite, qui casserait sans bruit toutes les lambdas ciblant l'interface.
Le catalogue java.util.function
Plutôt que de laisser chaque bibliothèque inventer ses propres interfaces à méthode unique, le JDK fournit un jeu standard. Retenez les six formes de base et la convention de nommage, et vous saurez décoder toutes les autres.
| Interface | Méthode | Signification |
|---|---|---|
Supplier<T> | T get() | Produire une valeur, sans entrée |
Consumer<T> | void accept(T t) | Utiliser une valeur, ne rien renvoyer |
Function<T, R> | R apply(T t) | Transformer un T en R |
Predicate<T> | boolean test(T t) | Décision oui/non |
UnaryOperator<T> | T apply(T t) | Fonction de T vers T |
BinaryOperator<T> | T apply(T a, T b) | Combiner deux T en un seul T |
Le préfixe Bi double les entrées (BiFunction<T, U, R>, BiConsumer<T, U>, BiPredicate<T, U>). Des spécialisations pour les types primitifs existent afin d'éviter le boxing : IntPredicate, IntFunction<R> (int en entrée), ToIntFunction<T> (int en sortie), IntUnaryOperator, IntBinaryOperator, IntSupplier, ObjIntConsumer<T>, avec leurs variantes Long et Double. C'est pour cette raison qu'un stream appelle mapToInt(ToIntFunction).
Supplier<List<String>> newList = ArrayList::new;
Consumer<String> print = System.out::println;
Function<String, Integer> parse = Integer::parseInt;
Predicate<String> isBlank = String::isBlank;
UnaryOperator<String> trim = String::strip;
BinaryOperator<Integer> max = Integer::max;
IntBinaryOperator gcd = (a, b) -> { while (b != 0) { int t = b; b = a % b; a = t; } return a; };
Tournez-vous vers ces interfaces avant d'en définir une vous-même. Une interface fonctionnelle maison se justifie lorsque son nom porte un sens métier (PricingRule, RetryPolicy) ou lorsque la méthode doit déclarer une exception checked, ce qu'aucune interface standard ne fait.
Les références de méthode
Une référence de méthode désigne une méthode existante là où une lambda se contenterait de l'appeler. Il en existe quatre formes :
| Forme | Syntaxe | Lambda équivalente |
|---|---|---|
| Méthode statique | Integer::parseInt | s -> Integer.parseInt(s) |
| Méthode d'instance d'un objet précis | System.out::println | x -> System.out.println(x) |
| Méthode d'instance d'un objet quelconque d'un type | String::toUpperCase | s -> s.toUpperCase() |
| Constructeur | ArrayList::new | () -> new ArrayList<>() |
La troisième forme est celle qui surprend au début : String::compareToIgnoreCase utilisé comme Comparator<String> signifie « appeler compareToIgnoreCase sur le premier argument en lui passant le second ». Le premier paramètre devient le receveur.
Les références liées (la deuxième forme) évaluent l'expression du receveur une seule fois, au moment où la référence est créée. this::handle ou logger::info capturent l'objet courant ; si l'objet change ensuite, la référence pointe toujours vers l'ancien.
Les références de méthode ne sont pas toujours plus claires. Person::getName vaut mieux que p -> p.getName(), mais entre (a, b) -> a.compareTo(b) et String::compareTo, c'est une affaire de goût, et une référence vers une méthode surchargée peut empêcher le compilateur de choisir la bonne surcharge. Dans le doute, écrivez la lambda.
Capture de variables et règle « effectivement final »
Le corps d'une lambda peut lire les variables locales, les paramètres, les champs et this de la portée englobante. Les variables locales et les paramètres doivent cependant être effectivement finals : affectés une seule fois, que vous écriviez le mot-clé final ou non. Le compilateur rejette une lambda qui écrit dans une variable locale, et il rejette aussi une lambda qui lit une variable locale modifiée où que ce soit après son initialisation.
int base = 10;
Function<Integer, Integer> addBase = x -> x + base; // fine: base is effectively final
int counter = 0;
list.forEach(item -> counter++); // compile error: counter is modified
Cette règle existe parce que la lambda reçoit une copie de la valeur. La variable locale vit dans la frame de pile de la méthode englobante ; la lambda peut s'exécuter après la disparition de cette frame, ou sur un autre thread. Partager un emplacement mutable obligerait la JVM à déplacer la variable sur le tas et à en synchroniser l'accès ; les concepteurs du langage ont préféré l'interdire plutôt que de masquer ce coût.
Quand vous avez réellement besoin d'accumuler, les options sont, par ordre de préférence : exprimer le calcul sous forme de réduction ou de collector, pour qu'aucun état partagé n'existe ; utiliser un AtomicInteger ou un LongAdder quand la lambda est un callback qui doit compter ; ou, en dernier recours, un tableau à un élément (int[] counter = {0}), qui compile parce que la référence est finale même si son contenu change — et qui devient une data race si la lambda s'exécute de manière concurrente.
Les champs ne sont pas soumis à cette règle. this.counter++ dans une lambda compile, parce que c'est this qui est capturé et que le champ est accédé à travers lui. C'est pratique dans un gestionnaire d'interface graphique monothread, et c'est un bug dans un parallel stream.
this dans une lambda et dans une classe anonyme
Dans une lambda, this désigne l'instance englobante, exactement comme dans la méthode qui l'entoure. Dans une classe anonyme, this est l'objet anonyme lui-même. C'est la raison la plus visible pour laquelle une lambda n'est pas un simple sucre syntaxique pour une classe anonyme : convertir l'une en l'autre peut changer ce que désigne this.name.
La composition
Les interfaces fonctionnelles sont livrées avec des méthodes default permettant de combiner des instances, si bien que de petites briques s'assemblent sans écrire de nouvelles lambdas.
Predicate<String> notBlank = Predicate.not(String::isBlank);
Predicate<String> shortEnough = s -> s.length() <= 80;
Predicate<String> validTitle = notBlank.and(shortEnough);
Function<String, String> normalise = String::strip;
Function<String, String> pipeline = normalise.andThen(String::toLowerCase); // strip, then lower-case
Function<String, Integer> lengthAfter = pipeline.andThen(String::length);
Comparator<Employee> ordering = Comparator.comparing(Employee::department)
.thenComparing(Employee::salary, Comparator.reverseOrder())
.thenComparing(Employee::name, String.CASE_INSENSITIVE_ORDER);
UnaryOperator<String> identity = UnaryOperator.identity();
andThen applique d'abord le receveur, puis l'argument ; compose fait l'inverse. Predicate propose and, or, negate et la méthode statique not. Comparator est la plus riche : comparing, comparingInt, thenComparing, reversed, nullsFirst, nullsLast et naturalOrder. Composer des comparateurs de cette façon remplace les méthodes compare écrites à la main, source inépuisable d'erreurs de signe.
Concevoir des API autour des lambdas
Le vrai bénéfice des lambdas ne se limite pas à des appels plus courts : elles permettent de construire des bibliothèques autour de la paramétrisation du comportement, c'est-à-dire des méthodes qui reçoivent une stratégie en argument plutôt qu'un flag. Comparez une méthode à trois paramètres booléens à une méthode qui prend un Predicate<Order> : la seconde est plus petite, plus générale et testable sans l'appelant.
public final class Retry {
public static <T> T withRetry(int attempts, Predicate<Exception> retryable, Supplier<T> action) {
Exception last = null;
for (int i = 0; i < attempts; i++) {
try {
return action.get();
} catch (Exception e) {
if (!retryable.test(e)) throw e instanceof RuntimeException r ? r : new RuntimeException(e);
last = e;
}
}
throw new IllegalStateException("Gave up after " + attempts + " attempts", last);
}
}
String body = Retry.withRetry(3, e -> e instanceof IOException, () -> client.fetch("/health"));
Quelques principes pour ce type d'API :
- Acceptez l'interface standard la plus générale qui convient :
Supplier<T>plutôt qu'unLoader<T>maison ;Function<? super T, ? extends R>avec des wildcards, comme l'explique le guide sur les génériques, pour que les appelants ne soient pas limités aux types exacts. - Préférez une spécialisation pour les primitifs quand la valeur est un type primitif :
IntPredicateévite le boxing dans une boucle critique. - Faites en sorte que la lambda soit pure autant que possible, et documentez le cas où l'API l'appelle plusieurs fois, de manière paresseuse ou sur un autre thread.
- Envisagez une petite interface maison quand elle rend l'appel plus lisible ou qu'elle doit déclarer une exception checked.
La seule vraie limite concerne les exceptions checked. Un Supplier<T> ne peut pas lever d'IOException ; une lambda qui appelle Files.readString doit donc l'attraper et l'encapsuler. Soit vous déclarez votre propre ThrowingSupplier<T, E extends Exception> pour les API où c'est attendu, soit vous encapsulez dans une UncheckedIOException au point d'appel.
Comment les lambdas sont compilées
Comprendre l'implémentation explique le comportement. Le compilateur transforme le corps d'une lambda en une méthode synthétique privée de la classe englobante et émet une instruction invokedynamic à l'endroit où apparaît la lambda. À la première exécution, la LambdaMetafactory de la JVM génère une petite classe cachée qui implémente l'interface fonctionnelle et y lie le site d'appel. Conséquences :
- Aucun fichier .class supplémentaire dans votre JAR, contrairement aux classes anonymes, qui produisent chacune un
Outer$1.class. - Les lambdas sans capture (aucune référence à des variables locales ni à
this) sont créées une fois et mises en cache : chaque évaluation renvoie la même instance. Les lambdas avec capture allouent une nouvelle instance à chaque évaluation, contenant des copies des valeurs capturées. - L'identité n'est pas spécifiée. Ne comparez jamais des lambdas avec
==et ne les utilisez pas comme clés de map : deux évaluations du même code source peuvent produire, ou non, le même objet. - La sérialisation ne fonctionne que si l'interface cible est
Serializable, et elle est fragile d'une recompilation à l'autre. Évitez-la.
Côté performances, un appel de lambda après le warm-up du JIT coûte à peu près autant qu'un appel d'interface et est généralement inliné. Les surcoûts qui apparaissent dans les profils viennent du boxing (utilisez les interfaces primitives), des allocations dues à la capture dans les boucles critiques et des sites d'appel mégamorphiques, où une même ligne de code dispatche vers de nombreuses lambdas différentes.
Erreurs courantes
Modifier un état capturé. L'astuce de la case de tableau et la modification de champs compilent, mais provoquent des race conditions en exécution parallèle. Utilisez une réduction ou un collector.
Des corps de lambda trop longs. Un bloc de dix lignes dans un pipeline de stream masque la structure du pipeline. Extrayez une méthode nommée et référencez-la.
Des surcharges qui prennent des interfaces fonctionnelles différentes. submit(Runnable) et submit(Callable<T>) sur ExecutorService créent déjà une ambiguïté avec () -> doWork(). Évitez de concevoir de telles surcharges ; si vous y êtes contraint, donnez-leur des noms différents.
Ignorer l'incompatibilité avec les exceptions checked. Si vous enveloppez chaque appel dans un try/catch à l'intérieur de la lambda, c'est le signe qu'il faut introduire une interface qui lève des exceptions ou restructurer le code.
Supposer une exécution immédiate. Passer une lambda à une méthode ne l'exécute pas. Si l'appelé la stocke pour l'appeler plus tard, les objets capturés restent vivants jusque-là, ce qui peut retenir de gros objets en mémoire.
Utiliser Optional.get dans une chaîne de lambdas. Composez avec map/flatMap/orElse ; voir le guide sur Optional.
Des lambdas de comparaison par soustraction. (a, b) -> a.value - b.value déborde pour les grandes valeurs. Utilisez Integer.compare ou Comparator.comparingInt.
Points clés à retenir
- Une lambda est une instance d'interface fonctionnelle ; son type vient du contexte dans lequel elle apparaît, jamais de la lambda elle-même.
java.util.functionfournit les formes standard —Supplier,Consumer,Function,Predicate,UnaryOperator,BinaryOperator— ainsi que les variantesBiet celles pour les primitifs. Utilisez-les avant d'écrire les vôtres.- Les références de méthode existent sous quatre formes ; utilisez-les quand la lambda ne ferait que transmettre ses arguments.
- Les variables locales capturées doivent être effectivement finales parce que la lambda en reçoit une copie ; les champs et
thissont capturés par référence. - Composez les comportements avec
andThen,compose,and,or,negateet les builders deComparatorau lieu d'écrire de nouvelles lambdas. - Les lambdas sont compilées en
invokedynamicplus une méthode synthétique, pas en classes anonymes : pas de fichiers .class, instances sans capture mises en cache,thisdésigne l'objet englobant, et l'identité n'est pas garantie.
Sources et lectures complémentaires
Questions fréquentes
- Qu'est-ce qu'une interface fonctionnelle ?
- Une interface qui possède exactement une méthode abstraite. Les méthodes default et static ne comptent pas, pas plus que les méthodes déjà fournies par Object, comme equals. N'importe quelle interface fonctionnelle peut servir de type cible à une lambda ou à une référence de méthode. L'annotation @FunctionalInterface est facultative, mais elle oblige le compilateur à vérifier la règle de la méthode unique.
- Pourquoi les variables utilisées dans une lambda doivent-elles être effectivement finales ?
- Une lambda capture la valeur d'une variable locale, pas la variable elle-même : la variable locale vit sur la pile et peut avoir disparu quand la lambda s'exécute plus tard, éventuellement sur un autre thread. Autoriser l'écriture ferait diverger la copie et l'original sans le moindre avertissement. Si vous avez besoin d'un état mutable, utilisez un champ, une case de tableau, un AtomicInteger ou, mieux, restructurez le code avec une réduction ou un collector.
- Une lambda n'est-elle qu'une syntaxe abrégée pour une classe interne anonyme ?
- Non. Une lambda est compilée en une méthode privée et un site d'appel invokedynamic qui crée paresseusement l'instance de l'interface fonctionnelle ; aucun fichier .class n'est généré à la compilation, this désigne l'instance englobante, et une lambda sans capture est en général allouée une seule fois puis réutilisée. Une classe anonyme est une classe à part entière, avec son propre this et un nouvel objet à chaque évaluation.
- Quand utiliser une référence de méthode plutôt qu'une lambda ?
- Quand la lambda se contenterait de transmettre ses paramètres à une méthode existante : String::length au lieu de s -> s.length(). Dès que les arguments doivent être adaptés, ou si la référence est ambiguë ou obscure, écrivez la lambda.