Je 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.
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 :
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]
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.
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.
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 :
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.
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]
Vous pouvez trouver d’autres fonctions comme :
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
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 :
Ces différentes règles permettent de tirer le meilleur de la programmation fonctionnelle et de conserver un code propre.
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 :
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.
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.
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 :
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.
Social media