W3docs

La programmation fonctionnelle en Java

Aperçu des concepts de programmation fonctionnelle en Java : fonctions de première classe, immuabilité, fonctions pures et composition.

La partie précédente — le Framework Collections — portait sur les conteneurs : les structures de données qui stockent des éléments et les opérations (add, remove, iterate, sort, binarySearch) qui leur sont applicables. Cette partie aborde une couche différente du même problème. Au lieu de s'intéresser à l'endroit où résident les données, nous allons nous concentrer sur la manière d'exprimer les transformations appliquées à ces données — clairement, de façon compositionnelle, sans boucles redondantes ni variables accumulatrices.

Ce changement de paradigme a un nom. La programmation fonctionnelle est le style dans lequel le calcul est exprimé comme l'application de fonctions à des valeurs, où les fonctions elles-mêmes sont des valeurs de première classe, et où les données sont généralement traitées comme immuables. Java n'a pas été conçu comme un langage fonctionnel — les classes, la mutation et les boucles explicites sont au cœur de sa philosophie — mais depuis Java 8, tout programme Java moderne emprunte abondamment à la boîte à outils fonctionnelle. Vous en avez déjà écrit une partie dans la section sur les collections : list.sort(Comparator.comparing(Person::name)), map.getOrDefault(k, 0), List.copyOf(source). La Partie 12 a pour objectif de nommer ce style explicitement et de vous fournir le reste des outils — lambdas, interfaces fonctionnelles, les types java.util.function, les références de méthodes, Optional et l'API Stream — afin que les patrons que vous imitiez deviennent des techniques pleinement maîtrisées.

Quatre idées qui définissent le style

