Java 8 Non ce n’est pas que les streams

Kosmik - Nov 24 '21 - - Dev Community

Introduction

Java 8 est sortie en 2014. À ce jour, les nombreuses nouveautés que cette version a apportées ne semblent toujours pas connues de la grande majorité des développeurs.

Nous aimerions tenter ici d'éclaircir ce que sont les lambdas et les streams. D'après ce que nous entendons lors de nombreuses qualifications techniques, le sujet reste nébuleux.

Nous essaierons aussi, modestement, d'y ajouter un petit retour d'expérience sur leur utilisation ...

Inner classes to lambdas

Plongeons tout de suite dans les lambdas.

Pour nous servir d'exemple nous utiliserons l'interface org.springframework.jdbc.core.RowMapper :

public interface RowMapper<T> {
  T mapRow(ResultSet rs, int rowNum) throws SQLException;
}
Enter fullscreen mode Exit fullscreen mode

Les classes internes allègent le code en nous permettant de ne pas créer des classes à tour de bras pour finalement ne redéfinir ou n'implémenter qu'un nombre réduit de méthodes.

Seulement ces classes internes restent très verbeuses : dans les classes internes de l'exemple ci-dessous, combien de lignes nous donnent réellement de l'information ? De combien de lignes devons-nous faire abstraction pour nous concentrer sur l'essentiel ?

public List<DataRecord> getDataRecords(String jobId){
  return jdbcTemplate.query("select * from DATARECORD ", 
    new RowMapper<DataRecord>(){ 
      @Override
      public DataRecord mapRow(ResultSet rs, int rowNum) 
             throws SQLException { 
        return new DataRecord(
          rs.getString("job_id"), 
          rs.getString("analysis_name"));
      }
  });
}
Enter fullscreen mode Exit fullscreen mode

⓵ Déclaration et utilisation de l'inner classe
⓶ Implémentation de la méthode.

Dans l'exemple du RowMapper, utilisé pour extraire le retour d'une requête sql jdbc, l'information que l'on retire de la définition de la classe interne est :

L'instance de la classe DataRecord est créée en exploitant les colonnes job_id et analysis_name.

Le reste n'est que syntaxe et bruit pour le lecteur.
Et c'est ce que proposent de simplifier les expressions lambdas, introduites en java 8.

Le raisonnement est le suivant :

  • Le RowMapper n'a qu'une unique méthode à redéfinir. On pourrait donc se passer de préciser la méthode redéfinie lors de l'implémentation.
  • Le type du paramètre d'entrée de la méthode peut se déduire de la définition de la méthode abstraite.
  • Le type paramétré du RowMapper se déduit du type de l'objet retourné par la méthode mapRow.

Les expressions lambdas permettent d'appliquer ces simplifications.

Mais c'est quoi une lambda ?

Les expressions lambdas sont un sucre syntaxique simplifiant l'implémentation de classe/interface.

Elles permettent de définir des fonctions sans les nommer. Elles peuvent être manipulées et exécutées dans un programme sans avoir un nommage figé.

Les lambdas s'écrivent de la façon suivante :

(Type1 param1, Type2 param2, .., TypeN paramN) -> { traitement }
Enter fullscreen mode Exit fullscreen mode

Utilisons-les pour mieux les comprendre

Appliquons les simplifications énoncées ci-dessus à notre exemple :

public List<DataRecord> getRecords(String jobId) {
  return jdbcTemplate.query("select * from DATARECORD ",
    (ResultSet rs, int rowNum) -> {
      return new DataRecord(
        rs.getString("job_id"),
        rs.getString("analysis_name"));
    });
}
Enter fullscreen mode Exit fullscreen mode

Il ne reste que l'essentiel : la définition du corps de la méthode en fonction des paramètres d'entrée.

La donnée essentielle s'écrit maintenant:

(ResultSet rs, int rowNum) -> {
  return new DataRecord(
     rs.getString("job_id"), 
     rs.getString("analysis_name")
  );
}
Enter fullscreen mode Exit fullscreen mode

rs est le résultat de la requête, rowNum est le numéro de la ligne en cours.

Dans notre exemple, le compilateur comprend de lui-même qu'on définit un RowMapper via l'implémentation de son unique méthode.

Notons que dans une expression lambda :

  • Si le type des paramètres peut être inféré, il peut être omis.
  • S'il y a un et un seul paramètre, les parenthèses peuvent être omises.
  • S'il n'y a qu'une seule instruction, les accolades autour du traitement, peuvent être omises. Dans ce cas, le mot clé return et le point-virgule ; de fin d'instruction peuvent eux aussi être omis.
  • Le nom des paramètres est indépendant de celui défini dans la méthode implémentée.

