Skip links

Le paradigme fonctionnel au service de la qualité de code

ProMyze Florian KauderJe m’appelle Florian Kauder et j’ai écrit cet article dans le cadre de mon stage de fin de Master Génie Logiciel chez ProMyze. Développeur depuis de nombreuses années, je m’intéresse notamment à tous les aspects des applications Web, allant de l’expérience utilisateur jusqu’à la conception des serveurs.

Twitter : https://twitter.com/aamulumi
LinkedIn : https://www.linkedin.com/in/floriankauder/
GitHub : https://github.com/AamuLumi
Medium : https://medium.com/@AamuLumi

 


 

Faire des fonctions, c’est le quotidien de beaucoup de développeurs. Nous sommes tous là, à construire ces fameuses “briques magiques” qui composent les logiciels de tous les jours. Mais une brique mal conçue fragilise tout l’édifice, et je suis persuadé que vous n’avez pas envie de voir votre bâtisse finir en champ de ruines.

La conception de ces briques correspond à un ensemble de techniques rassemblées sous le nom de programmation fonctionnelle. Ce paradigme de programmation, utilisé par OCaml, Erlang et autres F#, a été très fortement délaissé par les professionnels durant de très longues années. Mais cette ère est révolue : la programmation fonctionnelle est sûrement disponible dans votre langage favori ! Comment ça marche ? Qu’est-ce que cela implique ? Pourquoi devriez-vous en parler à toute votre équipe et l’adopter rapidement ? Tout ceci, c’est ce que je propose de vous expliquer dans cet article.

 

Vers l’infini et les fonctions

 

La première chose que vous devez vous dire est que, finalement, n’importe quel langage doit être fonctionnel : nous faisons des fonctions dans quasiment tous les langages, et ce depuis les débuts de l’informatique. En fait, qualifier un langage de fonctionnel implique qu’il réponde correctement aux définitions suivantes :

  • il est possible d’utiliser une fonction comme paramètre d’une autre fonction.
  • il est possible de retourner une fonction comme résultat d’une autre fonction.
  • il est possible d’assigner une fonction à une variable.
  • il est possible de stocker une fonction dans une structure de données.

Si ces conditions sont respectées, on dit alors que les fonctions dans ce langage sont des fonctions de première classe, et que le langage est fonctionnel.

Ces définitions nous permettent donc, par exemple, d’appliquer n’importe quelle fonction sur tous les éléments d’un tableau. Pour cet exemple, nous souhaitons mettre au carré tous les nombres d’un tableau d’entier. De façon classique, la fonction ressemblerait à ça :

 