Les langages purement fonctionnels (Haskell, Erlang, F#) poussent ces quatre idées à l'extrême. Java les applique avec modération. Les quatre idées :

  1. Les fonctions sont des valeurs de première classe. Vous pouvez passer une fonction en argument, en retourner une depuis une méthode, en stocker une dans un champ, ou en construire une à l'exécution.
  2. Les fonctions pures. Une fonction pure dépend uniquement de ses entrées et ne modifie rien d'observable dans le monde. Pour une même entrée, elle retourne la même sortie. Pas d'E/S, pas de mutation de champ, pas de branchement dépendant du temps.
  3. L'immuabilité par défaut. Les structures de données ne sont pas modifiées en place ; les transformations retournent de nouvelles valeurs. Les anciennes références restent valides.
  4. La composition. Les fonctions plus grandes sont construites en combinant des plus petites (f.andThen(g), pred.and(other), cmp.thenComparing(...)), et non en les modifiant.

Aucune de ces idées n'est spécifique à Java. Ce sont des manières de penser que le langage supporte désormais via les lambdas, les références de méthodes, l'API Stream et les collections immuables que vous venez de découvrir.

1. Les fonctions comme valeurs

Avant Java 8, il était impossible d'avoir une variable dont la valeur était une fonction. On pouvait passer un objet dont la classe possédait une seule méthode — c'est ce qu'étaient Runnable, Comparator et ActionListener — mais la syntaxe était lourde :

list.sort(new Comparator<String>() {
  @Override
  public int compare(String a, String b) {
    return a.length() - b.length();
  }
});

La méthode unique était enveloppée dans une déclaration de classe anonyme. Java 8 a introduit les expressions lambda comme syntaxe concise pour la même idée :

list.sort((a, b) -> a.length() - b.length());

Le lambda est la valeur. Il est compilé en une instance de l'interface fonctionnelle requise au point d'appel (ici, Comparator<String>). Le chapitre suivant est entièrement consacré à la syntaxe ; pour l'instant, l'essentiel est que les fonctions en Java sont désormais des valeurs que vous pouvez nommer, stocker et transmettre.

2. Les fonctions pures

Une fonction pure est une fonction dont la valeur de retour dépend uniquement de ses arguments et dont l'exécution n'a aucun effet de bord observable. Math.sqrt(2) est pure. System.currentTimeMillis() ne l'est pas — elle retourne des valeurs différentes à chaque appel. list.add(x) ne l'est pas non plus — elle mute list.

Les fonctions pures sont précieuses car :

  • Elles sont faciles à tester — pas de configuration, pas de mocks, juste assertEquals(expected, f(input)).
  • Elles sont faciles à paralléliser — deux appels purs peuvent s'exécuter sur des threads différents sans synchronisation.
  • Elles sont faciles à mettre en cache — mémoïser une fois, retourner la même réponse indéfiniment.
  • Elles se composent sans surprisef(g(x)) fait exactement ce que la lecture suggère.

La plupart des programmes réels utiles ne sont pas purs à 100 % (quelqu'un doit écrire dans une base de données). La discipline fonctionnelle consiste à rendre le calcul de base pur et à repousser les parties impures — E/S, temps, aléatoire, mutation — vers les bords. Les Streams encouragent cette approche : un pipeline d'opérations pures est correct par construction ; un pipeline impur (stream().peek(x -> counter++)...) est un nid à bogues.

3. L'immuabilité

Vous avez rencontré cela dans le dernier chapitre. List.of(...), Set.of(...), Map.of(...) et List.copyOf(...) produisent des collections qui ne peuvent pas être modifiées. Les Records (abordés plus tard) vous donnent des classes de données immuables :

record Point(double x, double y) {
  Point translated(double dx, double dy) {
    return new Point(x + dx, y + dy);     // returns a NEW Point — does not mutate this
  }
}

Les valeurs immuables sont intrinsèquement thread-safe. Elles ne présentent jamais un état intermédiaire « déchiré ». Elles peuvent être partagées librement sans copie défensive. Et elles rendent les fonctions pures pratiques — si les valeurs ne peuvent pas changer, une fonction qui en retourne une est garantie d'être déterministe pour cette partie du monde.

4. La composition

La composition consiste à « construire une grande fonction à partir de petites ». En Java, Function, Predicate et Comparator fournissent tous des opérateurs de composition :

Function<String, String> trim  = String::trim;
Function<String, String> upper = String::toUpperCase;
Function<String, String> clean = trim.andThen(upper);   // trim, then upper

Predicate<Integer> positive = n -> n > 0;
Predicate<Integer> even     = n -> n % 2 == 0;
Predicate<Integer> posEven  = positive.and(even);

Comparator<String> byLength    = Comparator.comparingInt(String::length);
Comparator<String> lengthThenA = byLength.thenComparing(Comparator.naturalOrder());

L'API compositionnelle fait partie de la valeur. Vous n'écrivez pas une méthode helper qui prend deux prédicats et les combine avec && — vous écrivez a.and(b). Le style passe à l'échelle : une transformation en six étapes peut se lire de haut en bas comme une seule expression au lieu de six boucles imbriquées avec des accumulateurs intermédiaires.

Ce que Java conserve du côté impératif

Java est multi-paradigme. Les fonctionnalités fonctionnelles ajoutées dans Java 8+ coexistent avec les fonctionnalités impératives présentes depuis la version 1.0. Certaines choses restent impératives délibérément :

  • Les instructions et le flux de contrôle. if, for, while, try sont toujours les blocs de construction de base ; les lambdas ne les remplacent pas, ils remplacent le boilerplate des classes anonymes.
  • Les variables locales mutables. À l'intérieur d'un corps de méthode, int sum = 0; for (int x : xs) sum += x; reste idiomatique.
  • Les champs mutables là où ils ont du sens. Les builders, les caches et les composants UI avec état mutent toujours.

Le principe : utilisez le style fonctionnel là où il rend le code plus clair, et non comme un dogme. Un stream().mapToInt(Integer::intValue).sum() pur est plus clair qu'une boucle écrite à la main. Un pipeline de composition lambda en six étapes que personne dans votre équipe ne peut lire ne l'est pas.

Un exemple concret : impératif vs fonctionnel, côte à côte

Le programme ci-dessous calcule la longueur moyenne des chaînes non vides dans une liste, de deux façons. La première version est impérative — un accumulateur mutable, une boucle explicite, une protection contre la division par zéro. La deuxième version est fonctionnelle — un pipeline de stream d'opérations pures qui se lit de haut en bas. Le troisième extrait construit des valeurs composées Predicate et Function à partir de plus petites, illustrant la composition en action.

java— editable, runs on the server

Ce qu'il faut retenir de l'exécution :

  • Les deux versions calculent la même moyenne. La version impérative déclare deux compteurs mutables et un corps de boucle ; la version fonctionnelle enchaîne cinq opérations nommées qui décrivent chacune quoi, et non comment.
  • Predicate.and a construit un test composé (notNull.and(notBlank)) à partir de deux prédicats plus petits — sans nouvelle méthode helper nécessaire. C'est la composition en action.
  • Function.andThen a fait de même pour un pipeline produisant une valeur : trim puis length, exprimé comme une seule Function<String, Integer> composite.
  • Chaque opération dans le stream est pure : String::trim, le lambda s -> !s.isEmpty(), String::length — aucune ne mute l'état. Appeler trimmedLen.apply(\" hi \") deux fois a produit la même réponse ; c'est la garantie de déterminisme qui rend les fonctions pures sûres à mémoïser et à paralléliser.

Et ensuite

Le modèle mental est en place : les fonctions sont des valeurs, les transformations pures se composent, l'immuabilité vous affranchit d'une catégorie de bogues. Le prochain chapitre, Java Lambda Expressions, introduit la syntaxe concrète(params) -> body — qui rend ce style ergonomique en Java, ainsi que les règles concernant la capture de variables, le typage par cible et les contextes où un lambda peut apparaître.

Pratique

Pratique
Une fonction 'pure' au sens de la programmation fonctionnelle est une fonction qui...
Une fonction 'pure' au sens de la programmation fonctionnelle est une fonction qui...
Was this page helpful?