Si on applique toutes ces règles on peut encore simplifier la lambda utilisée pour définir le RowMapper :

(ResultSet rs, int rowNum) -> {
  return new DataRecord(
    rs.getString("job_id"), 
    rs.getString("analysis_name")
  );
}
Enter fullscreen mode Exit fullscreen mode

puis

(resulSet, index) -> {
  return new DataRecord(
    resulSet.getString("job_id"), 
    resulSet.getString("analysis_name")
  );
}
Enter fullscreen mode Exit fullscreen mode

puis

(rs, rowNum) -> new DataRecord(
  rs.getString("job_id"), 
  rs.getString("analysis_name")))
Enter fullscreen mode Exit fullscreen mode

⚠️ S'il est vrai que les lambdas peuvent améliorer la lisibilité du code. Ce n'est pas toujours le cas.
En effet une lambda de 40 lignes posée comme une verrue au milieu de votre code ne facilitera la lecture du code pour personne.
Par ailleurs, il est strictement interdit de laisser sortir une checked exception d'une lambda. Si vous avez l'habitude d'en utiliser, c'est le moment ou jamais d'arrêter avec cette hérésie.
Shame !

Consumer, supplier, Function and many others

Les interfaces qui peuvent être implémentées sous la forme de lambdas ne possèdent qu'une unique méthode non implémentée. Elles sont appelées interfaces fonctionnelles et peuvent être annotées @FunctionalInterface.
Cette annotation permet au compilateur de vérifier que l'interface est bien fonctionnelle, mais elle n'est en aucun cas obligatoire.

La classe Comparator<T> est un exemple d'interface fonctionnelle commune aux développeurs : sa méthode compare(param1,param2) renvoie le résultat de la comparaison entre deux objets de type T.
Elle existait certes avant java 8, mais cette dernière version l'a dotée de l'annotation @FunctionalInterface pour la désigner comme telle.

Nous avons jusqu'ici utilisé des expressions lambdas pour implémenter des interfaces courantes comme RowMapper, Comparator ou Runnable.

Mais les lambdas sont plus largement utilisées pour définir des méthodes à l'endroit même où elles sont utilisées, sans porter d'intérêt particulier à l'interface fonctionnelle sous-jacente.

Il est possible de vouloir déclarer de telles méthodes lorsqu'elles ne sont utilisées qu'une fois par exemple, ou encore pour passer un traitement en paramètre d'un autre traitement.

La méthode que l'on souhaite définir via une lambda doit ici encore implémenter une interface fonctionnelle.

Java 8 fournit une bibliothèque d'interfaces fonctionnelles standard appelée java.util.function, qui permet au développeur d'avoir accès aux interfaces fonctionnelles les plus communes sans les définir lui-même.

L'essentiel

Les interfaces du package java.util.function (naissance avec Java 8) sont des fonctions génériques définissant les cas d'usages les plus courants:

  • Une function prendra un ou plusieurs paramètres en entrée et retournera un résultat.
  • Un supplier ne prendra pas de paramètre d'entrée et fournira un résultat.
  • Un consumer prendra un ou plusieurs paramètres d'entrée et ne renverra aucun résultat.
  • Un operator prendra un ou plusieurs paramètres du même type et fournira un retour de ce type.
  • Un predicate prendra un ou plusieurs paramètres en entrée et retournera un booléen.

Plus en détail

Des basiques ...

Function<T,R>

Elle désigne une fonction prenant un paramètre d'entrée de type T et retournant un objet de type R.

Function<Integer, String> getNumber = item -> "Number " + 
item;
Enter fullscreen mode Exit fullscreen mode

Elle s'applique en utilisant apply :

>> getNumber.apply(1); \\ renvoie Number 1
Enter fullscreen mode Exit fullscreen mode

Elle se décline en :

  • UnaryOperator<T> : fonction dont les types d’entrée et de retour sont identiques. C’est donc une Function<T,T>.
  • BiFunction<T,U,R> : fonction qui prend en entrée deux paramètres, le premier de type T et le second de type U.
  • BinaryOperator<T> : fonction qui prend en entrée deux paramètres de type T. C’est donc une BiFunction<T,T,T>. Par exemple :
BinaryOperator<Integer> add = (a,b) -> a+b;
Enter fullscreen mode Exit fullscreen mode
Consumer<T>

Il désigne une fonction prenant un paramètre d'entrée de type T et de type de retour void.

Consumer<Integer> display = entier -> System.out.println("Number " + entier);
Enter fullscreen mode Exit fullscreen mode

Elle s'applique en utilisant apply :

