Kaherecode

Collections-nous, élégamment avec stream !! (Part 1)

Nas Caso Camara
@NasIsGod 14 oct. 2020
0

Hello chers ami(e)s développeurs et développeuses ( c'est important !) . J'espère que vous, ainsi que vos familles se portent bien en ces temps de crises. Après mon premier article sur le REST, je reviens pour qu'on discute un peu de collections..

Avant d'aller plus loin, demandons gentiment à nos amis férus de la mode qu'ils ne sont pas concernés. Oui, on va parler de collections, plus précisément de nouveautés sur les collections mais pas celle de Zara mais celle de JAVA, à partir de la 8.

Maintenant que nous sommes entre développeurs, entrons dans le vif du sujet ! 

Un peu de rappel

Pour les férus de la mode qui seraient encore parmi nous, on va rappeler c'est quoi une collection.  Les collections, en JAVA, sont des classes permettant de manipuler les structures de données ( Merci wiki ).

En JAVA, il existe une pléthore de collections à notre disposition. On a les List (ArrayList, LinkedList .. etc) qui stockent les éléments ordonnés et acceptent les doublons, les Set (HashSet, TreeSet... etc) qui eux, par défaut, s'en foutent royalement de l'ordre et refusent les doublons (le culot). On a aussi les Map (HashMap, LinkedHashMap) qui stockent une clé et une valeur. N'abusons pas de la gentillesse de ces dernières, les clés ne peuvent pas être doublées. Pour le reste, allez voir la documentation. Par (On déteste tous lire la doc, je sais ).

On veut du code, stop le baratin !!!!

Dans les exemples qui vont suivre, nous allons manipuler une collection, une liste d'animaux (Original non ?) .  Avec cette liste, nous allons appliquer les différentes opérations qu'on réalise souvent quand on développe une application.

[SCOOP]: je vous le dis tout de suite, mon but est qu'à la fin de cette série de tutos, qu'on ait plus besoin d'utiliser la boucle FOR ..

Vu qu'on va manipuler des animaux, créons alors une entité qui représente ces animaux, on va l'appeler (Roulement de tambour).......... Animal .

import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class Animal {
    private String nom;
    private int force;
}

NB: les annotations @Data et @Builder proviennent de lombok. Une très bonne librairie qui peut nous épargner tout le code (Getter, setter, constructeur) qu'on a à écrire tout le temps en JAVA.  lombok

Maintenant, revenons à notre animalerie et créons une liste d'animaux.

import java.util.List;

public class Main {

    public static void main(String[] args) {
        List<Animal> animalList = List.of(
                Animal.builder()
                        .nom("Elephant")
                        .force(90)
                        .build(),

                Animal.builder()
                        .nom("Lion")
                        .force(100)
                        .build(),
                Animal.builder()
                        .nom("Chien")
                        .force(50)
                        .build(),
                Animal.builder()
                        .nom("Moustique")
                        .force(0).
                        build(),

                Animal.builder()
                        .nom("Fourmie")
                        .force(5)
                        .build()
        );
    }
}

Comment  ferions-nous si on voulait récupérer la liste tous les animaux ayant une force supérieure à 50?

Intuitivement, on se dit: on fait une boucle for, on teste la force. Et si la force est supérieure à 50, on stocke dans une nouvelle liste. En effet, cela marcherait. Alors, implémentons cette solution.

import java.util.ArrayList;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        List<Animal> animalList = List.of(
                Animal.builder()
                        .nom("Elephant")
                        .force(90)
                        .build(),

                Animal.builder()
                        .nom("Lion")
                        .force(100)
                        .build(),
                Animal.builder()
                        .nom("Chien")
                        .force(50)
                        .build(),
                Animal.builder()
                        .nom("Moustique")
                        .force(0).
                        build(),

                Animal.builder()
                        .nom("Fourmie")
                        .force(5)
                        .build()
        );

        List<Animal> animauxForts = new ArrayList<>();
/* On parcourt la liste et pour chaque animal, si la force > 50 on l'ajoute dans notre nouvelle liste */
        for (Animal animal : animalList) {
            System.out.println(animal.getForce());
            if( animal.getForce() > 50) {
                animauxForts.add(animal);
            }
        }
/* On affiche les animaux ayant la force supérieure à 50*/
        for(Animal animal : animauxForts ) {
            System.out.println("Nom :" + animal.getNom());
            System.out.println("Force :" + animal.getForce());
        }
    }
}