fonction tableauAuCarré(t: tableau d'entiers)
    pour i de 0 à taille(t)
        t[i] = t[i] * t[i]

 

Le problème, c’est que si on veut maintenant mettre au cube les nombres du tableau, on est obligé de créer une nouvelle fonction. Et on sera obligé de créer une nouvelle fonction pour chaque nouveau traitement que l’on veut faire sur le tableau.

La programmation fonctionnelle permet de corriger ce problème : nous pouvons créer une fonction qui itère sur un tableau, et appliquer cette fonction sur tous les éléments du tableau. Cette technique consistant à amener un traitement en paramètre pour pouvoir être changé s’appelle la généralisation.

 

fonction appliquerFonctionSurTableau(t : tableau d'entiers, fn : fonction(entier))
    pour i de 0 à taille(t)
        t[i] = fn(t[i])
 
fonction miseAuCarré(n : entier)
    retourner n * n
 
appliquerFonctionSurTableau([0, 1, 2, 3], miseAuCarré)
=> [0, 1, 4, 9]

 

Plus de productivité, moins de lignes à coder

 

Ce qui est génial avec la programmation fonctionnelle, c’est qu’elle est livrée avec plein de fonctions permettant d’appliquer des fonctions. Je vais me concentrer sur les fonctions agissant sur des tableaux, car je trouve qu’il s’agit des exemples les plus intéressants d’application, en plus d’être les plus simples. Ces fonctions que je présente sont (normalement) déjà inclues dans les bibliothèques natives de vos langages. Je donne simplement les algorithmes pour mieux comprendre ce qu’elles font.

 

map()

Si vous avez déjà entendu parler du pattern Map/Reduce souvent nommé quand on parlait du Big Data, et bien c’est de ce fameux map que nous parlons ici. Il s’agit d’une des opérations de base sur un tableau, permettant de créer un nouveau tableau, auquel on applique une fonction passée en paramètre. Avec l’exemple de tout à l’heure :

 

fonction map(t : tableau d'entiers, fn : fonction(entier))
    res : tableau d'entier de taille=taille(t)
 
    pour i de 0 à taille(t)
        res[i] = fn(t[i])
 
    retourner res
 
map([0, 1, 2, 3], miseAuCarré)
=> [0, 1, 4, 9]

 

Oui, c’est la même chose (comme par hasard), à une différence près : map crée un nouveau tableau, ne modifiant donc pas l’ancien.

 

reduce()

Toujours dans le fameux couple Map/Reduce, nous nous occupons maintenant du second. reduce est une fonction formidable qui permet de réduire un tableau à une donnée. Voici un exemple avant d’aller plus loin :

 

fonction reduce(t : tableau d'entiers, fn : fonction(?, entier))
    resultat : ?
 
    pour i de 0 à taille(t)
        resultat = fn(resultat, t[i])
 
    retourner res
 
fonction somme(précédentRésultat : entier, élémentActuel : entier)
    retourner précédentRésultat + élémentActuel
 
reduce([0, 1, 2, 3], somme)
=> 6

 

La fonction reduce va appliquer une fonction à tous les éléments du tableau, avec comme paramètre le résultat calculé jusqu’à l’élément actuel (l’accumulateur) et l’élément actuel. Le but de la fonction est d’obtenir un élément simple, comme la somme, qui nécessite d’utiliser tous les éléments du tableau. Il s’agit de la même chose que quand vous calculez vous-même la somme d’un tableau : prenons le tableau [4, 1, 6]. Pour faire la somme, nous suivons la démarche suivante :

  • Vous retenez 4.
  • Vous ajoutez 1, ce qui fait 5. Vous retenez 5.
  • Vous ajoutez 6, ce qui fait 11. Vous retenez 11.

Ce comportement est le même que celui de reduce : nous retenons quelque chose, nous effectuons un traitement entre le résultat retenu et le nouvel élément, puis nous le retenons, et ainsi de suite.

Note : j’ai volontairement mis ? à la place du type de l’accumulateur, car il est normalement du type que l’on souhaite.

 

filter()

Très souvent, vous avez besoin de conserver uniquement les éléments répondant à une certaine condition. Il s’agit du principe même de la fonction filter, qui va filtrer les éléments d’un tableau pour conserver uniquement ceux répondant à une condition, que l’on nomme le postulat. Au final, cette fonction nous permet de récupérer un nouveau tableau avec uniquement les éléments voulus.

 

fonction filter(t : tableau d'entiers, fn : fonction(entier))
    resultat : tableau de taille dynamique
 
    pour i de 0 à taille(t)
        si (fn(t[i]) est vrai)
            alors resultat.ajouter(t[i])
 
    retourner resultat
 
fonction positif(n: entier)
    retourner n >= 0
 
filter([0, -4, 6, -2, 1], positif)
=> [0, 4, 1]

 

… et ce ne sont pas les seules.

Vous pouvez trouver d’autres fonctions comme :

  • sort(tableau, fonction(e1, e2)) : permet de trier un tableau (une fonction comparant les éléments un à un est passée le long du tableau).
  • find(tableau, fonction(e)) : permet de chercher un élément dans un tableau (la fonction donnée en paramètre renvoie vrai quand l’élément est le bon).
  • some(tableau, fonction(e)) : renvoie vrai si un élément existe dans un tableau (même fonctionnement que find).
  • forEach(tableau, fonction(e)) : applique la fonction à tous les éléments du tableau.
  • etc.

 

Anonymes et chaînables

Jusqu’ici, nous déclarions chacune des fonctions que nous souhaitions utiliser. Mais il est possible de définir une fonction à la volée sans lui donner de nom ou autre : ces fonctions sont dites anonymes (on parle aussi de lambda-fonctions, en référence au lambda-calcul). Cette fonctionnalité va nous permettre d’écrire des fonctions très rapidement, le plus souvent avec une syntaxe spécifique et minimaliste (mais cependant propre à chaque langage).

 

map([0, 1, 2, 3], (n: entier) => n * n)
=> [0, 1, 4, 9]

 

Très souvent, dans les notations proposées par les langages, le return est implicite, pour justement simplifier au maximum les fonctions.

Autre concept intéressant : dans les langages objets où les tableaux peuvent avoir des propriétés, il est possible de faire un chaînage avec plusieurs fonctions. Chaque fonction renvoyant un tableau, nous pouvons enchaîner les appels fonctionnels pour obtenir des algorithmes très puissants en peu de lignes.

 

let array: tableau de chaînes de caractères = ["X:32", "X:41", "Y:28", "Y:24", "X:22"];
 
array.filter((element: chaîne de caractères) => element.exists("X:")) // => ["X:32", "X:41", "X:22"]
     .map((element: chaîne de caractères) => element.substring(taille("X:")).versEntier()) // => [32, 41, 22]
     .reduce((accumulator: entier, element: entier) => accumulator + element) // => 95

 

Impact sur la qualité de code

 

Posons directement les choses : la programmation fonctionnelle est bénéfique pour la qualité d’un code, mais seulement si elle respecte certaines règles. Par exemple :

  • 1 – toute fonction complexe (ici, dont le nombre de lignes est supérieur ou égal à 2) doit être déclarée et nommée.
  • 2 – si une fonction contient un enchaînement de méthodes (comme au-dessus dans le map), il est préférable de déclarer et nommer la fonction.
  • 3 – il est préférable d’éviter les effets de bord sur les fonctions utilisées dans les mapreduce, etc., c’est-à-dire d’éviter de modifier la structure et les données manipulées par les fonctions (on parle alors de fonctions pures).
  • 4 – bien évidemment, un chaînage trop long (à partir de 5 à 6 fonctions) doit être divisé.

Ces différentes règles permettent de tirer le meilleur de la programmation fonctionnelle et de conserver un code propre.

 

Lisibilité

L’un des principaux avantages de la programmation fonctionnelle est bien évidemment la lisibilité. Elle nécessite un prérequis (la connaissance des fonctions de base), mais une fois cette connaissance acquise de toute l’équipe, elle permet d’avoir un code plus simple et plus court. On va notamment réduire considérablement le nombre de boucles dans le programme (remplacées par des appels de fonctions), ainsi qu’ajouter la possibilité de traiter les éléments d’un tableau directement sans passer par la syntaxe array[i].

Pour remplacer l’imbrication des boucles dans des boucles, on peut envisager de :

  • créer une fonction permettant de traiter notre structure sur toutes les dimensions qu’elle nécessite (nécessite une fonction par type d’itération, donc rapidement lourd).
  • déclarer plusieurs fonctions différentes (une par boucle), et chaque fonction va appeler une autre fonction au moment où la boucle est nécessaire (permet de séparer l’enchaînement de boucles, mais peut rendre la lecture un peu plus compliquée).

 

Maintenabilité – Evolutivité

Une des conséquences directes d’une meilleure lisibilité est d’avoir un code beaucoup plus maintenable. En fait, cette maintenabilité vient aussi du fait qu’il est possible de déboguer chaque étape d’un chaînage assez facilement, contrairement à une écriture plus impérative dont les différents traitements seraient mélangés. La séparation du traitement et de l’itération permet aussi de faciliter l’identification des bogues, en proposant notamment de tester la fonction de traitement indépendamment du reste.

L’évolutivité est aussi plutôt avantagée : il est beaucoup plus simple de rajouter une nouvelle étape dans un chaînage que dans une boucle classique. Il est cependant nécessaire de penser à la règle (4) sur le nombre de fonctions d’un chaînage qui peut entraîner un peu de refactorisation de code.

 

Testabilité

Sur ce point, la programmation fonctionnelle est encore plus intéressante : chacun de nos traitements importants peut être transformé en une suite de fonctions. Il devient donc possible de tester unitairement chacune de ces fonctions complètement indépendamment.

Dans le cas où vous travaillez avec des fonctions pures (sans aucun effet de bord), vous avez alors des unités extrêmement simples à tester. Une fonction pure va, par définition, retourner toujours le même résultat pour les mêmes paramètres d’entrée. Si le test passe donc avec certains paramètres d’entrée, vous avez alors la garantie que votre fonction marchera toujours avec ces paramètres d’entrée.

 

Exemples concrets

 

Pour illustrer tout ce que nous avons vu jusqu’ici, nous allons nous soumettre à un petit exercice. Nous possédons un fichier de données donnant les températures maximales dans plusieurs villes dans le monde (toutes les informations contenues dans le fichier sont factices). Le but va être d’extraire la température maximale parmi les villes françaises. Le fichier de données est le suivant :

 

{
    "date": "26-07-2017",
    "data": [
       {"country": "FR", "city": "Lyon", "temperature": "25.3C"},
       {"country": "USA", "city": "Chicago", "temperature": "36.8C"},
       {"country": "DE", "city": "Berlin", "temperature": "21.4C"},
       {"country": "FR", "city": "Paris", "temperature": "27.6C"},
       {"country": "IT", "city": "Rome", "temperature": "31.7C"},
       {"country": "ES", "city": "Madrid", "temperature": "35.9C"},
       {"country": "DE", "city": "Munich", "temperature": "26.1C"},
       {"country": "FR", "city": "Bordeaux", "temperature": "27.4C"},
       {"country": "EN", "city": "Londres", "temperature": "25.1C"},
       {"country": "USA", "city": "Los Angeles", "temperature": "22C"},
       {"country": "EN", "city": "Oxford", "temperature": "24.1C"},
       {"country": "USA", "city": "New York", "temperature": "31.2C"}
    ]
}

 

Pour chacun des langages présentés, j’écrirai la fonction en paradigme impératif et la fonction en paradigme fonctionnel pour pouvoir voir la différence entre les deux.




Je considère toujours que JavaScript est un langage formidable pour la programmation fonctionnelle. Le langage est très permissif, et les évolutions de l’ECMAScript (la spécification qui régit Javascript) rendent l’écriture de plus en plus agréable.

 

const fn = function(a, b) { return a > b; };
const fn = (a, b) => a > b;

 

Les deux instructions font la même chose, sauf que la fat-arrow va bind le contexte actuel de this dans la fonction. Il s’agit du petit plus de l’utilisation de la syntaxe.

Dans l’exemple suivant, nous nous servons du chaînage et des fonctions anonymes avec les fat-arrows sous 3 formes différentes :

  • la première (celle dans filter) est une véritable fonction anonyme, écrite complètement.
  • la deuxième (celle dans map) est une fonction anonyme appelant une autre fonction, car celle-ci a besoin d’un paramètre précis.
  • la troisième (celle dans reduce) prend directement une fonction déjà déclarée, car les paramètres de cette fonction et de reduce sont les mêmes.

On remarque que la distinction entre les différentes étapes de l’algorithme est beaucoup plus claire, et ce malgré le fait que l’écriture soit bien plus courte.

 

'use strict';
 
const data = require('./data.json').data;
 
function getTemperatureNumber(temperatureStr) {
    return parseFloat(temperatureStr.substr(0, temperatureStr.indexOf('C'), 10));
}
 
function getMaximum(a, b){
    return a > b ? a : b;
}
 
function computeFranceMaxImp() {
    let max = 0;
 
    for (let i = 0, iMax = data.length; i < iMax; i++) {
        if (data[i].country === "FR") {
            let temperature = getTemperatureNumber(data[i].temperature);
             
            if (temperature > max){
                max = temperature;
            }
        }
    }
 
    return max;
}
 
function computeFranceMaxFn() {
    return data.filter((e) => e.country === "FR")
        .map((e) => getTemperatureNumber(e.temperature))
        .reduce(getMaximum);
}
 
console.log(computeFranceMaxImp());
console.log(computeFranceMaxFn());

Ruby est très connu pour son utilisation d’itérateurs par l’intermédiaire des blocs. Le langage dispose néanmoins de fonctions anonymes via l’utilisation du mot-clé lambda ou du symbole →.

 

fn = lambda { |a, b| a > b }
fn = -> (a, b) { a > b }

 

Assez simplement, nous pouvons créer des fonctions anonymes assignées ensuite à des variables. Les lambdas de Ruby sont assez équivalents aux blocs et aux Procà quelques exceptions près.

Dans l’exemple suivant, nous nous servons du chainage et des itérateurs de Ruby. De base, les itérateurs sont conçus pour fonctionner avec des blocs. Il est néanmoins possible de transformer des lambdas en blocs via l’opérateur &. Je considère que l’usage des fonctions que je cherche à expliquer dans cet article est plutôt équivalente à l’usage des blocs et Proc en Ruby, et même dans le cas présenté ici, il est plus simple d’utiliser directement des blocs. Je considère donc les deux écritures parfaitement acceptables.

Bref, on remarque que la distinction entre les différentes étapes de l’algorithme est beaucoup plus claire, et ce malgré le fait que l’écriture soit bien plus courte. La syntaxe en lambda paraît cependant légèrement compliquée à cause de l’opérateur & devant être rajouté.

 

require "json"
 
data = JSON.parse(File.read("./data.json"))["data"]
 
def parseTemperature (temperatureStr)
    return Float(temperatureStr[0, temperatureStr.index("C")])
end
 
def getMaximum (a, b)
    return a > b ? a : b
end
 
def computeFranceMaxImp(data)
    max = 0
 
    for entry in data do
        if entry["country"] == "FR"
            temperatureNb = parseTemperature(entry["temperature"])
 
            if (temperatureNb > max)
                max = temperatureNb
            end
        end
    end
 
    return max
end
 
 
# Block version
def computeFranceMaxFn (data)
    return data.select { |e| e["country"] == "FR" }
        .map { |e| parseTemperature(e["temperature"]) }
        .reduce(&method(:getMaximum))
end
 
 
# Lambda version
def computeFranceMaxFn (data)
    return data.select(&-> (e) { e["country"] == "FR" })
        .map(&-> (e) { parseTemperature(e["temperature"]) })
        .reduce(&method(:getMaximum))
end
 
print computeFranceMaxImp(data), "n"
print computeFranceMaxFn(data), "n"

La programmation fonctionnelle marche assez bien en Python. Le langage implémentant le mot-clé lambda, il est possible de définir des fonctions anonymes de la façon suivante :

 

fn = lambda a, b: a > b

 

Les fonctions de base des tableaux n’étant pas chaînables, il est possible d’émuler ce comportement en utilisant le résultat d’une fonction comme paramètre de la suivante. Le résultat est que le sens de la lecture est inversé : nous commençons la lecture par la fonction la moins imbriquée, donc la dernière à être exécutée, ce qui peut être troublant lors des premières rencontres avec cette syntaxe.

Néanmoins, cette syntaxe reste beaucoup plus courte que la précédente, et ce tout en conservant une lecture correcte de l’algorithme.

 

#!/usr/bin/env python
# -*- coding: utf-8 -*-
 
import json
 
with open('./data.json') as dataFile:   
    data = json.load(dataFile)["data"]
 
def parseTemperature(temperatureStr):
    return float(temperatureStr[:temperatureStr.index("C")])
 
def getMaximum(a, b):
    return a if a > b else b
 
def computeFranceMaxImp():
    max = 0
 
    for entry in data:
        if entry["country"] == "FR":
            temperature = parseTemperature(entry["temperature"])
 
            if temperature > max:
                max = temperature
     
    return max
 
def computeFranceMaxFn():
    return reduce(getMaximum,
    map(lambda e: parseTemperature(e["temperature"]),
    filter(lambda e: e["country"] == "FR", data)))
 
 
print computeFranceMaxImp()
print computeFranceMaxFn()

Depuis Java 8, il est possible d’utiliser des fonctions anonymes directement dans le langage via les interfaces fonctionnelles. Ces interfaces définissent le “type” de traitement que la fonction va effectuer, ainsi que le nombre de paramètres. Dans le cas d’une fonction prenant deux paramètres, il est nécessaire de se tourner vers la classe BiFunction. Le typage statique de Java est à la fois une force et une contrainte : il permet de connaître implicitement les types des fonctions qui suivent dans le chaînage, mais peut nécessiter des étapes de cast pour pouvoir pleinement utiliser des fonctions sur des classes non natives.

 

BiFunction<Integer, Integer, Boolean> fn = (a, b) -> a > b;

 

Pour l’exemple suivant, j’ai utilisé la bibliothèque json-simple de Google, qui permet de manipuler simplement les objets issues du format JSON. Cette bibliothèque étant assez ancienne, elle a nécessité d’utiliser une fonction supplémentaire pour transformer le tableau en Stream, qui possède cette fameuse API fonctionnelle. Une fois cette étape passée et le transtypage des objets effectué, nous pouvons pleinement nous servir de la puissance de la programmation fonctionnelle, le tout dans Java.

On remarque que la distinction entre les différentes étapes de l’algorithme est beaucoup plus claire, et ce malgré le fait que l’écriture soit bien plus courte.

 

import java.io.FileReader;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
 
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
 
public class Main {
 
    public static double getTemperature(String temperatureString) {
        return Double.parseDouble(temperatureString.substring(0, temperatureString.indexOf("C")));
    }
 
    public static double computeFranceMaxImp(JSONArray data){
        double max = 0;
 
        for (Object entry : data){
            JSONObject element = (JSONObject) entry;
 
            if (element.get("country").equals("FR")){
                double temperature = getTemperature((String) element.get("temperature"));
 
                if (temperature > max){
                    max = temperature;
                }
            }
        }
 
        return max;
    }
     
    private static Stream<Object> arrayToStream(JSONArray array) {
        return StreamSupport.stream(array.spliterator(), false);
    }
 
    public static double computeFranceMaxFn(JSONArray data) {
        return arrayToStream(data)
                .map(JSONObject.class::cast)
                .filter(e -> e.get("country").equals("FR"))
                .map(e -> getTemperature((String) e.get("temperature")))
                .reduce(.0, Math::max);
                 
    }
 
    public static void main(String[] args) {
        JSONParser parser = new JSONParser();
         
        try {
            JSONObject o = (JSONObject) parser.parse(new FileReader("./data.json"));
            JSONArray data = (JSONArray) o.get("data");
 
            System.out.println(computeFranceMaxImp(data));
            System.out.println(computeFranceMaxFn(data));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

 

Si un engouement se crée actuellement autour de la programmation fonctionnelle, il se justifie par ses différents avantages que l’on peut en tirer, et ce très rapidement. Ce paradigme sort de plus en plus de son ancienne bulle académique pour venir s’installer confortablement dans le monde professionnel, où les développeurs sont ravis d’utiliser ses principes.