>> display.apply(1); \\ affiche Number 1
Enter fullscreen mode Exit fullscreen mode

Il se décline également en BiConsumer<T,U> : prend en entrée deux paramètres, le premier de type T et le second de type U.

Supplier<R>

Il désigne une fonction ne prenant aucun paramètre d'entrée et retournant un objet de type R.

Supplier<Double> randomFrom0To100 = () -> Math.random() * 100;
Enter fullscreen mode Exit fullscreen mode

Elle s'applique en utilisant get :

>> randomFrom0To100.get(); \\ affiche un double aléatoire en 0 et 100
Enter fullscreen mode Exit fullscreen mode
Predicate<T>

Il désigne une fonction prenant un paramètre d'entrée de type T et renvoyant un booléen.

Exemple :

Predicate<String> isNull = (str) -> str == null;
Enter fullscreen mode Exit fullscreen mode

Elle s'applique en utilisant test :

>> isNull.test("HelloJava8"); // renvoie faux
Enter fullscreen mode Exit fullscreen mode
Les types primitifs

Les fonctions présentées ci-dessous ne permettent pas de manipuler des types primitifs. Des fonctions spécifiques existent pour ceux-ci :

  • Paramètre d'entrée de type primitif :
    • IntFunction<R>, IntConsumer dont le type du paramètre d'entrée est int.
    • Et les autres dérivés sur le même modèle : DoubleFunction<R>, DoubleConsumer, DoubleUnaryOperator, LongFunction<R>, etc.
  • Retour de type primitif :
    • IntSupplier dont le type de retour est int, DoubleSupplier dont le type de retour est double, etc.
    • ToIntFunction<T> dont le type de retour est int, et sur le même modèle : ToIntBiFunction<T,U>, ToLongFunction<T>, ToLongBiFunction<T,U>, ToDoubleFunction<T>, etc.

... Aux composées

Composition de prédicats

La composition de prédicats permet de créer un prédicat par la combinaison logique de plusieurs prédicats.

Prenons l'exemple suivant :

Predicate<Sock> isRed;
Predicate<Sock> isBlue;
Predicate<Sock> isHoled;
Enter fullscreen mode Exit fullscreen mode

Pour déterminer si une chaussette est rouge ou bleue et non trouée, on définit le prédicat ci-dessous :

Predicate<Sock> isRedOrBlueWithoutHoles = 
  sock -> (isRed.test(sock) || 
           isBlue.test(sock)) 
          && !isHoled.test(sock);
Enter fullscreen mode Exit fullscreen mode

L'interface Predicate propose des méthodes permettant une réécriture concise et naturelle de ce prédicat :

Predicate<Sock> isRedWithoutHoles = 
  isRed
  .or(isBlue)
  .and(not(isHoled));
Enter fullscreen mode Exit fullscreen mode
Composition de fonctions

En mathématiques, la composition consiste à créer une fonction par l'application d'une fonction au résultat d'une autre fonction. Par exemple :

f(x) = x + 1
g(x) = x²
f(g(x)) = (x²) + 1
g(f(x)) = (x + 1)²
Enter fullscreen mode Exit fullscreen mode

où f(g(x)) et g(f(x)) sont des compositions de f et g.

En utilisant les fonctions du package java.util.function, on peut créer ces mêmes fonctions composées :

Function<Integer,Integer> f = x -> x + 1;
Function<Integer,Integer> g = x -> x^2;
Function<Integer,Integer> composition1 = x -> 
  f.apply(
    g.apply(x)
  ); // f(g(x))
Function<Integer,Integer> composition2 = x -> 
  g.apply(
    f.apply(x)
  ); // g(f(x))
Enter fullscreen mode Exit fullscreen mode

Vous conviendrez aisément que ces apply successifs ne sont pas d'une lisibilité à toute épreuve. Pour clarifier ces compositions, Java 8 propose pour les function une interface plus commode :

Function<Integer,Integer> composition1 = f.compose(g); 
Function<Integer,Integer> composition2 = f.andThen(g); 
Enter fullscreen mode Exit fullscreen mode

La différence entre compose et andThen réside dans l'ordre d'évaluation des fonction :

⓵ équivalent à g.andThen(f)
⓶ équivalent à g.compose(f)

Streams

L'API Stream<T> a été introduite par Java 8. Le Stream est un objet java qui permet de définir via une API une série de traitements à réaliser sur une collection ou tableau d'objets.

Cette API utilise massivement lambdas et références de méthodes (que nous verrons plus tard). En revanche, aucune structure de boucle n'est nécessaire pour appliquer ces opérations.

Avec les Streams, la programmation java peut devenir déclarative au lieu d'être impérative : déclarer ce que doit faire un traitement et non comment il doit le faire.