resultat description Résultat d'éxécution

Comme on peut le voir, les deux animaux ayant une force supérieure à 50 sont bien l'éléphant (90) et le lion (100).  On a créé et instancié une liste. Puis on a parcouru la liste qui contenait nos animaux et fait le test à chaque fois. Ensuite, on ajoute l'animal s'il correspond  au critère. 

A chaque fois que ce code est exécuté, la liste (animauxForts) est créée. Cela semble un peu embêtant non? ça ne serait pas cool de récupérer cette liste que lorsqu'on en aura besoin ? De plus, qu'en est t-il de la lisibilité de ce code ? 

Essayons une autre approche, celle fonctionnelle, avec les STREAM.  En fonctionnelle, on décrit ce que l'on souhaite avoir, ici une liste filtrée, on s'en fout de comment c'est fait en dessous pour la trier. C'est un peu comme en SQL, tu dis à ton moteur de base de données (ex Postgre) : Récupère moi la liste des utilisateurs, ensuite, trie les par ordre croissant et ne me retourne que 10. Est ce que tu te soucies de comment la commande ORDER BY est faite par postgre ou la commande LIMIT ? La réponse est non..

En stream, nous avons ce qu'on appelle des opérateurs. Ces opérateurs nous permettent  de décrire ce que l'on souhaite. On en verra pas mal dans cette série. Pour réécrire ce code de manière fonctionnelle, nous allons utiliser l'opérateur appelé FILTER.  Je pense que le nom de cet opérateur suffit à le définir.

Maintenant, voici le code refactoré :

import java.util.List;
import java.util.stream.Collectors;

public class Main {

    public static void main(String[] args) {
        List<Animal> animalList = List.of(
                Animal.builder()
                        .nom("Elephant")
                        .force(90)
                        .build(),

                Animal.builder()
                        .nom("Lion")
                        .force(100)
                        .build(),
                Animal.builder()
                        .nom("Chien")
                        .force(50)
                        .build(),
                Animal.builder()
                        .nom("Moustique")
                        .force(0).
                        build(),

                Animal.builder()
                        .nom("Fourmie")
                        .force(5)
                        .build()
        );
// Voici un code qui donne le même résultat que le précédent.
  List<Animal> animalListfortCollected = animalList.stream()
                .filter(animal -> animal.getForce() > 50)
                .collect(Collectors.toList());
    }
 System.out.println(animalListfortCollected);
}

resultat éxécution

On retrouve bien, nos deux animaux bien aimés à savoir le lion et l'éléphant. Au delà du fait qu'on a bien le résultat escompté, on est bien d'accord que ce code est bien plus LISIBLE. Même quelqu'un qui n'est pas développeur pourrait deviner ce que fait ce code. 

Mais examinons ce code de plus près. Tout d'abord, on fait animalList.stream(). Ce qui a pour conséquence d'ouvrir un flux de données (Animal), ensuite sur ce flux, on demande à garder (filtrer) que ceux respectant notre condition. Et pour terminer, on ne demande à ce  que l'api collecte ce flux sous forme de List. 

Il est important de mettre l'accent sur le mot "terminer", parce que c'est ce qui déclenche l'exécution. On les appelle, les méthodes terminales. Tant qu'une méthode terminale, dans notre exemple collect, n'est pas appelée, le bout de code avant n'est pas exécuté. Voilà un des grands avantages des streams, l'évaluation est faite de manière paresseuse......... LAZY.

Illustrons ce comportement LAZY par un exemple, pour cela nous allons juste déplacer le bout de code qui teste que la force de l'animal est supérieure à 50 dans une fonction. 

ça donne ça :

import java.util.ArrayList;
import java.util.List;

public class Main {

    public static boolean estIlFort(int force) {
        System.out.println("Je fais un test sur la force " + force);
        return force > 50;
    }

