Programmation fonctionnelle avec JAVA 8 et les expressions lambda

plus de 4 an(s)

Description

La programmation fonctionnelle et les lambdas avec JAVA 8  

La programmation fonctionnelle est un paradigme de programmation de type déclaratif qui considère le calcul en tant qu'évaluation de fonctions mathématiques et n'admet pas les changement d'état,  contrairement au modèle de programmation impérative qui met en avant les changements d'état.  

La programmation fonctionnelle n'est pas quelque chose de nouveau. son origine  peut être trouvée dans le lambda-calcul, le langage fonctionnel le plus ancien est Lisp, créé en 1958 par McCarthy.   

Aujourd'hui on peut distinguer les langages purement fonctionnelles et les langages qui utilisent le style fonctionnelle 

Haskell : est purement un langage fonctionnel (pas de mutation)  

Java : utile le style de programmation fonctionnelle grâce aux expressions lambdas mais n'est pas purement fonctionnelle, les développeurs doivent éviter d'introduire des changements d'états dans la composition des fonctions  

A noter que le développement orienté objet (OOP)  utilise souvent un approche Algorithmique, c'est pour cela que les développeurs OO ont au départ du mal à se familiariser avec l'approche fonctionnelle (qui profite de l'encapsulation et du polymorphisme). 

La plupart des langages permettent d'utiliser les deux  paradigmes de programmation : impérative ou déclaratif. Les développeurs ont le choix de l'approche à utilisé.  

La programmation fonctionnelle est :  

  • Est déclarative (non impératif)  

  • Sans effet de bord (Les fonctions utilisées sont sans effets de bord sur le reste du programme)  

  • Permet de faire des transformations sans changement d'états 

  • Interdit les opérations d'affections (mutation) 

  • Utilise des fonctions d'ordre supérieur  

    • Les fonctions sont considérées comme des objets de première classe et peut être passer en argument a d'autres fonctions.  

    •  On peut créer une fonction dans une fonctions  

    • On peut retourner une fonction à partir d'une fonction 

    • Les fonctions sont des boites noires 

 Programmation impérative vs programmation déclarative  

  

Comment faire ?   Algorithme  

Quoi faire ? Quelle sont les transformations nécessaires ?  

Mutation (affectation)  

Transformation, mapping  

Effet de bord  

Sans effet de bord  

Utilise des objets en paramètre  

 Utilise des fonctions en paramètre  

Difficile de composer avec objets déjà utilisé  

Composer des fonctions pour faire multiple transformation sans altérer l'état des objets  

Difficile à paralléliser  

Facile à paralléliser   

Difficile à comprendre  

Facile à comprendre  

Verbeux  

Concis  

  

Méthode sans effet de bord  

La règles d'or de la programmation fonctionnelle, c'est qu'une méthode fonctionnelle doit être pure et par conséquent sans effet de bord lors de l'exécution du programme  :

  • Ne change rien (pas de changement d'état)  

  • Ne dépends de rien qui peut changer    

Les expressions lambdas avec Java 8 :  

Dans la version Java 8, les expressions lambdas ont été introduite pour faciliter la programmation fonctionnelle avec notamment une syntaxe simplifié. 

Une expression Lambda a besoin de deux choses :  

  • Les paramètres  de fonction

  • La corp  de la fonction

Le type de retour est géré de manière implicite.  

Le nom de la fonction n'a plus d'importance dans le travail est bien fait.  

(args....)-> // fonction lambda 

 

Exemples :   

Class Test 
    public static void main(String[] args){
        Thread th = new Thread();  
        th.start();  
        System.out.println("In main");  
      
}  

//   Puis on crée un nouveau Thread

Class Test 
    public static void main(String[] args){
        Thread th = new Thread(new  Runnable (){
            public void run(){
            System.out.println("In  another thread");  

        );  
            th.start();  
            System.out.println("In main");  
        }   
    }
}  

 

Avec les lambdas 

Class Test   
   public static void main(String[] args){  
        Thread th = new Thread(() -> {System.out.println("In  another thread"););  
    }  

}  

En utilisant les lambdas, on élimine le superflu et le code devient plus concis et facile à comprendre.  

 

Exemple d'utilisation avec les itérateurs  

Les itérateurs externes 

Avec les itérateurs externes, on passe la collection en argument et on dit au programme comment itérer et ce qu'il faut faire.  

Exemple d'utilisation d'une boucle  

Class Test 
    public static void main(String[] args){
        List<Integer> numbers  =  Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);  
        // boucle  
        for (int  i = 0; i < numbers.size(); i++){
            System.out.println(numbers.get(i));  
          
    }
}

// on peut simplifier la boucle avec le code suivant : équivalent   

 

Class Test 
    public static void main(String[] args){
        List<Integer> numbers  =  Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);  
        // boucle  
        for(int i : numbers){  
            System.out.println(numbers.get(i));  
          
    }
}

Maintenant on va faire la même choses avec les itérateurs internes   

L'avantage principal des itérateurs internes c'est le polymorphisme,.