Dans ce cadre déclaratif, les fonctions sont manipulées comme n'importe quel objet java pour être passées en paramètre ou retournées.

Prenons un exemple :

String[] tableau = {"toto", "titi", "tata", "toto", ""};
long count = Stream.of(tableau) 
            .filter(item -> item.isBlank()) 
            .distinct() 
            .count(); 
Enter fullscreen mode Exit fullscreen mode

⓵ Création d'un Stream à partir du tableau.
⓶ Filtrage des éléments vide.
⓷ Suppression des doublons.
⓸ Décompte des éléments restants.

Ce programme ne déclare que les opérations à effectuer. À aucun moment la façon d'exécuter ces traitements n'est définie : pas de boucle, pas de variable locale pour stocker les résultats temporaires, etc. Cela crée une topologie.

Le Stream est maître des algorithmes à utiliser, de l'optimisation et même de l'ordonnancement des opérations. La documentation de chacune des méthodes du Stream précise les garanties qu'elles offrent, charge au développeur de les prendre en compte.

Stream signifie flux : c'est à dire qu'une fois qu'un Stream a été utilisé pour appliquer une succession d'opérations et a permis d'obtenir un résultat, il n'est plus réutilisable.

L'exemple suivant ne fonctionnera donc pas :

String[] tableau = {"toto", "titi", "tata", "toto", ""};
Stream<String> stream = Stream.of(tableau);

long count = stream
            .filter(item -> item.isBlank())
            .distinct()
            .count();

long totosNumber = stream
        .filter(str -> "toto".equalsIgnoreCase(str))
        .count(); 
Enter fullscreen mode Exit fullscreen mode

⓵ 💣 Stream déjà épuisé !

Créer un Stream : générateurs de flux

Toute séquence d'éléments peut être transformée en Stream :

  • Un tableau :
String[] helloWorld = {"Hello", "stream", "world", "!"};
Stream<String> helloStream = Arrays.stream(helloWorld);
Stream<String> otherHelloStream = Stream.of(helloWorld);
Enter fullscreen mode Exit fullscreen mode
  • Une Collection :
List<String> helloWorld = Arrays.asList("Hello", "stream", "world", "!");
Stream<String> helloStream = helloWorld.stream();
Enter fullscreen mode Exit fullscreen mode
  • Une suite numérique :
IntStream zeroToHundred = IntStream.range(0, 100);
DoubleStream squaresOfTwo = DoubleStream
  .iterate(2, i -> i < 1000000, i -> i * 2);
Enter fullscreen mode Exit fullscreen mode
  • Un autre flux :
Stream<String> fewWords = Stream.<String>builder()
        .add("words")
        .add("to")
        .add("add")
        .build();
Stream<String> filesLines = Files
  .lines(Path.of("/c/file-sample.txt"));
Stream<String> linesStartingWithAddedWords = 
  Stream.concat(fewWords, filesLines);
Enter fullscreen mode Exit fullscreen mode
  • Ou même rien du tout :
Stream<Object> empty = Stream.empty();
Enter fullscreen mode Exit fullscreen mode

Appliquer des opérations : méthodes intermédiaires

Les méthodes intermédiaires sont l'ensemble des opérations applicables à un Stream et qui renvoient un Stream.

Puisqu'elles renvoient un Stream, elles peuvent être chaînées pour appliquer plusieurs méthodes intermédiaires successivement.

Ces méthodes utilisent une approche builder, c'est-à-dire que leur invocation permet de créer le pipeline de traitement qui sera ou pas invoqué dans le futur.

⚠️ Les méthodes intermédiaires ne déclenchent aucune exécution. Elles ne font que configurer une future utilisation.

  • distinct : ne conserve que les éléments non égaux du Stream initial.
Stream<Character> letters = Stream.of('a', 'b', 'j', 'z', 'b');
Stream<Character> distinctLetters = letters.distinct(); // contains only 'a', 'b', 'j', 'z'
Enter fullscreen mode Exit fullscreen mode
  • sorted : Elle prend en paramètre un Comparator et trie les éléments selon leur ordre naturel. Il est possible de préciser l'ordre dans lequel les éléments doivent être triés en fournissant un Comparator en entrée.
Stream<Character> letters = Stream.of('a', 'b', 'j', 'z', 'b');
letters.sorted();
// equivalent à
letters.sorted((someLetter, someOtherLetter) -> someLetter.compareTo(someOtherLetter));
// equivalent à
letters.sorted(Comparator.naturalOrder());
Enter fullscreen mode Exit fullscreen mode
  • limit / skip : limit(x) tronque le Stream à x éléments, tandis que skip(x) retire du Stream les x premiers éléments.

  • filter : Prend en paramètre un Predicate et retire du Stream tous les éléments ne le respectant pas.