    public static void main(String[] args) {
        List<Animal> animalList = List.of(
                Animal.builder()
                        .nom("Elephant")
                        .force(90)
                        .build(),

                Animal.builder()
                        .nom("Lion")
                        .force(100)
                        .build(),
                Animal.builder()
                        .nom("Chien")
                        .force(50)
                        .build(),
                Animal.builder()
                        .nom("Moustique")
                        .force(0).
                        build(),

                Animal.builder()
                        .nom("Fourmie")
                        .force(5)
                        .build()
        );
             System.out.println("Avant le stream");
                 animalList.stream()
                .filter(animal -> Main.estIlFort(animal.getForce()));
              System.out.println("Après le stream");
    }
}

A la lecture de code, on s'attendait logiquement que notre fonction estIlFort soit appelé à chaque fois non?

LazyExécution

Et non, ça ne sera pas le cas parce que comme je le disais plus haut, le stream est évalué en LAZY. Pour qu'il s'exécute il faut que tu appelles une méthode terminale. Cela va prouver que t'as besoin de ton résultat à cet instant. Ajoutons une méthode terminale à ce code et voyons comment ça réagit.

import java.util.List;

public class Main {

    public static boolean estIlFort(int force) {
        System.out.println("Je fais un test sur la force " + force);
        return force > 50;
    }

    public static void main(String[] args) {
        List<Animal> animalList = List.of(
                Animal.builder()
                        .nom("Elephant")
                        .force(90)
                        .build(),

                Animal.builder()
                        .nom("Lion")
                        .force(100)
                        .build(),
                Animal.builder()
                        .nom("Chien")
                        .force(50)
                        .build(),
                Animal.builder()
                        .nom("Moustique")
                        .force(0).
                        build(),

                Animal.builder()
                        .nom("Fourmie")
                        .force(5)
                        .build()
        );

        System.out.println("Avant le stream");
                 animalList.stream()
                .filter(animal -> Main.estIlFort(animal.getForce()))
                .forEach(System.out::println);
        System.out.println("Après le stream");
    }
}

Nous avons ajouté la méthode terminale FOREACH.  Et comme par miracle, jetons un coup d'œil au résultat.

StreamLazyExec

Comme vous pouvez le constater, notre fonction est bien appelée cette fois. Ce comportement, est vraiment une excellente chose et devrait nous pousser plus encore à utiliser l'API stream.

C'est bien cool ton API, mais on ne peut que filtrer ?

Du calme déjà, on n'a pas fait que filtrer. On a vu deux méthodes terminales à savoir : FOREACH qui nous permet d'itérer sur nos données mais aussi COLLECTqui nous permet de récupérer nos données sous forme de liste par exemple.

Mais bon tu veux des opérateurs, alors parlons du prochain..  MAP

Oui, on va utiliser la map, mais pas pour jouer à call Of. A quoi sert cette map ? Et bien, c'est simple. Elle sert à appliquer une fonction à un élément du flux de données (stream). Pour ceux d'entre nous qui ont codé en OCAML, appliquer une fonction à un élément n'a rien de surprenant. Je m'explique, supposons que dans notre animalerie, nous nourrissons bien les animaux. C'est donc tout à fait logique qu'ils aient beaucoup plus de force non? Ce que nous allons faire est simple. Pour chaque animal, nous allons ajouter +10 à sa force et afficher les nouvelles forces.  Donc nous aurons une fonction qui fait la somme de la force actuelle plus 10. Cette fonction sera le paramètre de notre MAP. Et oui, c'est bien une fonction en paramètre.

ajoutons +10 à tous les animaux !

Pour les exemples qui vont suivre, nous allons écrire de manière impérative d'un côté et fonctionnelle de l'autre.

Nos animaux avaient respectivement comme force: 90, 100, 50, 0, 5.

import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Animal> animalList = List.of(
                Animal.builder()
                        .nom("Elephant")
                        .force(90)
                        .build(),

                Animal.builder()
                        .nom("Lion")
                        .force(100)
                        .build(),
                Animal.builder()
                        .nom("Chien")
                        .force(50)
                        .build(),
                Animal.builder()
                        .nom("Moustique")
                        .force(0).
                        build(),

                Animal.builder()
                        .nom("Fourmie")
                        .force(5)
                        .build()
        );
                 animalList.stream()
                 .map(animal -> Integer.sum(animal.getForce(), 10))
                .forEach(System.out::println);

    }
}
import java.util.ArrayList;
import java.util.List;

public class Main {

    public static boolean estIlFort(int force) {
        System.out.println("Je fais un test sur la force " + force);
        return force > 50;
    }

