Introduction à la programmation fonctionnelle en JS, partie II
Précédemment …
Dans la première partie de cet article, nous avons introduit ce qu’était la programmation fonctionnelle et expliquer ses grands principes, puis nous avons abordé certains de ses concepts, comme la composition de fonctions et les fonctions d’ordre supérieur.
Cette semaine, nous continuons à aborder d’autres concepts importants de la programmation fonctionnelle : les structures et les types algébriques.
Les concepts, la suite
Les structures algébriques
En programmation fonctionnelle, les structures algébriques sont des outils permettant de résoudre des problèmes particuliers, de la même manière que le font les designs patterns en programmation orientée objet. Mais contrairement à ces derniers, leurs bases sont définies par les mathématiques et non par la seule observation : elles sont définies plus formellement et possèdent leurs lois propres.
En pratique, on peut considérer les structures algébriques comme des types conteneurs de données proposant des opérations permettant de la traiter en fonction du contexte.
Il existe en JavaScript une spécification pour les structures algébriques appelée Fantasy Land. Pour chacune des structures, la spécification liste les méthodes qu’un objet doit proposer. Ces méthodes ont chacune une signature et une série de lois auxquelles elles doivent obéir.
Si ce concept peut sembler abstrait et sa théorie complexe, les exemples ci-après montreront qu’il est plus simple que ce qu’il laisse croire et que bon nombre d’entre nous ont déjà utilisé partiellement ces outils sans forcément s’en rendre compte.
Les foncteurs
Commençons par la structure la plus simple, les foncteurs (functor).
1
map :: Functor f => f a ~> (a -> b) -> f b
On appelle cette écriture la notation de Hindley-Milner. Ici, elle décrit 3 choses :
- Un foncteur
f
doit proposer une méthodemap
map
prend en paramètre une fonction : celle-ci attend une donnée de typea
et renvoie une donnée de typeb
- Un foncteur contenant des éléments de type
a
, notéf a
sur laquelle on appellemap
avec une fonctiona -> b
renvoie le même type de foncteur, mais contenant des éléments de typeb
, notéf b
.
Le type Array
en JavaScript est un foncteur, servons-nous en comme un exemple pour vérifier ce que nous venons de décrire :
1
2
3
4
const n = [1, 2, 3, 4] // Array<number>
const fn = n => `${n}` // number -> string
n.map(fn) // => Array<string> = ['1', '2', '3', '4']
Array
propose bien une méthodemap
map
prend ici la fonctionfn
transformant des nombres en chaînes de caractères (number -> string
)map
retourne bien le même type de foncteur (iciArray
) mais contenant des éléments d’un type différent (f a
devientf b
).
Pour résumer, la fonction map
permet donc d’appliquer une fonction sur une valeur qui est encapsulée dans un conteneur, et de ré-encapsuler le résultat dans un autre conteneur.
Enfin, un foncteur suit les deux lois suivantes :
-
la loi d’identité : Si
u
est un foncteur, appelermap
avec la fonction d’identité est équivalent àu
:1
u.map(x => x) ≡ u
-
la loi de composition : Si
u
est un foncteur, appelermap
avec la composéef ∘ g
est équivalent à appeler successivement map avec la fonctiong
puis avecf
:1
u.map(f(g(x))) ≡ u.map(g).map(f)
Notes :
-
La fonction d’identité (Identity) est une fonction renvoyant l’argument qu’elle reçoit
-
Le symbole
≡
représente une équivalence en mathématique
Les monades
Voyons maintenant en quoi consiste une monade :
1
chain :: Monad m => m a ~> (a -> m b) -> m b
La notation de Hindley-Milner nous décrit 3 choses :
- Une monade
m
doit proposer une méthodechain
chain
prend en paramètre une fonction : celle-ci attend une donnée de typea
et renvoie une monade contenant une donnée de typeb
(m b
).- Une monade contenant des éléments de type
a
, notém a
sur laquelle on appellechain
avec une fonctiona -> m b
renvoie le même type de monade, mais contenant des éléments de typeb
, contenue par la monadem b
.
Pour résumer, la fonction chain
permet d’appliquer une fonction sur une valeur qui est encapsulée dans un conteneur, renvoyant une valeur elle-même encapsulée dans un conteneur, et de ré-encapsuler ce résultat en sortie.
Note : D’après Fantasy Land, la structure algébrique Monad
déclare d’autres opérations et lois, mais je n’ai détaillé ici que la partie qui nous intéresse pour la suite.
Les types algébriques
Le type Maybe
Maybe
est à la fois un foncteur et une monade qui va nous permettre de gérer l’éventualité d’une opération retournant une valeur nulle (null
et undefined
en JavaScript).
1
data Maybe a = Nothing | Just a
Le type Maybe
définit deux contextes différents :
-
soit il n’y a aucune valeur :
Nothing
-
soit il y en a une :
Just(<value>)
Maybe
étant un foncteur, on peut appeler map
pour tenter d’appliquer un traitement sur la valeur contenue dans le contexte.
Voici un exemple dans lequel nous utiliserons la bibliothèque JavaScript sanctuary et son implémentation de Maybe
:
1
2
3
4
5
6
import S from 'sanctuary'
const maybeIntA = S.Just(20)
const maybeIntB = S.Nothing
const double = S.mult(2)
Ici on veut doubler la valeur à l’intérieur de chaque Maybe
:
1
2
maybeIntA.map(double) // --> Just(40)
maybeIntB.map(double) // --> Nothing
On voit que grâce à ce mécanisme de foncteur, on peut appliquer le traitement sur un conteneur avec un nombre de la même manière que sur un conteneur vide. Dans le cas de Nothing
, la fonction map
se contente de retourner Nothing
sans exécuter le traitement.
On peut ainsi chaîner plusieurs traitements, le moindre Nothing
court-circuitera la chaîne sans provoquer d’erreur.
Maintenant, voyons un autre exemple dans lequel on a une fonction half
qui renvoie la moitié d’un nombre pair, ou rien si le nombre est impair. Rappelons que Maybe
est aussi une monade, et qu’elle implémente donc l’opération chain
. Étant donné que half
prend un nombre pour renvoyer un Maybe
contenant un nombre, c’est cette opération que nous allons devoir utiliser à la place de map
.
Rappelons la notation de Hindley-Milner pour l’opération chain
des monades :
1
chain :: Monad m => m a ~> (a -> m b) -> m b
Voici ce que cela donne :
1
2
3
4
5
6
7
8
9
10
const half = n => (n % 2 !== 0) ? S.Nothing : S.Just(n / 2)
const compute = S.pipe([
S.chain(half), // --> Just(10)
S.chain(half), // --> Just(5)
S.chain(half), // --> Nothing
S.chain(half), // --> Nothing
])
compute(S.Just(20))
En chaînant les appels à half
via l’opérateur chain
, on obtient la moitié de 20, puis de 10, et on voit qu’à partir de 5, map
renvoie Nothing
, et le fera peu importe le nombre d’appels suivant à half
.
Le type Either
Either
va nous permettre de gérer les erreurs dans un traitement ou une chaîne de traitement sans utiliser les exceptions.
1
data Either l r = Left l | Right r
Le type Either
peut contenir 2 contextes différents :
-
soit une valeur gauche :
Left(<value>)
-
soit une valeur droite :
Right(<value>)
La valeur gauche sera utilisée pour décrire une erreur, généralement un message d’erreur sous forme de string
.
La valeur droite contiendra le résultat d’un traitement.
D’une manière un peu similaire à Maybe
avec Nothing
, une valeur de type Left
va court-circuiter le traitement et être simplement renvoyée à travers toute la chaîne.
Voici un exemple très semblable à celui exposé ci-dessus pour Maybe
:
1
2
3
4
5
6
7
8
9
10
const half = (n => n % 2 !== 0) ? S.Left(`${n} is not an even number`) : S.Right(n / 2)
const compute = S.pipe([
S.chain(half), // --> Right(10)
S.chain(half), // --> Right(5)
S.chain(half), // --> Left('5 is not an even number')
S.chain(half), // --> Left('5 is not an even number')
])
compute(S.Right(20))
Encore une fois, on s’aperçoit que Either
et Maybe
sont assez proches :
-
le contexte
Left
correspond àNothing
, à la différence qu’il contient une valeur -
le contexte
Right
correspond àJust
D’ailleurs, la bibliothèque sanctuary propose des fonctions utilitaires permettant de passer aisément d’un type à l’autre :
1
2
3
4
5
6
7
8
9
10
11
S.maybeToEither ('Expecting a value') (S.Nothing)
// --> Left ('Expecting a value')
S.maybeToEither ('Expecting a value') (S.Just (42))
// --> Right (42)
S.eitherToMaybe (S.Left ('Cannot divide by zero'))
// --> Nothing
S.eitherToMaybe (S.Right (42))
// --> Just (42)
Le type Future
de la bibliothèque fluture-js
Pour gérer l’asynchronisme, on utilise généralement les Promises
. Cet objet pourrait s’apparenter en programmation fonctionnelle à un conteneur de données avec les deux contextes suivants :
-
Resolved : la promesse est résolue, le traitement asynchrone est terminé et peut éventuellement contenir un résultat
-
Rejected : la promesse est rejetée, le traitement a échoué et peut éventuellement contenir une valeur décrivant la raison de cet échec (pas nécessairement un objet ni une exception)
Mais il y a un problème : sa méthode .then()
permet d’effectuer trois opérations distinctes :
-
Appliquer une fonction sur la valeur asynchrone :
1 2
const getPrice = Promise.resolve(10) // Promise(10) const getPriceVAT = getPrice().then(p => p * 1.2) // Promise(12)
-
Appliquer une fonction asynchrone sur la valeur asynchrone :
Si le résultat de la fonction passée à
then
est unePromise
, ces deux promesses seront automatiquement chaînées.1 2 3 4 5 6 7 8
const getPriceInUSD = Promise.resolve(10) const getUSDToEURExchangeRate = Promise.resolve(0.97) const getPriceInEUR = priceInUSD => priceInUSD .then(price => getUSDToEURExchangeRate() .then(rate => rate * price) ) getPriceInEUR(getPriceInUSD()) // -> Promise(9.7)
Dans cet exemple, lorsque la promesse renvoyée par
getPriceInUSD
est résolue, son résultat est passé àgetUSDToEURExchangeRate
qui renvoie elle aussi une promesse : la résolution de celle-ci déclenchera la résolution de la promesse finale, avec le résultat attendu. -
Réagir à un valeur asynchrone :
Généralement lorsqu’on ne retourne rien dans la fonction passé au
then
, mais qu’on veut afficher le résultat, par exemple dans la console :1 2 3 4
const getArticlePrice = id => {...} // number -> Promise<number> getArticlePrice(1) .then(price => console.log(price)) .catch(err => console.error(err))
Le type Future
de la bibliothèque fluture-js
permet de faire la même chose qu’avec une Promise
, mais il sépare ces opérations en trois méthodes distinctes, et est conforme à la spécification Fantasy-land, ce qui le rend parfaitement compatible avec la bibliothèque sanctuary vue plus haut.
1
data Future a b = Reject a | Resolve b
Si l’on reprend les trois cas de figures vus plus haut, voici comment ils sont respectivement traités grâce au type Future
:
-
Via la méthode
map
:1 2 3 4
const getPrice = { ... } // Resolve(10) const getPriceVAT = F.map(price => price * 1.2) getPriveVAT(getPrice()) // Resolve(12)
-
Via la méthode
chain
:1 2 3 4 5 6 7 8
const getPriceInUSD = { ... } // Resolve(10) const getUSDToEURExchangeRate = _ => { ... } // Resolve(0.97) const getPriceInEUR = F.chain(price => F.map(rate => rate * price)(getUSDToEURExchangeRate()) ) getPriceInEUR(getPriceInUSD()) -> // Resolve(9.7)
-
Via la méthode
fork
:1 2 3 4 5 6
const getArticlePrice = id => {...} // number -> Future string number F.fork (console.error) (console.log) (getArticlePrice(1)) // > 45.00 F.fork (console.error) (console.log) (getArticlePrice(-1)) // > 'No article with id -1 exists'
Il y a plusieurs méthodes disponibles pour créer des futures :
-
Via le constructeur
Future
-
À partir d’une
Promise
grâce à la fonctionencaseP
-
À partir d’un callback Node.js grâce à la fonction
node
Il existe également des shorthands pour créer des futures, comme resolve
, reject
, resolveAfter
, etc.
Je ne peux que vous conseiller leur excellente documentation pour approfondir les possibilités offertes par cette bibliothèque : fluture-js/Fluture.
Dans le prochain épisode …
Nous verrons comment tous les concepts abordés jusqu’à présent peuvent être appliqués au travers d’un exemple d’API Web.
Restez connectés ! 😉