Stream<String> namesContainingToto = Stream.of("Pierre-Toto", "Jean-Toto", null, "Tutu", "Toto")
        .filter(item -> Objects.nonNull(item))
        .filter(item -> item.contains("Toto"));
Enter fullscreen mode Exit fullscreen mode
  • map : Prend en paramètre une Function et l'applique sur chacun des éléments du Stream. Permet de passer d'un Stream à un Stream.
PairOfSocks[] socks = {
        new PairOfSocks("blanc", 38),
        new PairOfSocks("bordeaux", 42),
        new PairOfSocks("bleu", 39)
};

Stream<PairOfSocks> socksStream = Arrays.stream(socks);
Stream<Integer> socksSizes = Arrays.stream(socks).map(pair -> pair.size);
Stream<String> socksColors = Arrays.stream(socks).map(pair -> pair.color);
Stream<PairOfSocks> biggerSocks = Arrays.stream(socks).map(pair -> new PairOfSocks(pair.color, pair.size + 1));
Enter fullscreen mode Exit fullscreen mode

mapToInt, mapToDouble et mapToLong sont des spécialisations de map qui imposent que le Stream résultant de l'application du map soit respectivement un IntStream, DoubleStream, LongStream. Ces Streams permettent de manipuler les types primitifs int, double et long.

  • flatMap : transforme chaque élément du Stream en un autre Stream via la fonction passée en paramètre et retourne la concaténation de chacun des Streams obtenus.
Stream<String> names = Stream.of("Pierre-Toto", "Jean-Toto", "Tutu", "Toto");
Stream<String> letters = names.flatMap(name -> Arrays.stream(name.split(""))); // splits into letters
Enter fullscreen mode Exit fullscreen mode

Dans l'exemple ci-dessus, letters est un Stream composé de chacun des caractères présents dans chacun des noms de names. Il équivaut à :

Stream<String> names = Stream.of("P","i","e","r","r","e","-","T","o","t","o", "J","e","a","n","-","T","o","t","o", "T","u","t","u", "T","o","t","o");
Enter fullscreen mode Exit fullscreen mode

Le Stream d'origine n'a donc pas forcément la même cardinalité que le Stream résultant du flatmap.

  • peek : Il permet de "jeter un coup d'oeil" sur les éléments du Stream, sans les transformer, et sans être final non plus. Dans la grande majorité des cas, si vous apercevez un peek, fuyez. La doc indique clairement qu'il s'agit d'une méthode dédiée au debug. Par ailleurs le peek ne fonctionne que si une opération intermédiaire ou l'opération terminal a besoin de parcourir l'intégralité des éléments.
List<String> l = Arrays.asList("A", "B", "C", "D");
     long count = l.stream().peek(item -> System.out.println(item)).count();
Enter fullscreen mode Exit fullscreen mode

⓵ Le count n'a pas besoin de parcourir la liste donc pas de parcours de liste, et donc le peek n'est pas déclenché.

Obtenir un résultat : méthodes terminales

Les méthodes terminales sont les méthodes de l'API Stream qui ne renvoient pas un Stream : elles terminent donc les enchaînements d'opérations sur un Stream.

⚠️ Les méthodes terminales déclenchent l'exécution des traitements configurés via les méthodes intermédiaires, provoquant ainsi l'épuisement du Stream.

Dénombrer

  • count :
    • dénombre les éléments présents dans le Stream.
    • type de retour : long
PairOfSocks[] socks = {
        new PairOfSocks("blanc", 38),
        new PairOfSocks("bordeaux", 42),
        new PairOfSocks("bleu", 39)
};

long socksUnderSize40 = Stream.of(socks)
                            .filter(sock -> sock.size < 40)
                            .count(); // returns 2
Enter fullscreen mode Exit fullscreen mode

Réduire

Les méthodes de réduction permettent de passer d'un Stream à un unique résultat.

  • allMatch :
    • renvoie vrai si tous les éléments du Stream respectent le prédicat fourni en paramètre. Faux sinon.
    • type de retour : boolean
  • anyMatch :
    • renvoie vrai si au moins un élément du Stream respecte le prédicat fourni en paramètre. Faux sinon.
    • type de retour : boolean
PairOfSocks[] socks = {
        new PairOfSocks("blanc", 38),
        new PairOfSocks("bordeaux", 42),
        new PairOfSocks("bleu", 39)
};

boolean existsSockSizeOver38 = Stream
     .of(socks)
     .anyMatch(sock -> sock.size > 38); // returns true
