Java : du style impératif au fonctionnel

Java : du style impératif au fonctionnel

Java, dans ses versions antérieures à la JDK 8, supportait principalement la programmation orientée objet (POO) combinée avec la programmation impérative. La programmation impérative est un paradigme où l'on spécifie non seulement ce que l'on souhaite accomplir, mais également la manière de l'accomplir.

À partir de la version 8, Java a introduit la programmation fonctionnelle. Avec cette approche, nous indiquons ce que nous voulons accomplir sans détailler la manière de le faire. Les bibliothèques prennent en charge les détails de l'implémentation. Cette méthode permet de rédiger du code plus concis, clair et facile à maintenir, en comparaison avec l'approche impérative, souvent plus verbeuse et complexe.

Dans cet article, nous allons examiner plusieurs techniques de transition vers la programmation fonctionnelle qui peuvent améliorer la lisibilité de nos programmes au quotidien.

Boucle simple :for() \=>range()ourangeClosed()

Considérons la traditionnelle boucle for suivante

for(int i = 0; i < 5; i++) {
  System.out.println(i);
}

Il s'agit d'une boucle classique qui effectue une opération avec la valeur, ici juste l'afficher. Cette boucle peut être transformée en une version fonctionnelle en utilisant la méthode range() de IntStream, comme illustré ci-dessous.

import java.util.stream.IntStream;

...
IntStream.range(0, 5)
  .forEach(i -> System.out.println(i));

On peut simplifier encore davantage en utilisant la référence de méthode println.

import java.util.stream.IntStream;

...
IntStream.range(0, 5)
  .forEach(System.out::println);

On observe que la version fonctionnelle est plus concise et plus facile à lire, et que l'opération est exprimée de manière plus claire comparée au style impératif.

Et si nous souhaitons inclure la borne supérieure de la boucle, comme suit :

for(int i = 0; i <= 5; i++) {
  System.out.println(i);
}

Dans ce cas, nous utiliserons rangeClosed() de IntStream :

import java.util.stream.IntStream;

...
IntStream.rangeClosed(0, 5)
  .forEach(System.out::println);

Les méthodes range() et rangeClosed() de IntStream renvoient un Stream de int sur lequel il est possible d'utiliser l'itérateur interne pour réaliser diverses actions. Dans les exemples précédents, nous avons utilisé forEach, mais d'autres opérations sont également possibles, comme nous le verrons plus loin dans cet article.

Contrairement à l'itérateur externe fourni par la boucle for, l'utilisation de l'itérateur interne avec IntStream est beaucoup plus concise, sans encombre, et évite la gestion manuelle de l'index. Cela rend le code plus lisible, facile à modifier et généralement plus agréable à manipuler.

Vous pouvez donc rechercher dans votre code des occasions de remplacer les boucles for traditionnelles par range et rangeClosed de IntStream. Assurez-vous que votre code fonctionne toujours correctement après ces modifications, par exemple en exécutant les tests automatisés existants.

Boucle avec pas :for(...i = i + ...) => iterate() et takeWhile()

Supposons une boucle avec pas qui permet de sauter certaines valeurs du range :

for(int i = 0; i < 15; i = i + 3) {
  System.out.println(i);
}

On peut convertir cette boucle en utilisant range() de IntStream et appliquer la méthode filter() sur le flux renvoyé par range. Cependant, explorons des approches plus efficaces.

Cette boucle initialise l'index à 0, puis l'incrémente de 3 à chaque itération, tout en vérifiant que la valeur reste inférieure à 15. Avant de poursuivre, imaginons une correspondance conceptuelle de cette boucle comme suit :

//imaginary code
for(int i = 0; i < 15; i = i + 3) //imperative
for(seed, i -> i < 15, i -> i + 3) //functional

Dans cette correspondance, nous avons le seed qui représente la valeur initiale, suivi d'un lambda pour la condition, et enfin un autre lambda pour calculer la valeur suivante.

La bonne nouvelle est que IntStream dispose de la méthode iterate() qui nous permet d'exprimer exactement cette logique :

iterate(int seed, IntPredicate hasNext, IntUnaryOperator next)

  • seed : c'est la valeur initiale

  • hasNext : c'est un IntPredicate, une interface fonctionnelle qui prend une valeur entière et retourne un booléen

  • next : c'est un IntUnaryOperator, une interface fonctionnelle qui prend une valeur entière et retourne une valeur du même type

Ainsi, nous pouvons convertir notre boucle comme suit :

import java.util.stream.IntStream;

...
IntStream.iterate(0, i -> i < 15, i -> i + 3)
  .forEach(System.out::println);

Et que faire lorsqu'il s'agit d'une boucle infinie avec une condition à l'intérieur ?

Considérons l'exemple suivant :

for(int i = 0;; i = i + 3) {
  if(i > 20) {
    break;
  }

  System.out.println(i);
}

La méthode iterate() possède une version surchargée qui permet de se passer du second argument, à savoir l'IntPredicate.

iterate(int seed, IntUnaryOperator next)

Cependant, cette version renvoie un Stream infini. Comment alors arrêter ce flux ? Heureusement, Java a prévu cette situation. Nous allons utiliser la méthode takeWhile(), qui prend un argument de type IntPredicate pour arrêter le flux lorsque la condition devient fausse.

La boucle réécrite est alors la suivante :

IntStream.iterate(0, i -> i + 3)
  .takeWhile(i -> i <= 20)
  .forEach(System.out::println);