    public static void main(String[] args) {
        List<Animal> animalList = List.of(
                Animal.builder()
                        .nom("Elephant")
                        .force(90)
                        .build(),

                Animal.builder()
                        .nom("Lion")
                        .force(100)
                        .build(),
                Animal.builder()
                        .nom("Chien")
                        .force(50)
                        .build(),
                Animal.builder()
                        .nom("Moustique")
                        .force(0).
                        build(),

                Animal.builder()
                        .nom("Fourmie")
                        .force(5)
                        .build()
        );

        List<Integer> nouvellesForces = new ArrayList<>();
            for (Animal animal: animalList) {
                nouvellesForces.add(Integer.sum(animal.getForce(), 10));
            }
            System.out.println(nouvellesForces);
    }
}

Encore une fois, on voit bien en terme de lisibilité, laquelle est meilleure. Tout de même, vous avez dû notifier deux choses: la première, c'est au niveau de l'affichage. Pourquoi les [] sur une console mais par sur l'autre ?

La réponse est simple. Dans le cas de la forme impérative, on a déclaré une liste (nouvellesForces) on a donc indiqué le type de structure qu'on veut. Dans le cas du fonctionnel, on n'a pas collecté (COLLECT ) et comme c'est une pipeline (imagine un tuyau)  qui est créée par le stream, les données sont envoyées séquentiellement à travers cette pipeline d'où l'affichage verticale.. C'est à dire, il y a d'abord eu 100 qui est envoyé ensuite 110, et ainsi de suite.

La deuxième, c'est pourquoi on n'a plus les noms dans le résultat? La réponse est, pour le cas du stream la faute à : L'opérateur MAP(MDRRR). 

Du calme, Du calme. Déjà, dans l'exercice j'avais dit qu'on ajouterait +10 et affichera les nouvelles forces. Je ne t'ai donc pas arnaqué. Mais ne t'en prends non plus l'opérateur map, car il fait bien son taff. Quand on a ouvert le flux de données en appelant la méthode animaList.stream(), le type Animal est le type de données qui était en entrée mais ensuite quand cet animal arrive à l'opérateur map, on demande à la fonction map d'appliquer notre fonction Integer.sum. Et cette fonction retourne quoi ? un entier qui est tout simplement la somme qu'on lui a demandée. La donnée qui est donc transmise à la fonction system.out.println à travers le forEach n'est pas l'objet Animal mais le résultat de l'opérateur  MAP .

L'opérateur map est très utile et sans doute l'un des plus utilisés. Imagine dans ton appli, t'as une liste de user à qui tu veux envoyer un mail. Pour cela, t'as juste besoin d'envoyer à ton service de notifications la liste des adresses emails non? Appliquer l'opérateur map à ta liste de user te permettra facilement de récupérer juste les mails puis les passer à ton service. Et ça, ce n'est qu'un exemple parmi tant d'autres.

NB: Pour les curieux qui se demandent si on ne peut pas, ajouter +10 tout en ayant au bout une liste de type Animal...La réponse est **OUI,**mais pour ça, rester branchés pour le prochain épisode (Toudoumm, Netflix) .

Ok mais on ne peut utiliser qu'une opération intermédiaire à chaque fois ?

Non, tu peux en utiliser mille à la fois et tu sais quoi ? tu peux même enchainer les mêmes opérateurs. Dans l'exemple précédent, imaginons qu'on voulait convertir la valeur de force en chaîne de caractères après avoir fait la somme de 10. On aurait donc appeler une opération map pour faire la somme ensuite une deuxième fois pour faire la conversion en chaîne de caractères. il n'y a pas de LIMIT

En fait, j'ai dit une bêtise. Non. Pas totalement! il n'existe pas de limite au nombre d'opérateurs mais il y existe bien un opérateur appelé  LIMIT.

Je vois déjà les matheux s'exciter à vouloir calculer les limites.. Rangez moi tout de suite ces stylos. 

l'animalerie a du succès, il y a plein d'animaux. Mais dis moi quels sont les deux animaux les plus forts?

Notre animalerie a du succès, ce n'est pas moi qui le dit, c'est mentionné dans le titre. Vu ce succès, j'aimerai voir  quels sont les deux animaux les plus forts de notre animalerie. Cet exercice est intéréssant, j'aimerai bien que ceux qui lisent me proposent une solution, dans les commentaires par exemple. Surtout, ceux qui ne sont pas familiers avec l'API qui j'imagine vont l'implémenter de façon impérative.

Voyons encore la différence entre les deux approches.

import java.util.Comparator;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        List<Animal> animalList = List.of(
                Animal.builder()
                        .nom("Elephant")
                        .force(90)
                        .build(),

                Animal.builder()
                        .nom("Lion")
                        .force(100)
                        .build(),
                Animal.builder()
                        .nom("Chien")
                        .force(50)
                        .build(),
                Animal.builder()
                        .nom("Moustique")
                        .force(0).
                        build(),

                Animal.builder()
                        .nom("Fourmie")
                        .force(5)
                        .build()
        );
                 animalList.stream()
                  .sorted(Comparator.comparingInt(Animal::getForce).reversed())
                 .limit(2)
                .forEach(System.out::println);
}
import java.util.*;

public class Main {
    public static void main(String[] args) {
        List<Animal> animalList = List.of(
                Animal.builder()
                        .nom("Elephant")
                        .force(90)
                        .build(),

                Animal.builder()
                        .nom("Lion")
                        .force(100)
                        .build(),
                Animal.builder()
                        .nom("Chien")
                        .force(50)
                        .build(),
                Animal.builder()
                        .nom("Moustique")
                        .force(0).
                        build(),

                Animal.builder()
                        .nom("Fourmie")
                        .force(5)
                        .build()
        );

       List<Animal> copiedList =  new ArrayList<>(animalList);
        copiedList.sort(Comparator.comparingInt(Animal::getForce).reversed());
        List<Animal> limitedResult = new ArrayList<>();

        for (int i = 0; i < copiedList.size(); i++) {
            if( i < 2) {
                limitedResult.add(copiedList.get(i));
            }
        }
        System.out.println(limitedResult);
    }
}

Encore une fois, en terme de lisibilité. Nous serons tous d'accord que la version fonctionnelle est mieux que l'impérative. L'opérateur  LIMIT fonctionne comme son homonyme en SQL, il retourne le nombre d'éléments jusqu'à atteindre le maximum souhaité, c'est à dire 2 dans notre cas.  C'est aussi une méthode terminale, son appel déclenche l'exécution du stream.

Pour écrire la solution avec l'api Stream, ça m'a pris 3 minutes environ. Le tri de la liste et l'ajout de limite se font en deux lignes. le SORTED un opérateur  (on le verra plus tard) qui prend aussi en paramètre une fonction, applique cette fonction aux éléments du flux. Ici, la fonction en paramètre est le comparator et la limite qui prend en paramètre la taille max qu'on veut.

Une chose qu'on a souvent tendance à négliger est la complexité d'écrire un programme de façon impérative. J'ai mis un peu plus de 20 minutes à écrire le code à droite. Pour rappel, j'en ai mis 3 pour écrire le code à gauche.  

Pourquoi autant de temps à écrire le code de droite vous me direz? Parce que la liste (animalList) qui stocke les animaux de notre animalerie est immutable. Ce qui veut dire qu'on ne peut pas la modifier ! Or pour répondre à notre besoin, il faut d'abord trier la liste ce qui implique, la MODIFIER! Pour outrepasser le problème, j'ai donc copié la liste dans une liste qui est modifiable, ensuite trier cette liste et récupérer les deux premiers éléments. Merci encore à l'interface List qui contient une méthode Sort. Sans cela, nous aurions eu à écrire le tri proprement dit (J'ai essayé, ça m'a pris 10-15min, j'ai abandonné lol).

J'ai atteint ma  LIMITpour ce qui est de cette première partie. Je reviendrai dans les prochains jours pour présenter d'autres fonctionnalités de l'API Stream..  J'attends vos propositions de code pour résoudre le problème posé dans le dernier exercice. 

Hâte de vous retrouver pour le prochain épisode !! Prenez-soin de vous et Streamez!!!!!!!!!!!!!


Partage ce tutoriel


Merci à

Nas Caso Camara

Nas Caso Camara

@NasIsGod

Développeur FullStack et grand fan de musique, travaillant essentiellement sur les technos JAVA et Angular 2+.

Continue de lire

Discussion

Tu dois être connecté pour participer à la discussion. Me connecter.