Enter fullscreen mode Exit fullscreen mode
  • noneMatch :
    • renvoie vrai si aucun élément du Stream ne respecte le prédicat fourni en paramètre. Faux sinon.
    • type de retour : boolean
  • reduce :
    • renvoie un objet qui résulte de l'accumulation de tous les éléments du Stream via le BiOperator donné en entrée d
    • type de retour : boolean
PairOfSocks[] socks = {
        new PairOfSocks("blanc", 38),
        new PairOfSocks("bordeaux", 42),
        new PairOfSocks("bleu", 39)
};

PairOfSocks neutralSock = new PairOfSocks("gris", 40);
BinaryOperator<PairOfSocks> makePatchworkSocks = (someSocks, someOtherSocks) -> new PairOfSocks(someSocks.color + "," + someOtherSocks.color, someSocks.size);

PairOfSocks patchworkSocks = Arrays.stream(socks)
                                .reduce(neutralSock, makePatchworkSocks); // returns a PairOfSocks("gris,blanc,bordeaux,bleu", 40)
Enter fullscreen mode Exit fullscreen mode
  • collect : rassemble tous les éléments du Stream dans un nouvel objet, tel qu'une List, une Map ou une String par exemple.
    • prend en paramètre un collecteur de type Collector. Collectors propose plusieurs implémentations usuelles telles que tolist().
PairOfSocks[] socks = {
        new PairOfSocks("blanc", 38),
        new PairOfSocks("bordeaux", 42),
        new PairOfSocks("bleu", 39)
};

List<String> availableColors = Stream.of(socks)
   .map(sock -> sock.color) // only keep colors
   .collect(Collectors.toList()); // make a list of them

Map<String,Integer> availableSizesByColor = Stream.of(socks)                                                  
   .collect(Collectors.toMap(
       sock -> sock.color, // take sock colors as keys
       sock -> sock.size // take sock sizes as values
   )); // Only works if there are no color duplicates
Enter fullscreen mode Exit fullscreen mode

Rechercher

Les méthodes de recherche d'éléments renvoient des Optional<T>. Un Optional encapsule un objet java (ou du vide) et fournit une API qui permet mettre en place des traitements ne dépendants pas de la nullité de l'objet, sans mettre en place de structure conditionnelle.

Pour un stream de type Stream<T> :

  • findAny :
    • renvoie n'importe quel élément du Stream.
    • type de retour : Optional<T>
  • findFirst:
    • renvoie le premier élément du Stream
    • type de retour : Optional<T>
  • max:
    • renvoie l'élément le plus grand du Stream, selon le comparateur passé en paramètre
    • type de retour : Optional<T>
  • min:
    • renvoie l'élément le plus petit du Stream, selon le comparateur passé en paramètre
    • type de retour : Optional<T>

Découpage

Rappelons que les méthodes intermédiaires ne font effectivement aucun traitement. Si l'on doit effectuer de nombreuses transformations, il peut être plus lisible de scinder le code :

Stream<PairOfSocks> socksStream = socks.stream();

Stream<PairOfSocks> usedSocksStream = socksStream
        .filter(item -> item.used);

Stream<PairOfSocks> smallUsedSocksStream = usedSocksStream
        .filter(item -> item.size < 35);

Map<Integer, List<PairOfSocks>> pairOfSocksBySize =  smallUsedSocksStream
        .collect(Collectors.groupingBy(item -> item.size));
Enter fullscreen mode Exit fullscreen mode

Oui mais alors on a perdu les noms ?

Comme chacun le sait, après le choix entre espace et tabulation, un nommage correct reste souvent un des meilleurs moyens d'avoir du code lisible.

Or a priori les merveilleuses lambdas nous ont fait perdre nos noms !

Référence de méthode

Imaginons que nous ayons une liste de chaînes de caractères myList de type List, dont nous souhaitons afficher chacun des éléments sur la sortie standard.
L'implémentation naïve serait la suivante :

for (String element : myList) {
    System.out.println(element);
}
Enter fullscreen mode Exit fullscreen mode

Mais maintenant que nous connaissons les lambdas, passons à une version plus concise :

myList.forEach(element -> System.out.println(element));
// pour chaque élément que l'on appelera "element" de myList,
// appliquer la méthode System.out.println avec comme paramètre d'entrée "element"
Enter fullscreen mode Exit fullscreen mode

La méthode forEach applique à chaque élément de la liste le Consumer<String> fourni en entrée : fonction qui prend une String en entrée et ne renvoie rien.