Génial tout ça non 😊

foreach(...) { if... }\=>stream()etfilter()

Nous pouvons facilement convertir une boucle forEach en style fonctionnel. Considérons l'exemple suivant :

List<String> names = List.of("Jack", "Paula", "Kate", "Peter");

for(String name: names) {
  System.out.println(name);
}

Le refactoring de ce code consiste à utiliser directement la méthode forEach() sur la collection, comme suit :

List<String> names = List.of("Jack", "Paula", "Kate", "Peter");

names.forEach(name -> System.out.println(name));

Une alternative consiste à utiliser la méthode stream() :

List<String> names = List.of("Jack", "Paula", "Kate", "Peter");

names.stream()
  .forEach(name -> System.out.println(name));

La méthode forEach est disponible pour les types Collection<E> et Stream<T>.

Voyons maintenant comment traiter l'exemple suivant :

List<String> names = List.of("Jack", "Paula", "Kate", "Peter");

for(String name: names) {
  if(name.length() == 4) {
    System.out.println(name);
  }
}

L'instruction if peut être directement remplacée par un appel à la méthode filter() de Stream. Voyons cela en pratique :

List<String> names = List.of("Jack", "Paula", "Kate", "Peter");

names.stream()
  .filter(name -> name.length() == 4)
  .forEach(name -> System.out.println(name));

La valeur sera transmise à l'étape suivante du traitement si le Predicate fourni à la méthode filter renvoie true.

Itération avec transformation : foreach(...) { ...transformation... } => stream() et map()

Considérons l'exemple d'une boucle forEach contenant une transformation en majuscules, comme suit :

List<String> names = List.of("Jack", "Paula", "Kate", "Peter");

for(String name: names) {
  System.out.println(name.toUpperCase());
}

La boucle for peut être remplacée par stream(), puis la transformation en majuscules peut être effectuée à l'aide de map(), qui accepte une fonction lambda. Le code devient alors :

List<String> names = List.of("Jack", "Paula", "Kate", "Peter");

names.stream()
  .map(name -> name.toUpperCase())
  .forEach(nameInUpperCase -> System.out.println(nameInUpperCase));

Ce code peut être optimisé davantage en remplaçant les expressions lambda par des références de méthode, comme suit :

List<String> names = List.of("Jack", "Paula", "Kate", "Peter");

names.stream()
  .map(String::toUpperCase)
  .forEach(System.out::println);

Supposons maintenant que nous ayons besoin d'appliquer un filtre avant la transformation :

List<String> names = List.of("Jack", "Paula", "Kate", "Peter");

for(String name: names) {
  if(name.length() == 4) {
    System.out.println(name.toUpperCase());
  }
}

Si nous avons besoin d’appliquer un filtre avant la transformation, nous devons d'abord utiliser la méthode filter() pour sélectionner les éléments qui répondent à une certaine condition, puis appliquer la transformation avec map(). Voici comment nous pouvons procéder :

List<String> names = List.of("Jack", "Paula", "Kate", "Peter");

names.stream()
  .filter(name -> name.length() == 4)
  .map(String::toUpperCase)
  .forEach(System.out::println);

Manipuler une Source de Données sous forme Stream

Pour illustrer notre démonstration, imaginons que nous souhaitons compter le nombre de lignes contenant un mot spécifique dans un fichier. Le code traditionnel pour accomplir cette tâche pourrait ressembler à ce qui suit :

//Sample.java
import java.nio.file.*;

public class Sample {
  public static void main(String[] args) {
    try {
      final var filePath = "./Sample.java";
      final var wordOfInterest = "public";

      try (var reader = Files.newBufferedReader(Path.of(filePath))) {
        String line = "";
        long count = 0;

        while((line = reader.readLine()) != null) {
          if(line.contains(wordOfInterest)) {
            count++;
          }
        }

        System.out.println(String.format("Found %d lines with the word %s", count, wordOfInterest));
      }
    } catch(Exception ex) {
      System.out.println("ERROR: " + ex.getMessage());
    }
  }
}

En résumé, nous utilisons la méthode newBufferedReader() pour obtenir un BufferedReader, que nous parcourons à l'aide d'une boucle while avec la méthode readLine(). À chaque itération, nous vérifions si la ligne contient le mot recherché, puis nous incrémentons le compteur.

Cependant, si nous pouvons obtenir le contenu du fichier sous forme de flux (Stream), nous pouvons simplifier et moderniser notre code en remplaçant la boucle while et la condition if par les méthodes stream() et filter().

Heureusement, Java propose une solution pour cela. La classe Files du package java.nio.file dispose de la méthode lines(), qui nous fournit le contenu du fichier sous forme de flux de lignes. Nous pouvons donc réécrire notre code comme suit :

//Sample.java
import java.nio.file.*;

public class Sample {
  public static void main(String[] args) {
    try {
      final var filePath = "./Sample.java";
      final var wordOfInterest = "public";

      try(var stream = Files.lines(Path.of(filePath))) {
        long count = stream.filter(line -> line.contains(wordOfInterest))
          .count();

        System.out.println(String.format("Found %d lines with the word %s", count, wordOfInterest));
      }
    } catch(Exception ex) {
      System.out.println("ERROR: " + ex.getMessage());
    }
  }
}

Nous espérons que les techniques présentées dans cet article vous aideront à optimiser et améliorer la qualité de votre code.