Il existe 3 principaux paradigmes de programmation : procédural, orienté objet et fonctionnel. Si les deux premières sont très répandues la dernière ne connait du succès que depuis quelques années. Un récent article intéressant pose la question de la place de la programmation fonctionnelle, j’aime bien les réactions variées à cet article.
Voyons de quoi il s’agit et comment l’appliquer dans le cas de Javascript.
Des fonctions pures
En programmation fonctionnelle les fonctions doivent être pures. Ce qui signifie que d’une part elles doivent retourner toujours le même résultat si on lui fournit les mêmes paramètres, et d’autre part elle ne doit pas dépendre d’un élément extérieur ni en modifier un. Autrement dit la fonction doit être parfaitement isolée et ne présenter aucune surprise lorsqu’on l’utilise. En plus il faut éviter les effets collatéraux c’est à dire une action qui ne rentre pas dans la finalité de la fonction.
Voici une fonction impure :
let index = 0 const getIndex = (base) => base + ++index console.log(getIndex(50)) // 51 console.log(getIndex(100)) // 102
La fonction getIndex dépend d’une variable externe et en plus la modifie. Tentons de la rendre pure :
const getIndex = (base) => { if(typeof index == 'undefined') { index = 0; } return base + ++index } console.log(getIndex(50)) // 51 console.log(getIndex(50)) // 52
Là elle ne dépend plus d’une variable externe mais est-elle pour autant pure ? Non parce qu’elle ne retourne pas la même chose avec une valeur de paramètre identique. En fait dans mon exemple on n’a pas besoin d’une fonction mais juste d’une variable qu’on incrémente :
index = 0 console.log(50 + ++index) // 51 console.log(50 + ++index) // 52
Immuabilité
Dans l’approche fonctionnelle on préfère les valeurs immuables, c’est-à-dire celles qui ne peuvent pas être modifiées. Avec Javascript seuls les objest et les tableaux sont muables, pas les autres types de base. Regardez ce cas :
const a = 'Toto' const b = a + ' a faim' console.log(a) // "Toto" console.log(b) // "Toto a faim"
La chaîne b est totalement distincte de la chaîne a. Le fait d’avoir concaténé des caractères à la suite de Toto n’a pas modifié la chaîne a, mais créé une nouvelle chaîne en mémoire.
On peut faire la même démonstration avec tous les autres types de base.
Prenons maintenant le cas d’un tableau :
const a = ['a', 'b', 'c'] const b = a b.push('d') console.log(a) // ["a", "b", "c", "d"] console.log(b) // ["a", "b", "c", "d"]
Là on voit que le tableau initial est modifié donc muable. On ne crée pas un nouveau tableau en mémoire mais une autre référence pour le même tableau, d’où le résultat obtenu.
Finissons avec les objets :
const a = { index: 1, portee: 50} const b = a b.index = 2 console.log(a) // [object Object] { // index: 2, // portee: 50 // } console.log(b) // [object Object] { // index: 2, // portee: 50 // }
On obtient exactement le même résultat que pour les tableaux. remarquez aussi que le fait d’utiliser le mot clé const ne change rien à l’affaire. C’est juste la référence qui est préservée :
const a = { index: 1, portee: 50} a = {} // "error"
Mais les valeurs peuvent changer !
Alors comment faire en sorte que nos tableaux et objets soient immuables ? La solution consiste à cloner nos tableaux et objets :
const a = ['a', 'b', 'c'] const b = [...a] b.push('d') console.log(a) // ["a", "b", "c"] console.log(b) // ["a", "b", "c", "d"] const c = { index: 1, portee: 50} const d = {...c} d.index = 2 console.log(c) // [object Object] { // index: 1, // portee: 50 // } console.log(d) // [object Object] { // index: 2, // portee: 50 // }
Merci à la décomposition ! On peut aussi utiliser Object.assign(). Mais bon… ça ne fonctionne pas pour les tableaux ou objets inclus, c’est juste un clonage de surface :
const obj1 = { a: 320, b: { c: 10, d: 'test' }} const obj2 = {...obj1} obj2.b.c = 20 console.log(obj1) // [object Object] { // a: 320, // b: [object Object] { // c: 20, // d: "test" // } // } console.log(obj2) // [object Object] { // a: 320, // b: [object Object] { // c: 20, // d: "test" // } // }
Une astuce pour faire un copie profonde consiste à utiliser JSON :
const obj2 = JSON.parse(JSON.stringify(obj1))
Cette méthode ne fonctionne que pour ce qui est convertissable en JSON !
Il existe aussi des librairies comme immutability-helper.
Les fonctions d’ordre élevé
Dans Javascript les fonction sont de « première classe » (rien à voir avec les trains), c’est juste pour dire qu’elles sont traitées comme n’importe quelle autre variable :
- on peut assigner la fonction à une variable, mais également comme valeur dans un tableau ou un objet,
- on peut transmettre la fonction comme paramètre d’une autre fonction,
- on peut retourner la fonction à partir d’une autre fonction.
Ça fonctionne ainsi parce que les fonctions sont en fait des objets, comme on l’a déjà vu précédemment.
Et alors que sont les fonctions d’ordre élevé (Higher-Order function) ? Ce sont des fonctions qui utilisent d’autres fonctions, soit parce qu’elles en reçoivent une en paramètre soit parce qu’elles en retournent une, et encore plus si elles font les deux !
Prenons un exemple, nous avons un tableau et nous voulons créer un nouveau tableau en ajoutant à chaque élément la valeur 2. On peut arriver au résultat ainsi :
const values = [1, 5, 8] const result = [] for (const element of values) { result.push(element + 2); } console.log(result) // [3, 7, 10]
Mais on peut de façon plus efficace utiliser la méthode map :
const values = [1, 5, 8] const result = values.map(element => element + 2) console.log(result) // [3, 7, 10]
La méthode map crée un nouveau tableau en appliquant à chaque élément du tableau en entrée la fonction passée en paramètre. On peut donc dire que c’est une fonction d’ordre élevé. On a de la même manière les méthodes filter et reduce.
On voit que les fonction d’ordre élevé ont le mérite d’éviter d’écrire des boucles. La programmation fonctionnelle se présente comme plutôt déclarative, on dit ce qu’on veut faire plutôt que comment le faire.
Pipe et compose
Prenons un autre exemple avec une fonction qui retourne un age :
getAge = (someone) => someone.age getAge({ age: 27 }) // 27
Maintenant une autre qui nous dit si la personne est majeure :
isMajeur = (age) => age >= 18 isMajeur(20) // true
Maintenant j’ai un objet avec des renseignement sur une personne, en particulier son age et je veux utiliser ces deux fonctions pour savoir si elle est majeure :
getAge = (someone) => someone.age isMajeur = (age) => age >= 18 const age = getAge({ age: 27 }) // 27 isMajeur(age) // true
Je passe par une variable intermédiaire pour mémoriser l’age et ensuite j’utilise la seconde fonction. Le code est un peu lourd, je peux l’améliorer :
getAge = (someone) => someone.age isMajeur = (age) => age >= 18 isMajeur(getAge({ age: 27 })) // true
Si j’ai deux fonctions ça passe mais si j’en ai beaucoup plus ? Ça serait bien de disposer d’un moyen pour chaîner les fonctions. Eh bien c’est ce qu’on peut faire avec pipe :
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x) getAge = (someone) => someone.age isMajeur = (age) => age >= 18 const result = pipe( getAge, isMajeur )({ age: 27 }) console.log(result) // true
Pipe est une fonction d’ordre élevé puisqu’elle prend comme argument un nombre quelconque de fonctions. L’opérateur de décomposition range ces fonctions dans le tableau fns. On applique ensuite reduce à ce tableau, on invoque donc la première fonction avec x en entrée. A l’invocation de la seconde fonction on a en entrée le résultat v de la première fonction, et ainsi de suite.
On peut aussi utiliser compose qui est l’inverse de pipe : les fonctions sont traitées dans l’ordre inverse :
const compose = (...fns) => x => fns.reduceRight((v, f) => fn(v), x)
Currying
Currying est un processus où on transforme une fonction avec plusieurs paramètres en plusieurs fonctions emboîtées. Prenons un exemple :
const sum = (a, b) => a + b console.log(sum(5, 2)) // 7
On a ici une fonction avec 3 paramètres, on va la transformer ainsi :
const sum = a => b => a + b console.log(sum(5)(2))
On a deux fonctions ici et chacune a un seul paramètre. La première a le paramètre a et retourne une fonction qui prend le paramètre b. L’écriture en fonctions est élégante mais peut des fois déstabiliser. Si vous avez du mal cette écriture peut vous aider :
const sum = a => function(b) { return a + b }
Ça fonction pour un nombre quelconque de paramètres :
const sum = a => b => c => a + b + c console.log(sum(5)(2)(10)) // 17
La récursion
La récursion est le fait pour une fonction de s’appeler elle-même. Ça permet parfois de résoudre efficacement et en peu de code des problèmes complexes. Elle est très utilisée dans des langages non pourvus de boucles comme Erlang. Avec Javascript nous avons la possibilité de créer des boucles mais on a vu qu’on cherchait à les éviter en programmation fonctionnelle.
La récursion est une approche consistant à diviser une tâche en procédures élémentaires répétitives mais il n’est pas forcément facile de s’en faire une représentation mentale. On l’utilise par exemple pour effectuer des tris. Mais je vais prendre le cas simple de calcul d’une factorielle. Pour mémoire la factorielle est le produit de tous les nombres jusqu’au nombre considéré, par exemple pour 5 :
5! = 1 * 2 * 3 * 4 * 5 = 120
On peut le traiter de façon classique (il faut tenir compte du fait que la factorielle de zéro est 1, même si cela peut sembler étrange) :
const fact = (x) => { let r = 1 for(let i = 2; i <= x; i++) r *= i return r } console.log(fact(10)) // 3628800
Mais on peut aussi utiliser la récursion :
const fact = (x) => { return x === 0 ? 1 : x * fact(x - 1) } console.log(fact(10)) // 3628800
Le code est plus élégant ici. Une fois traité le cas particulier de la factorielle de zéro qui sert en même temps de sortie de la fonction (on appelle ça le cas de base) on a une seule ligne de code. La fonction s’appelle elle-même chaque fois en enlevant la valeur 1 à la donnée en entrée (on appelle ça le cas de propagation). Ça demande un peu de gymnastique mentale pour suivre les différents appels…
Conclusion
Même si on n’a pas l’intention d’utiliser la programmation fonctionnelle on est quand même obligé d’avouer qu’elle pose de bonnes questions et qu’elle permet d’améliorer la manière de coder avec plus de rigueur.