On pourrait amplement se satisfaire de cette version. Mais poussons encore légèrement le curseur de la concision.
En effet, le forEach itère sur une simple liste de chaînes de caractères : le Consumer prendra forcément un élément de cette liste en paramètre d'entrée. Seule la méthode à appliquer aux éléments nous donne de l'information :

myList.forEach(System.out::println); //  à chaque élément de myList, appliquer la méthode println issue de la classe System.out
Enter fullscreen mode Exit fullscreen mode

On vient alors d'utiliser une référence de méthode. De manière générale, les références de méthodes s'effectuent ainsi :

 <nom de la classe ou de l'instance>::<nom de la méthode>
Enter fullscreen mode Exit fullscreen mode

On peut les utiliser si notre lambda comporte pour seule instruction un appel de méthode et une seule variable.

myList.forEach(item -> item.toString()); // équivaut à :
myList.forEach(String::toString);

myList.forEach(element -> System.out.println(element)); // équivaut à :
myList.forEach(System.out::println);
Enter fullscreen mode Exit fullscreen mode

Lambdas nommées

Imaginons désormais que nous souhaitions concaténer notre élément à une autre chaîne de caractères lors de l'affichage :

myList.forEach(element -> System.out.println("This is one element of my list: " + element));
Enter fullscreen mode Exit fullscreen mode

Il n'est pas possible de faire référence à la méthode println, puisque son paramètre d'entrée ne peut plus être implicite.

En revanche, si nous avons plusieurs listes sur lesquelles appliquer ce traitement, il est possible de mutualiser la déclaration de notre expression lambda. Il faudra pour cela, créer une variable correctement typée et l'initialiser avec une lambda.

myList.forEach(element -> System.out.println("This is one element of my list: " + element));
theirList.forEach(element -> System.out.println("This is one element of my list: " + element));
Enter fullscreen mode Exit fullscreen mode

peut devenir :

Consumer<String> elementPrinter = element -> System.out.println("This is one element of my list: " + element);

myList.forEach(elementPrinter);
theirList.forEach(elementPrinter);
Enter fullscreen mode Exit fullscreen mode

On a pu extraire l'expression lambda dans une variable de type Consumer<String> et ainsi la nommer et la réutiliser.

Une version qu'on voit moins souvent -et c'est bien dommage- nous permet d'utiliser les méthodes de sa propre classe.

Je peux écrire :

public void printElements(List<String> myList, List<String> theirList) {
    myList.forEach(item -> this.printElement(item));
    theirList.forEach(item -> this.printElement(item));
}

private void printElement(String element) {
        System.out.println("This is one element of my list: " + element);
    }
Enter fullscreen mode Exit fullscreen mode

Or comme on l'a vu, si la lambda contient pour seule instruction un appel de méthode, on peut utiliser les méthodes références :

public void printElements(List<String> myList, List<String> theirList) {
    myList.forEach(this::printElement);
    theirList.forEach(this::printElement);
}

private void printElement(String element) {
    System.out.println("This is one element of my list: " + element);
}
Enter fullscreen mode Exit fullscreen mode

Cas réel

Cas 1

Ainsi lors d'une revue, nous sommes tombés sur le code suivant :

public Map<String, List<Record>> getMap(List<Record> records, boolean sortByAnalysis) {
    Map<String, List<Record>> groupedRecords = new HashMap<>(); 
    for (Record record : records) { 
        String keyword = sortByAnalysis ? record.getAnalysisName() : record.getJobId(); 
        if (!groupedRecords.containsKey(keyword)) { 
            groupedRecords.put(keyword, new ArrayList<>()); 
        }
        groupedRecords.get(keyword).add(record); 
    }
    return groupedRecords;
}
Enter fullscreen mode Exit fullscreen mode

⓵ On instancie la map que l'on va retourner
⓶ On boucle sur la liste
⓷ On extraie la clef de regroupement
⓸ Si la map ne contient pas encore la clef
⓹ On l'ajoute avec une liste vide
⓺ On ajoute l'élément à la liste présente à cette clef

En utilisant les apis à notre disposition, nous avons effectué la réécriture suivante :

public Map<String, List<Record>> getMap(List<Record> records, boolean sortByAnalysis) {
    Function<Record, String> classifier = sortByAnalysis ? Record::getAnalysisName : Record::getJobId; 
    return records.stream().collect(Collectors.groupingBy(classifier)); 
}
Enter fullscreen mode Exit fullscreen mode

⓵ On initialise le classifier
⓶ On regarde la plateforme travailler.

Cas 2

Regardons un autre exemple de code également réel, dont le jargon métier a été modifié :

