La programmation fonctionnelle tire ses origines des mathématiques, en particulier du calcul lambda développé par Alonzo Church dans les années 1930. Ce formalisme a posé les bases théoriques pour décrire les calculs comme des fonctions pures, sans effets de bord. Dans les années 1950, John McCarthy a créé Lisp, le premier langage explicitement inspiré de ces idées, introduisant des concepts comme les fonctions de première classe. Les années 1980 ont vu l’émergence de langages comme Haskell, qui a mis l’accent sur les fonctions pures et l’évaluation paresseuse.
L’évaluation paresseuse est une stratégie en programmation fonctionnelle où l’évaluation d’une expression est retardée jusqu’à ce que sa valeur soit réellement nécessaire. Cela permet d’optimiser les performances en évitant les calculs inutiles et de supporter des structures de données potentiellement infinies.
Depuis, la programmation fonctionnelle a influencé de nombreux langages modernes (Scala, Clojure, F#) et a été intégrée dans des langages impératifs comme Python, JavaScript et Java, en réponse à la nécessité de parallélisme et de code plus robuste.
Avant Java 8 (2014), la programmation fonctionnelle était absente ou limitée. Les programmeurs utilisaient des solutions comme les classes anonymes pour simuler des comportements fonctionnels, mais cela était verbeux. Avec Java 8, l’introduction des expressions lambda, de l’API Stream et des interfaces fonctionnelles (comme Function, Predicate) a marqué un tournant, permettant de manipuler les fonctions comme des objets de première classe et de traiter les données de manière déclarative. Les références de méthodes et les types fonctionnels ont renforcé cette approche.
Dans ce cours, vous n’avez pas à maîtriser la programmation fonctionnelle, mais il est utile d’être familier avec cette notion.
Une fonction lambda, ou expression lambda, est une fonction anonyme concise définie sans nom, souvent utilisée pour des opérations simples et ponctuelles. Elle permet d’écrire du code plus compact, notamment dans des contextes où une fonction est passée en argument, comme pour des opérations de filtrage ou de tri. En Java, par exemple, les lambdas s’appuient sur les interfaces fonctionnelles, qui possèdent une unique méthode abstraite. Leur syntaxe est de la forme (paramètres) -> expression ou (paramètres) -> { instructions; } pour des blocs plus complexes.
Voici un exemple de code Java avec une méthode main utilisant une lambda pour trier une liste de chaînes par longueur :
Ce code définit une liste de mots, utilise une lambda pour trier les éléments par longueur croissante, puis affiche le résultat. La lambda (a, b) -> a.length() - b.length() remplace une implémentation complète de Comparator.
En Java, les expressions lambda permettent de capturer des variables de leur portée environnante, mais cette capture est soumise à des règles strictes pour garantir la sécurité et la prévisibilité. Les lambdas peuvent accéder aux variables locales, aux paramètres de méthode, aux variables d’instance, aux variables statiques et aux références this ou super.
Les variables locales et les paramètres de méthode doivent être final ou effectivement final (non modifiés après leur initialisation) pour être capturés par une lambda. Par exemple, une variable locale int x = 10 peut être utilisée dans une lambda si elle n’est pas réassignée. En revanche, les variables d’instance et statiques de la classe englobante peuvent être librement accédées et modifiées.
Les lambdas capturent également la référence this de la classe englobante, permettant d’accéder à ses méthodes et champs d’instance. Contrairement aux classes anonymes, où this fait référence à l’instance de la classe anonyme, dans une lambda, this désigne l’instance de la classe englobante. Les lambdas ne créent pas de nouvelle portée pour this, ce qui simplifie leur sémantique. Cependant, les paramètres de la lambda ne peuvent pas avoir le même nom qu’une variable locale capturée pour éviter les ambiguïtés.
Sous le capot, la capture des variables locales se fait par référence à leur valeur (qui est constante en raison de la règle de finalité effective), tandis que les variables d’instance et statiques sont accessibles via une référence à l’objet ou à la classe englobante.
Cela permet aux lambdas de modifier l’état mutable, comme une liste référencée par une variable locale effectivement final, ce qui peut compromettre la pureté fonctionnelle. Pour qu’une lambda soit une fonction pure, elle doit éviter de modifier ou de dépendre d’un état mutable externe et se limiter à ses paramètres d’entrée et à des données immuables.
Les lambdas sont utilisés à de multiples fins en Java. L’API Stream en Java pour effectuer un filtrage fonctionnel sur une liste de nombres. Considérons le prochain exemple. Le premier concept clé est la création d’une liste immuable avec Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8). Cette méthode transforme un tableau de nombres en une List fixe, qui ne peut pas être modifiée (par exemple, pas d’ajout ou de suppression d’éléments). Cette liste, nommée nombres, sert de point de départ pour le traitement. Ensuite, l’opération de filtrage repose sur l’API Stream, introduite en Java 8, qui permet de manipuler des collections de manière déclarative, en exprimant ce qu’on veut obtenir (ici, les nombres pairs) plutôt que comment le faire (comme avec une boucle traditionnelle). Ce paradigme fonctionnel rend le code plus concis et expressif. La méthode filter() crée un nouveau flux contenant uniquement les éléments satisfaisant une condition donnée, définie par une expression lambda qui retourne true ou false.
Dans cet exemple, nous utilisons les méthodes stream et collect.
stream() : La méthode stream() convertit une collection (comme une liste) en un flux (Stream), une séquence d’éléments permettant un traitement séquentiel ou parallèle. Les opérations sur un flux sont paresseuses (exécutées uniquement lorsque nécessaire) et ne modifient pas la collection d’origine.
collect() : La méthode collect() rassemble les éléments d’un flux dans une structure de données (par exemple, une liste) après application des opérations. Elle utilise souvent un collecteur, comme Collectors.toList(), pour spécifier le type de résultat. C’est une opération terminale qui déclenche l’exécution du flux.
La méthode map() transforme chaque élément d’un flux en appliquant une fonction, produisant un nouveau flux de même longueur avec les valeurs transformées.
La méthode reduce() combine les éléments d’un flux en une seule valeur en appliquant une fonction prenant deux arguments : le résultat accumulé et l’élément suivant.
La méthode forEach() applique une action à chaque élément d’un flux. C’est une opération terminale, souvent utilisée pour afficher des résultats ou effectuer des effets secondaires.
La méthode flatMap() transforme chaque élément d’un flux en un flux d’éléments, puis aplatit ces flux en un seul flux. Elle est utile pour gérer des collections imbriquées.
La méthode distinct() supprime les doublons d’un flux, renvoyant un flux avec des éléments uniques (basé sur la méthode equals()).
La méthode limit() restreint un flux aux n premiers éléments, utile pour traiter un sous-ensemble de données.
La méthode sorted() trie les éléments d’un flux selon leur ordre naturel ou un comparateur personnalisé.
Les méthodes peuvent être enchaînées pour des opérations complexes. Par exemple, filtrer les nombres pairs, les mettre au carré, supprimer les doublons, limiter à deux éléments et trier en ordre décroissant :
Voici un exemple qui illustre les principales méthodes.
Les flux standards opèrent sur des objets, mais pour optimiser les performances avec les types primitifs, Java propose des flux spécialisés : IntStream, LongStream et DoubleStream. Par exemple, un IntStream manipule directement des valeurs int.
Voici un exemple qui illustre les principales méthodes d’une instance de IntStream.
L’API Stream de Java comprend des méthodes tells que mapToInt, qui transforme un Stream en un IntStream de valeurs primitives int. Elle prend en paramètre une fonction définissant comment chaque élément est converti en int. De manière similaire, l’API propose mapToLong et mapToDouble, qui convertissent respectivement un flux en LongStream ou DoubleStream pour les types primitifs long et double.
Une fois le flux transformé, des opérations spécifiques aux flux primitifs, comme sum(), average(), min() ou max(), deviennent disponibles. Par exemple, mapToInt est souvent utilisé pour extraire des valeurs numériques d’objets complexes ou de chaînes de caractères avant d’effectuer des calculs.
Les références de constructeur en Java, introduites avec Java 8, permettent de référencer un constructeur d’une classe de manière concise à l’aide de la syntaxe Classe::new. Elles sont utilisées dans des contextes fonctionnels, comme les interfaces fonctionnelles (par exemple, Function, Supplier, ou IntFunction), pour créer des instances d’objets sans écrire explicitement une expression lambda. Elles sont particulièrement utiles dans les opérations de l’API Stream, les fabriques d’objets, ou les méthodes comme map et toArray, car elles simplifient le code tout en restant expressives. Par exemple, dans une conversion d’un flux en tableau, une référence de constructeur comme String[]::new peut être utilisée pour indiquer comment créer un tableau de chaînes de la taille appropriée, évitant ainsi des conversions manuelles complexes.
Nous pouvons utiliser la référence de constructeur comme une fonction.
En Java, les interfaces Function et Predicate font partie du package java.util.function, introduit avec Java 8 pour supporter la programmation fonctionnelle. L’interface Function<T, R> représente une fonction qui prend un argument de type T et produit un résultat de type R. Elle est utilisée pour transformer ou mapper des données, souvent dans des contextes comme les streams ou les lambdas. Par exemple, une Function peut convertir une chaîne en sa longueur ou transformer un objet en un autre type. Sa méthode principale est apply, qui exécute la transformation. Les Function peuvent être chaînées avec des méthodes comme andThen ou compose pour combiner des opérations. Ces interfaces rendent le code plus concis et déclaratif, facilitant les manipulations fonctionnelles dans un langage historiquement orienté objet.
En Java, la méthode andThen est une méthode de l’interface Function<T, R> qui permet de composer deux fonctions en séquence. Plus précisément, si vous avez une Function<T, R> (disons f1) et une autre Function<R, V> (disons f2), f1.andThen(f2) crée une nouvelle fonction qui applique d’abord f1 à l’entrée de type T pour produire un résultat de type R, puis applique f2 à ce résultat pour produire une sortie de type V. En d’autres termes, andThen définit une chaîne où le résultat de la première fonction devient l’entrée de la seconde.
L’interface Predicate<T> représente une fonction qui prend un argument de type T et retourne un booléen, servant à tester une condition. Elle est couramment utilisée pour filtrer des données, notamment dans les streams avec la méthode filter. Sa méthode principale est test, qui évalue la condition. Les Predicate peuvent être combinés avec des méthodes comme and, or ou negate pour créer des conditions complexes. Par exemple, un Predicate peut vérifier si un nombre est pair ou si une chaîne dépasse une certaine longueur. Voici un exemple.
La programmation fonctionnelle est un paradigme de programmation qui met l’accent sur l’utilisation de fonctions pures, l’immutabilité des données et l’évitement des effets secondaires. Une fonction pure produit toujours le même résultat pour les mêmes entrées et n’altère pas l’état externe, ce qui facilite la prédiction et le débogage du code. En Java, bien que le langage soit principalement orienté objet, l’introduction de l’API Stream et des expressions lambda en Java 8 a permis d’intégrer des concepts fonctionnels. Ces outils permettent de manipuler des collections de données de manière déclarative, en exprimant ce que l’on veut accomplir (par exemple, filtrer ou transformer des données) plutôt que comment le faire étape par étape, comme dans une approche impérative. Cette approche améliore la lisibilité et la modularité du code, tout en favorisant des opérations comme le parallélisme sans effort explicite.
Le rôle des lambdas et de l’API Stream dans Java illustre bien l’influence de la programmation fonctionnelle. Les lambdas permettent de définir des fonctions anonymes concises, souvent utilisées pour implémenter des interfaces fonctionnelles (comme Predicate ou Function) dans des opérations comme le filtrage, le mappage ou le tri. Par exemple, dans une opération comme list.stream().filter(x -> x > 0).map(x -> x * 2).collect(Collectors.toList()), chaque étape est une transformation fonctionnelle qui ne modifie pas la liste initiale, respectant ainsi le principe d’immutabilité. L’API Stream prend en charge ces transformations en traitant les données comme un flux, où les opérations sont enchaînées et évaluées de manière paresseuse (lazy evaluation), ne s’exécutant qu’à la demande d’un résultat final. Cela réduit les calculs inutiles et permet une optimisation automatique, comme le traitement parallèle avec parallelStream().
Cependant, l’intégration de la programmation fonctionnelle en Java reste partielle, car le langage conserve une forte orientation objet. Les développeurs doivent être conscients des compromis : les lambdas et les streams rendent le code plus expressif, mais une utilisation excessive ou inappropriée peut nuire à la performance ou à la lisibilité, notamment dans des cas complexes. De plus, Java impose des contraintes, comme l’absence de fonctions de première classe (les lambdas sont des implémentations d’interfaces) et une gestion explicite de l’immutabilité. Malgré ces limitations, la programmation fonctionnelle en Java, via les streams et les lambdas, a transformé la manière dont les développeurs manipulent les données, encourageant des pratiques plus modernes et alignées sur les paradigmes fonctionnels tout en restant ancrées dans l’écosystème Java.