par exemple en utilisant une méthode de type Consumer comme classe interne, on fournit à l'iterateur ce qu'il faut faire pour chaque élément de la liste au moment de l'exécution de la fonction. Ce qui permet entre autres de pouvoir changer la fonction d'itération si besoin sans impacter le reste de l'application  

Exemple avec un consumer :   Exemple   avec les lambdas   

 

Class Test 
    public static void main(String[] args){
        List<Integer> numbers  =  Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);  
        numbers.forEach (new Consumer<Integer>(){
            public accept(Integer i){
                  System.out.println(numbers.get(i));  
              
        });  
    }  
}

 

Avec les expressions lambdas, on peut utilser le Consumer comme suit 

Class Test 
    public static void main(String[] args){
        List<Integer> numbers  =  Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);  
        // 1 expression lambda  
        numbers.forEach ((Integer) i ->{ System.out.println(numbers.get(i)); );  

        // 2 expression lambda, on retire le Cast  
        numbers.forEach ((i) -> System.out.println(numbers.get(i)); );  

        // 3 expression lambda, on retire le Cast et les paranthéses  
        numbers.forEach ((i) -> System.out.println(numbers.get(i)); );  
    }  
}

 

// on peut enlever le type car java sait qu'on itère sur collections de Integer  

numbers.forEach ( (i) -> System.out.println(numbers.get(i)) );  

 // et on peut enlever les parenthèses si la fonction prendre en argument un seul paramètre.    

numbers.forEach ( i -> System.out.println(numbers.get(i)); );  

Et avec les méthodes de référence, on peut simplifier le code en utilisant le ::   

numbers.forEach (System.out::println);  

  

Dans les exemples ci-dessus, on passe de la programmation impérative à la programmation fonctionnelle en utilisant les lambdas.  

Dans l'exemple qui suit, imaginer qu'in souhaite trouver le total  des nombres pair multiplié par 2  de la liste.

A) approche impérative vs fonctionnelle

 

Class Test 
    public static void main(String[] args){
        List<Integer> numbers  =  Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);  
        // trouver le total des nombres pairs multiple par 2  
  // A ) approche impérative     
        // 1- je déclare le variable total
        int total  = 0;  
        for (int  i : numbers){
        // je vérifie si le nombre est pair
            If(i % 2) {
                // 2- addition
                total  += e*2;  
              
          }  
        //  3- j'affiche le total
        System.out.println(result);

// A ) approche fonctionnelle     
  
        total = numbers.stream()            // 1- itérateur  
            .filter(i -> e % 2 = 0) // 2- je  filtre  
            .mapToInt(e -> e*2)     // 3- je transforme chaque élément   
            .sum();                 // 4- je somme    
        // j'affiche
        System.out.println(total);
    }    
}  

 

La closure dans les expressions lambda :  

On parle de closure lorsque dans le corp d'une méthode fonctionnelle on utilise une variable dont la valeur doit être déterminer lors l'exécution de la fonction.   Par convention la variable factor est considérée comme final par java 8 et par conséquent sa valeur ne peut pas être modifier.  

 

Class Test 
     public static void main(String[] args){
        List<Integer> numbers  =  Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);  

        // trouver le total des nombres pairs multiple par 2  
    numbers.stream()  
            .map(e -> e*2)  
            .sum();  
     int factor = 2;  
        numbers.stream()  
        .map(e -> e*factor) // closure :  la variable factor doit exister dans le scope de l'exécution de la fonction    
        .sum();  
    
}

 

Closure et problème de mutation (changement d'état):   

  

Class Test 
     public static void main(String[] args){
        List<Integer> numbers  =  Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);  
        int factor =  new int[]{2;  

        Stream strm = numbers.stream();  
            strm.map(e -> e*factor[0]) ; //  la variable factor doit exister dans le scope de l'exécution de la fonction map    
             factor[0] = 0; // on peut modifier la valeur, ne cela viole la règle de non mutation    
            strm.sum(); // opération terminale dont le resultat va être incertain 
    }  
}

Dans l'exemple dessus, on réaffecte la valeur de factor[0] = 0, et on appelle l'opération terminale sum() .  

Le compilateur ne va pas nous empêcher de faire ce genre de chose.   

Mais l'exécution de la fonction terminal sum() peut être affecter par la valeur de l’affection factor[0] = 0;  

Ce qui faut retenir : 

  • Les lambdas n'ont pas d'état   

  • La closure doit utiliser des références immutables (utilisation de final) 

 

Avantages de la programmation fonctionnelle:  

  • Les fonctions peuvent être exécuté de manière paresseuse (Lazy evaluation) c-a-d que le code n'est exécuté que lorsqu'on appelle une méthode avec une opération terminale.  

  • Réduit les complexités introduites par accident lors qu'on utilise la programmation impérative.  

  • Possibilité de composer plusieurs fonctions et de faire des transformations intermédiaires avant le résultat final  

  • Facilite le parallélisme du code et la concurrence.  

  • Facilite le re-factoring du code  

Inconvénients du style de programmation impérative :  

  • Mutations fréquentes des objets  

  • On déclare souvent des variables locales  

  • Très verbeux 

  • Difficile à paralléliser car on doit suivre les changements d'états