public boolean hasRedSocks(Home home) {
    return home.getRooms().stream()
            .filter(room -> room.getName().equals("bedroom"))
            .findAny()
            .flatMap(room -> room.getFurnitures().stream()
                    .filter(furniture -> furniture.getName().equals("sock drawer"))
                    .findAny().flatMap(furniture -> furniture.getClothes().stream()
                            .filter(clothe -> clothe instanceof Sock)
                            .map(clothe -> ((Sock) clothe).getMaterial())
                            .filter(material -> material instanceof Cotton
                                    && ((Cotton) material).getColor().equals("red"))
                            .findAny()
                            .map(o -> true)))
            .orElse(false);
}
Enter fullscreen mode Exit fullscreen mode

Alors, sceptique ? Pourtant tout cela nous semble très clair
!
En effet, l'api Stream utilisée à mauvais escient devient tout à fait indigeste. Considérez les trois réécritures suivantes :

Mieux avec lambda

public boolean hasRedSock1(Home home) { 
    return home.getRooms()
            .stream()
            .anyMatch(room -> isBedroom(room) && containsRedSock1(room));
}

private boolean containsRedSock1(Room room) {
    return room.getFurnitures()
            .stream()
            .anyMatch(furniture -> isSockDrawer(furniture) && containsRedSock1(furniture));
}

private boolean containsRedSock1(Furniture furniture) {
    return furniture.getClothes()
            .stream()
            .anyMatch(this::isARedSock);
}
Enter fullscreen mode Exit fullscreen mode

Mieux sans lambda

public boolean hasRedSock2(Home home) { 
    for (Room room : home.getRooms()) {
        if (isBedroom(room) && containsRedSock2(room)) {
            return true;
        }
    }
    return false;
}

private boolean containsRedSock2(Room room) {
    for (Furniture furniture : room.getFurnitures()) {
        if (isSockDrawer(furniture) && containsRedSock2(furniture)) {
            return true;
        }
    }
    return false;
}

private boolean containsRedSock2(Furniture furniture) {
    for (Clothe clothe : furniture.getClothes()) {
        if (isARedSock(clothe)) {
            return true;
        }
    }
    return false;}
Enter fullscreen mode Exit fullscreen mode

Mieux nommage

public boolean hasRedSock3(Home home) { 
    Stream<Room> bedRooms = home.getRooms().stream()
            .filter(this::isBedroom); // Retains only bedrooms
    Stream<Furniture> sockDrawers = bedRooms
            .map(Room::getFurnitures) // Gets the list of furnitures
            .flatMap(List::stream) // Turns them to stream
            .filter(this::isSockDrawer); // Filter on sock drawer
    Stream<Clothe> clothes = sockDrawers
            .map(Furniture::getClothes) // Get clothes
            .flatMap(List::stream);
    return clothes
            .anyMatch(this::isARedSock); // Has at least one red sock
}
Enter fullscreen mode Exit fullscreen mode

Et le code commun aux réécritures :

private boolean isBedroom(Room room) {
    return room.getName().equals("bedroom");
}

private boolean isSockDrawer(Furniture furniture) {
    return furniture.getName().equals("sock drawer");
}

private boolean isARedSock(Clothe clothe) {
    return clothe instanceof Sock &&
            ((Sock) clothe).getMaterial() instanceof Cotton
            && ((Cotton) ((Sock) clothe).getMaterial()).getColor().equals("red");
}
Enter fullscreen mode Exit fullscreen mode

Les réécritures ⓵ et ⓷ utilisent l'api Stream tandis que la ⓶ utilise des boucles for et conditions basiques du langage.

Bien que moins concise, la réécriture ⓶ n'en est pas moins lisible que les autres.

Le gain en lisibilité obtenu via ces réécritures ne découle que de la réorganisation et de l'extraction de la logique métier dans des méthodes simples, et non pas de l'utilisation ou non des fonctionnalités de Java 8.

Les nouveautés ne sont pas forcément mieux, ni forcément mauvaises. Et c'est la force d'un bon développeur que de savoir quand et où les utiliser.

Conclusion

Avec c'est article qui ne fait que gratter la surface, nous espérons avant tout donner des clefs pour comprendre les mécanismes à l'œuvre dans les streams, et les APIs fonctionnelles.

Nous pourrions encore explorer la programmation fonctionnelle (ou ce qu'il est possible de faire depuis Java8), nous pencher sur les exécutions de stream en parallèle, ou sur les optimisations faites par le compilateur java.

Mais avant tout ce qui nous intéresse ici, c'est de lever le voile sur la magie noire et de vous donner envie d'aller creuser vous mêmes les merveilleuses arcanes suprêmes de la connaissance du java.

Bon voyage.

Avec la merveilleuse Lucile Thienot

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .