Nous avons vu comment écrire des programmes qui acheminent des données au travers de fonctions pures. Ils constituent une forme déclarative des spécifications de comportements attendus. Qu'en est-il de la gestion des erreurs, du contrôle de l'exécution, des actions asynchrones, des états et, allons jusque-là, des effets ?! Dans ce chapitre, nous bâtirons les fondations au-dessus desquelles sont construites toutes ces abstractions importantes.
En premier lieu, nous allons créer un contenant. Il doit pouvoir contenir n'importe quel type de valeur; Il y a fort à parier qu'un bocal qui ne peut contenir que du pâté serve énormément. Il s'agira d'un objet bien que nous nous refuserons à lui affecter des propriétés et des méthodes au sens de l'orientée-objet. Non, nous le traiterons comme un coffre-fort - une boîte singulière qui renferme notre bien-aimée donnée.
var Container = function(x) {
this.__value = x;
}
Container.of = function(x) { return new Container(x); };
Voilà notre premier contenant. Nous l'avons en toute connaissance de causes nommé Container
.
Nous utiliserons Container.of
comme constructeur en lieu et place de l'horrible mot-clé
new
. Il y a plus à raconter sur cette fonction of
qu'il n'y paraît, pour le moment
ceci-dit, elle n'est qu'une façon propre de placer une valeur au sein du contenant.
Jouons un peu avec notre nouvelle boîte flambant neuve...
Container.of(3)
//=> Container(3)
Container.of("hotdogs")
//=> Container("hotdogs")
Container.of(Container.of({name: "yoda"}))
//=> Container(Container({name: "yoda" }))
Si vous utilisez node, vous verrez {__value: x}
bien qu'il s'agisse d'un Container(x)
.
Chrome affichera le type correctement mais peu importe tant que nous comprenons ce à quoi
ressemble un Container
. Au sein de certains environnements, vous avez la possibilité de réécrire
la méthode inspect
mais nous n'irons pas jusque-là. Pour les besoins du livre ceci dit, nous
présenterons la valeur de sortie théorique comme si nous avions modifié la méthode inspect
en
conséquence; ce sera au moins plus intuitif que {__value: x}
sinon à la fois plus esthétique
et pédagogique.
Clarifions quelques points avant de creuser encore le sujet:
-
Container
est un objet avec une unique propriété. De nombreux contenants ne contiennent qu'une seule chose mais n'en faites pas un état de fait. Nous avons de plus complétement arbitrairement appelé cette propriété__value
. -
La valeur
__value
ne peut être restreinte à un type spécifique. Le cas échéant, nous aurions défini un bien piètre contenant. -
Une fois capturée par le
Container
, la donnée y reste. Il serait hypothétiquement envisageable d'y accéder via__value
mais ce serait violer l'essence même du contenant.
Les motivations qui nous animent ici deviendront bientôt claires comme de l'eau de roche. Pour l'heure je vous demande de me faire confiance.
Quelque soit notre valeur, une fois dans son contenant, il nous faut lui appliquer des fonctions.
// (a -> b) -> Container a -> Container b
Container.prototype.map = function(f){
return Container.of(f(this.__value))
}
Grandement inspirée de la fameuse méthode map
propre aux listes, cette méthode fonctionne de
façon similaire mais s'applique cependant à un Container a
plutôt qu'à un [a]
.
Container.of(2).map(function(two){ return two + 2 })
//=> Container(4)
Container.of("flamethrowers").map(function(s){ return s.toUpperCase() })
//=> Container("FLAMETHROWERS")
Container.of("bombs").map(_.concat(' away')).map(_.prop('length'))
//=> Container(10)
Il est donc possible de travailler avec notre valeur sans jamais avoir à quitter le contenant.
C'est tout à fait prodigieux. La valeur au sein du contenant est simplement transmise à la
fonction map
de telle sorte que l'on puisse la manipuler à notre guise avant de la replacer
au chaud dans sa boîte. Conserver la valeur au sein de son contenant a plusieurs avantages.
Entre autres, il nous est possible de continuer à appliquer des fonctions à l'aide de map
autant que nous le voulons. Il n'y a aucun problème non plus à changer le type de la valeur à
l'intérieur du contenant comme l'ont montré les trois exemples précédents.
Mais au fait, si l'on appelle map
, c'est que quelque part sous le capot on fait appel à de la
composition ! Quelle sorcellerie mathématique est à l'oeuvre ici ? Bravo, nous venons de
découvrir les Foncteurs.
Un Foncteur est un type qui implémente
map
et obéit à quelques lois
Oui, un Foncteur n'est rien de plus qu'une interface avec un contrat. On pourrait tout aussi
bien l'avoir appelé Mappable
mais Foncteur rime avec bonheur, n'est-ce pas ? Les
Foncteurs proviennent tout droit de la théorie des catégories et bien entendu, nous aborderons
les détails techniques mathématiques à ce sujet d'ici la fin du chapitre. Pour l'instant,
tâchons d'en développer une intuition pratique et de comprendre ce qu'il se cache derrière
cette interface un tant soit peu étrange.
Quelle raison saugrenue peut bien nous pousser à encapsuler une valeur de la sorte pour ensuite
interagir avec elle via map
? La réponse est dans la question pour peu qu'on prenne la peine
de la reformuler: Que gagnons-nous à demander au contenant d'appliquer les fonctions à notre
place ? Eh bien, c'est un niveau d'abstraction supplémentaire dans l'application de fonctions.
Il nous suffit d'appliquer une fonction et le contenant se charge de l'appliquer pour nous en
prenant soin de gérer le type. C'est un concept puissant, le voyez-vous ?
Container
est relativement morne. En réalité, on le présente d'ordinaire comme Identity
et
agit de façon similaire à la fonction id
(au risque de me répéter, sachez qu'il y a également
un lien mathématique entre ces deux entités, lien que nous expliciterons en temps voulus). Il
existe néanmoins bien d'autres foncteurs c'est-à-dire, des contenants typés qui possèdent leur
propre implémentation de la fonction map
laquelle leur confère des propriétés intéressantes.
Voyons-en un nouveau dès à présent:
var Maybe = function(x) {
this.__value = x;
}
Maybe.of = function(x) {
return new Maybe(x);
}
Maybe.prototype.isNothing = function() {
return (this.__value === null || this.__value === undefined);
}
Maybe.prototype.map = function(f) {
return this.isNothing() ? Maybe.of(null) : Maybe.of(f(this.__value));
}
Maybe
ressemble presque trait pour trait à Container
avec pour seule petite différence
qu'il vérifie la présence d'une valeur avant d'y appliquer la fonction fournie. De cette façon,
les valeurs null
ou undefined
sont écartées du traitement (en outre, cette implémentation
est une version simplifié pour les besoins de l'apprentissage).
Maybe.of("Malkovich Malkovich").map(match(/a/ig));
//=> Maybe(['a', 'a'])
Maybe.of(null).map(match(/a/ig));
//=> Maybe(null)
Maybe.of({name: "Boris"}).map(_.prop("age")).map(add(10));
//=> Maybe(null)
Maybe.of({name: "Dinah", age: 14}).map(_.prop("age")).map(add(10));
//=> Maybe(24)
Vous remarquerez que notre application ne s'effondre pas dès lors que l'on applique nos
fonctions sur des valeurs nulles via map
. C'est en effet le rôle du Maybe
que de vérifier la
présence d'une valeur au sein du contenant avant d'y appliquer la fonction passée en
argument.
Par ailleurs, cette syntaxe n'est pas des plus fines et fonctionnelles; par conséquent et en accord
avec ce qui fut mentionné en première partie de ce livre, il serait bon d'encourager une
écriture pointfree. Comme par hasard, map
est parfaitement équipée pour pallier ce
contretemps et déléguer le travail au foncteur qu'elle reçoit:
// map :: Functor f => (a -> b) -> f a -> f b
var map = curry(function(f, any_functor_at_all) {
return any_functor_at_all.map(f);
});
C'est fantastque car nous pouvons désormais à nouveau procéder à des compositions et map
fonctionnera comme prévue. C'est d'ailleurs aussi le cas avec la méthode map
fournie par
ramda. Nous utiliserons la notation "classique" avec point lorsqu'elle est forte de sens, et la
notation pointfree le reste du temps car plus pratique. Au fait, avez-vous remarqué
quelque chose de singulier ? J'ai introduit en douce une petite notation dans la signature de
type. La partie Functor f =>
indique que f
désigne une foncteur dans le reste de la
signature. Intuitif mais une petite explication ne fait pas de mal.
Dans la nature, on utilise essentiellement Maybe
avec des fonctions dont l'issue n'est pas a
priori garantie.
// safeHead :: [a] -> Maybe(a)
var safeHead = function(xs) {
return Maybe.of(xs[0]);
};
var streetName = compose(map(_.prop('street')), safeHead, _.prop('addresses'));
streetName({addresses: []});
// Maybe(null)
streetName({addresses: [{street: "Shady Ln.", number: 4201}]});
// Maybe("Shady Ln.")
safeHead
agit comme notre classique _.head
, sauf que la valeur de retour est encapsulée
dans un type plus sûr. L'introduction du Maybe
dans notre code fait curieusement apparaître
quelque chose; il nous faut désormais traiter les valeurs nulles pouvant surgir inopinément.
La fonction safeHead
est transparente et nous affiche clairement sa propension à l'erreur en
retournant un Maybe
. D'ailleurs, non seulement nous informe-t-elle d'un retour
potentiellement erroné mais aussi nous oblige-t-elle à utiliser map
pour accéder à la valeur
retournée emprisonnée dans son contenant. Intuitivement, il s'agit d'une vérification de la
présence de la valeur dictée par la fonction safeHead
elle-même. On peut ainsi dormir sur nos
deux oreilles sachant qu'il ne risque pas de surgir de valeur nulle comme par magie. Ce genre
de méthodes peut significativement améliorer une application faite de bric et de broc en
quelque chose de nettement plus robuste. Par ce procédé, on garantit un programme plus sûr.
Parfois une fonction retournera un Maybe(null)
afin de signaler explicitement un cas
d'erreur. Par exemple:
// withdraw :: Number -> Account -> Maybe(Account)
var withdraw = curry(function(amount, account) {
return account.balance >= amount ?
Maybe.of({balance: account.balance - amount}) :
Maybe.of(null);
});
// finishTransaction :: Account -> String
var finishTransaction = compose(remainingBalance, updateLedger); // <- ces fonctions composées sont hypothétiques et non-encore implémentées.
// getTwenty :: Account -> Maybe(String)
var getTwenty = compose(map(finishTransaction), withdraw(20));
getTwenty({ balance: 200.00});
// Maybe("Your balance is $180.00")
getTwenty({ balance: 10.00});
// Maybe(null)
Si nous somme à court d'argent, withdraw
nous le fera savoir en remontant un Maybe(null)
.
Elle le fera de telle sorte que son caprice sera entendu jusqu'en bout de chaîne en nous
obligeant à appliquer map
pour chaque fonction en suivant. À la fin, nous rendons
effectivement compte de l'erreur et c'est notre application qui s'arrête correctement. S'il y a
bien quelque chose à comprendre de cet exemple, c'est que si withdraw
échoue, alors les
appels à map
servent de bouclier et empêchent l'exécution de tout autre fonction (comme
finishTransaction
). C'est précisément le comportement que nous espérons. Mettre à jour le
solde de notre compte alors que nous possédons des fonds insuffisants n'est pas du meilleur
effet.
Ce que les gens oublient parfois c'est qu'à un moment donné, on atteint le bout de la chaîne; des fonctions effectives qui transmettent du JSON, affichent à l'écran ou encore altèrent le système de fichiers. À ce moment précis, il nous est impossible de retourner un résultat, il nous faut exécuter des fonctions qui pourront communiquer avec le monde extérieur. Tel un sage Buddhiste, nous pouvons le formuler comme le Kôan Zen suivant: "Si un programme ne possède aucun effet observable, s'exécute-t-il seulement ? ". S'exécute-t-il selon son propre désir ? Sans effet observable, il consomme au mieux quelques cycles CPU avant de retourner au repos.
Le rôle de notre application est de récupérer, transformer et d'amener un ensemble de données
jusqu'à ce qu'il soit l'heure de leur dire au revoir. Ce faisant, nous appliquons
successivement des fonctions grâce à map
de telle sorte que les données n'ont guère besoin de
quitter leur chaleureux contenant. Une erreur assez commune consiste à essayer de retirer la
valeur du Maybe
d'une façon ou d'une autre en espérant que celle-ci se matérialise comme par
enchantement. Il faut bien comprendre qu'il existe potentiellement une exécution du programme
dans laquelle la valeur n'ira pas vivre sa destinée. Notre code est comme le chat de
Schrödinger, simultanément dans deux états et nous nous devons de maintenir cette distinction
jusqu'à l'exécution de la fonction finale. De cette façon le code demeure linéaire et l'on
s'évite des branchements logiques.
Il existe toutefois une échappatoire. Si l'on souhaite effectivement retourner une valeur particulière et continuer, on peut le faire grâce à la petite fonction suivante:
// maybe :: b -> (a -> b) -> Maybe a -> b
var maybe = curry(function(x, f, m) {
return m.isNothing() ? x : f(m.__value);
});
// getTwenty :: Account -> String
var getTwenty = compose(
maybe("You're broke!", finishTransaction), withdraw(20)
);
getTwenty({ balance: 200.00});
// "Your balance is $180.00"
getTwenty({ balance: 10.00});
// "You're broke!"
Dorénavant, nous retournons ou bien une valeur statique (néanmoins du même type que celle
retournée par finishTransaction
) ou bien, nous menons la transaction à bien cette fois-ci
sans Maybe
. Avec Maybe
, nous reflétons un branchement équivalent à un if/else
pour lequel
map
correspond de façon moins impérative à: if (x !== null) { return f(x) }
.
L'introduction de Maybe
peut être dure à appréhender de premier abord. Les utilisateurs de
Swift et Scala savent bien de quoi je parle vu que le même concept est présent via les
bibliothèques natives au travers des Option(al)
. Lorsque l'on en vient à devoir traiter un
ensemble de vérifications à null
à la suite (même lorsque l'on sait parfois que la valeur ne
peut simplement pas être nulle), la plupart des gens n'ont aucune solution mais ressentent
toutefois bien la pénibilité de l'écriture d'un tel code. Avec Maybe
, vous prendrez vite
l'habitude à tel point que c'en deviendra une seconde nature. Après tout, la plupart du temps
ce sera un bien pour un moindre mal.
Développer un programme non fiable, c'est tout aussi stupide que de prendre le temps de décorer
des oeufs avec de la peinture avant de les jetez au milieu de la route. C'est une bonne chose
que de vouloir apporter un peu de robustesse à nos fonctions et Maybe
est la pour ça.
Je manquerais à mes responsabilités si je ne vous précisais pas que la "réelle" implémentation
de Maybe
séparera effectivement la valeur selon deux types: un pour ladite valeur, et l'autre
pour l'absence de cette valeur. Ceci nous offre une plus grande souplesse dans l'application
dans l'utilisation de map
en permettant à des valeurs nulles comme null
ou undefined
d'être également mappées. Ainsi, la dénomination universelle de foncteur est respectée. Vous
rencontrerez plus fréquemment des types tels que Some(x) / None
ou Just(x) / Nothing
plutôt
qu'un Maybe
qui effectue une bête comparaison à null
.
Ce peut être surprenant mais, throw/catch
n'est pas vraiment pure. Lorsqu'une erreur surgit,
nous tirons la sonnette d'alarme plutôt que de retourner une valeur de sortie ! La fonction
agresse d'un millier de 1 et de 0 notre application en réponse à l'entrée qu'on lui a fourni.
Avec l'aide de notre nouvel ami Either
, nous pouvons faire mieux que de déclarer la guerre à
nos entrées. Nous pouvons répondre un message à la fois plus poli et plus adéquat. Regardons
cela de plus près.
var Left = function(x) {
this.__value = x;
}
Left.of = function(x) {
return new Left(x);
}
Left.prototype.map = function(f) {
return this;
}
var Right = function(x) {
this.__value = x;
}
Right.of = function(x) {
return new Right(x);
}
Right.prototype.map = function(f) {
return Right.of(f(this.__value));
}
Left
et Right
sont deux sous-classes d'un type plus abstrait nommé Either
. Je vous
épargne la création de la classe mère Either
étant donné que nous n'allons que peu l'utiliser
en tant que telle; n'hésitez pas à la regarder pour vous faire une idée néanmoins. Ceci étant
dit, vous constaterez qu'il n'y a hormis les types que peu de nouveautés. Jetons-y un oeil:
Right.of("rain").map(function(str){ return "b"+str; });
// Right("brain")
Left.of("rain").map(function(str){ return "b"+str; });
// Left("rain")
Right.of({host: 'localhost', port: 80}).map(_.prop('host'));
// Right('localhost')
Left.of("rolls eyes...").map(_.prop("host"));
// Left('rolls eyes...')
Left
matérialise l'adolescent en crise qui refuse toute communication et requête via map
.
Right
fonctionne de façon similaire à Container
(a.k.a Identity). L'intérêt ici vient de la capacité de
Left` à stocker un message d'erreur.
Faisons l'hypothèse d'une fonction qui a des chances d'échouer à l'exécution. Considérons le
calcul d'un âge depuis une date de naissance. On peut utiliser Maybe(null)
afin de signaler
une erreur et aiguiller la suite de notre programme en conséquence mais l'information est
maigre. En connaître davantage sur la nature de l'erreur, voilà ce qui nous intéresse. Voyons
ce que cela donne en utilisant Either
.
var moment = require('moment');
// getAge :: Date -> User -> Either(String, Number)
var getAge = curry(function(now, user) {
var birthdate = moment(user.birthdate, 'YYYY-MM-DD');
if (!birthdate.isValid()) return Left.of("Birth date could not be parsed");
return Right.of(now.diff(birthdate, 'years'));
});
getAge(moment(), {birthdate: '2005-12-12'});
// Right(9)
getAge(moment(), {birthdate: '20010704'});
// Left("Birth date could not be parsed")
De la même façon qu'avec Maybe(null)
nous court-circuitons l'application en retournant un
Left
. Une petite différence, forte de sens néanmoins, c'est que l'on peut maintenant
caractériser avec davantage de précision la nature de l'interruption. Ainsi, vous remarquerez
que l'on indique à présent dans la signature Either(String, Number)
indiquant que le foncteur
renferme un String
en partie gauche, et un Number
en partie droite. Cette signature peut
sembler légérement informelle vu que nous n'avons pas présenté la classe mère Either
. C'est
pourtant assez transparent et l'on comprend aisément ce dont il s'agit.
// fortune :: Number -> String
var fortune = compose(concat("If you survive, you will be "), add(1));
// zoltar :: User -> Either(String, _)
var zoltar = compose(map(console.log), map(fortune), getAge(moment()));
zoltar({birthdate: '2005-12-12'});
// "If you survive, you will be 10"
// Right(undefined)
zoltar({birthdate: 'balloons!'});
// Left("Birth date could not be parsed")
Lorsque birthdate
est valide, le programme nous dévoile sa mystérieuse bonne fortune. Sinon,
nous récupérons un bon vieux message d'erreur cependant encore dans son contenant. D'une
certaine façon, c'est un peu comme avant lorsqu'une erreur était levée sauf qu'à présent elle
l'est calmement, poliement et sans excès là oú autrefois elle nous hurlait à la figure sans
aucun respect à la manière d'une enfant turbulent.
Dans cet exemple, nous effectuons un branchement logique selon la potentielle validité de la
date de naissance. Il se lit toutefois d'un trait, linéairement sans avoir à visuellement
jongler entre les accolades d'une structure conditionnelle. On préférera usuellement déporter
l'appel à console.log
en dehors de notre fonction zoltar
pour l'appliquer via map
au
niveau de chaque appel. C'est toutefois intéressant de rendre compte de la divergence de la
branche droite Right
. Dans la signature de type de cette même branche, nous utilisons \_
pour désigner un type qui n'a aucune importance car ignoré. (Dans certains navigateurs, vous
aurez à utiliser console.log.bind(console)
afin de l'utiliser en first-class).
J'aimerai prendre quelques mots pour mettre l'accent sur quelque chose qui vous a probablement
échappé: La fonction fortune
ici, bien qu'utilisée de paire avec Either
, n'a aucune
connaissance a priori d'une quelconque association avec un foncteur. C'était aussi le cas avec
finishTransaction
dans l'exemple précédent. On peut dire sans trop de formalisme qu'une
fonction classique peut être grâce à map
transforméeen une fonction de foncteur. On dénomme ce procédé
lifting`. Il est souvent plus simple pour des fonctions de travailler avec des
types normaux plutôt que des contenants, puis, d'être liftées vers un contenant adéquat. Il
s'ensuit une plus grande simplicité et réutilisabilité dans le code.
En somme, Either
est approprié pour la gestion des erreurs communes comme les résultats d'une
validation, tout autant qu'il l'est dans la gestion d'erreurs complexes qui conduisent
normalement à une interruption de l'exécution (un fichier manquant ou une socket inaccessible
par exemple). Essayez de remplacer quelques-uns des Maybe
précédents par Either
pour en
améliorer le retour d'information.
En outre, j'ai l'impression d'avoir présenté Either
d'une façon un peu réductrice. Non
seulement nous sert-il à mieux gérer les erreurs, mais aussi représente-t-il une disjonction
logique (a.k.a ||
) au sein d'un type. Il matérialise également l'idée de Coproduit en
théorie des catégories (point que nous n'aborderons pas dans ce livre mais qu'il reste
intéressant de connaître ne serait-ce que pour explorer les propriétés à en exploiter). C'est
une représentation canonique de la somme (ou de l'union disjointe de deux ensembles) en tant
qu'union des quantités représentées par l'association des deux types contenus (je me doute que
ceci doive vous paraître obscure, n'hésitez pas à jeter un oeil à cet excellent
article).
Either
peut donc représenter de nombreuses choses mais en tant que foncteur, on l'associe
naturellement à la gestion d'erreur.
Enfin, tout comme avec Maybe
, nous considérons la petite either
qui agit similairement mais
cette fois-ci à l'aide de deux fonctions et d'une valeur statique. Chaque fonction doit
bien-entendu avoir un même type de retour.
// either :: (a -> c) -> (b -> c) -> Either a b -> c
var either = curry(function(f, g, e) {
switch(e.constructor) {
case Left: return f(e.__value);
case Right: return g(e.__value);
}
});
// zoltar :: User -> _
var zoltar = compose(console.log, either(id, fortune), getAge(moment()));
zoltar({birthdate: '2005-12-12'});
// "If you survive, you will be 10"
// undefined
zoltar({birthdate: 'balloons!'});
// "Birth date could not be parsed"
// undefined
Finalement une utilisation de cette mystérieuse fonction id
. Rien de plus qu'un simple
perroquet qui retransmet la valeur contenue dans Left
afin d'être affichée via console.log
.
En renforçant la gestion de nos erreurs depuis getAge
, nous avons rendu notre diseuse de
bonne fortune plus robuste. Ou bien nous mettons l'utilisateur devant le fait accompli en lui
révélant la dure vérité liée à son erreur, ou bien nous exécutons la suite d'actions attendues.
À présent, nous voilà prêt à migrer vers une nouvelle famille de foncteurs.
Dans le chapitre à propos de la pureté nous avons écrit une fonction relativement étrange. La fonction original possédait un effet de bord et nous l'avons encapsulée dans une autre fonction qui retournait la première. Dans la même idée, nous avons:
// getFromStorage :: String -> (_ -> String)
var getFromStorage = function(key) {
return function() {
return localStorage[key];
}
}
Si l'on n'avait pas emprisonné les entrailles de notre fonction dans une autre, sa réponse
pourrait vraisemblablement varier selon le contexte. En revanche, notre petit mécanisme nous
assure une réponse toujours identique (pour une même entrée bien-entendu) qui n'est ni plus ni
moins qu'une fonction qui ne fois appelée nous retournera une entrée contenue dans notre
localStorage
. Grâce à cela, nous continuons l'esprit tranquille le reste de notre application.
Toutefois il faut bien avouer que telle quelle la fonction ne nous sert pas à grand-chose. Tout
comme la figurine maintenue dans son emballage il nous est impossible de jouer avec. Si
seulement il nous était possible d'atteindre le contenu de ce paquet sans attendre la fin pour
tout déballer... C'est ici que IO
entre en jeu.
var IO = function(f) {
this.__value = f;
}
IO.of = function(x) {
return new IO(function() {
return x;
});
}
IO.prototype.map = function(f) {
return new IO(_.compose(f, this.__value));
}
IO
se distingue des précédents foncteurs de par la nature de sa valeur \_\_value
, toujours
une fonction. C'est toutefois davantage un détail d'implémentation et l'on s'y réfère comme à
une fonction. Ce que l'on observe réellement c'est que tout comme getFromStorage
, IO
retarde l'exécution de l'action impure en la capturant au sein d'une fonction. Par conséquent
on perçoit IO
comme contenant le résultat de l'action encapsulée plutôt que la fonction
englobante. Cela saute aux yeux avec la méthode of
: nous obtenons un IO(x)
même si sous le
capot, le mécanisme IO(function(){ return x })
est nécessaire pour éviter l'évaluation
précoce.
Regardons tout ceci en action.
// io_window :: IO Window
var io_window = new IO(function(){ return window; });
io_window.map(function(win){ return win.innerWidth });
// IO(1430)
io_window.map(_.prop('location')).map(_.prop('href')).map(split('/'));
// IO(["http:", "", "localhost:8000", "blog", "posts"])
// $ :: String -> IO [DOM]
var $ = function(selector) {
return new IO(function(){ return document.querySelectorAll(selector); });
}
$('#myDiv').map(head).map(function(div){ return div.innerHTML; });
// IO('I am some inner html')
Ici, io\_window
est bel et bien un IO
sur lequel on peut mapper directement alors que $
est une fonction qui retourne un IO
une fois invoquée. Remarquez que j'ai exposé les valeurs
conceptuelles afin de rendre les expressions plus explicites bien qu'en réalité les IO
s'exprimeront toujours comme { \_\_value: [Function] }
. Lorsqu'on map
sur notre IO
, on ne
fait qu'agréger une fonction en bout d'une composition qui devient la valeur d'un nouvel IO
.
Notre fonction n'est pas exécutée, elle prend simplement sa place au milieu d'une chaîne de
calculs que l'on construit, fonction après fonction à l'image d'un ensemble de dominos que l'on
place bout à bout sans les renverser. Le résultat rappelle le pattern commande du Gang of Four.
Prenez une minute et faites fonctionner votre intuition sur les foncteurs. Si l'on regarde nos expériences passées, on se sent bien confortable à pouvoir mapper sur des contenants dont les bizarreries intrinsèques ne nous sont bien que contingentes.
En outre, il est bien beau d'avoir emprisonnée la bête, il faudra tôt ou tard la libérer. En
composant notre IO
avec de nouvelles fonctions, nous avons créé une bien puissante
quoiqu'impure fonction dont l'invocation risque fort de troubler l'ordre établi. À quel moment
est-il donc adéquat de libérer le Kraken ? Est-il ne serait-ce que possible d'exécuter
notre IO
sans provoquer la fin du monde ? La réponse est oui à partir du moment où le code en
charge de l'appel en prend la responsabilité. Il faut que notre code pur en dépit de sa
fourberie soit préservé et maintenu innocent. C'est l'appelant qui doit également accepter le
fardeau et les responsabilités qui viennent avec les effets de bords liés à l'exécution.
Illustrons concrètement cela par un exemple:
////// Notre ressource de code pure: lib/params.js ///////
// url :: IO String
var url = new IO(function() { return window.location.href; });
// toPairs = String -> [[String]]
var toPairs = compose(map(split('=')), split('&'));
// params :: String -> [[String]]
var params = compose(toPairs, last, split('?'));
// findParam :: String -> IO Maybe [String]
var findParam = function(key) {
return map(compose(Maybe.of, filter(compose(eq(key), head)), params), url);
};
////// La partie de code impure: main.js ///////
// run it by calling __value()!
findParam("searchTerm").__value();
// Maybe([['searchTerm', 'wafflehouse']])
Notre bibliothèque se dédouane de toute responsabilité et conserve sa pureté en encapsulant
url
au sein d'un IO
avant de passer le relais. Vous avez sans doute remarqué que nous avons
comme qui dirait empilé nos contenants; c'est toutefois tout à fait raisonnable d'avoir un
IO(Maybe([x]))
qui se révèle être un foncteur à trois niveaux riches de sens (Array
peut somme toute s'interpréter comme un contenant sur lequel map
s'applique).
Il demeure néanmoins un aspect dangereux qui me démange de rectifier dans la précédente
notation. La valeur contenue dans IO
n'est pas vraiment une valeur, ni un quelconque attribut
privé comme le suggèrerait le préfixe devant son nom \_\_value
. En effet, elle représente
sinon une grenade qui attend d'être dégoupillée du moins une portion de code qui mérite d'être
désignée en conséquence. Renommons celle-ci en unsafePerformIO
afin de ne pas perdre de vue
son potentiel destructeur.
var IO = function(f) {
this.unsafePerformIO = f;
}
IO.prototype.map = function(f) {
return new IO(_.compose(f, this.unsafePerformIO));
}
Voilà qui est bien mieux. Notre code appelant se transforme en
findParam("searchTerm").unsafePerformIO()
ce qui est de prime abord bien plus alertant.
IO
sera notre fidèle compagnon qui nous aidera à apprivoiser toutes ces féroces actions
impures à venir. Maintenant, nous armons nous d'un autre type poursuit un but bien différent
mais dans un esprit similaire.
Emprunter la voie des callbacks c'est prendre un aller simple vers l'Enfer. Tels qu'imaginés par M.C. Escher, ils contrôlent le flot d'exécution de l'application. Chacun d'eux tentant de se dépatouiller parmi une jungle hostile d'accolades et de parenthèses. Je deviens malade rien qu'à y penser. Pas d'inquiétude à avoir cela dit, nous avons dans nos rangs des structures bien plus appropriés pour gérer du code asynchrones, et cela commence par un "F".
Vous exposer dès à présent à la machinerie sur laquelle tout ceci repose serait un tantinet
rude. Ainsi nous utiliserons pour l'instant les Data.Task
(autrefois Data.Future
) de
Quildreen Motta et de sa fantastique bibliothèque Folktale. En voici
quelques usages:
// Node readfile example:
//=======================
var fs = require('fs');
// readFile :: String -> Task Error String
var readFile = function(filename) {
return new Task(function(reject, result) {
fs.readFile(filename, 'utf-8', function(err, data) {
err ? reject(err) : result(data);
});
});
};
readFile("metamorphosis").map(split('\n')).map(head);
// Task("One morning, as Gregor Samsa was waking up from anxious dreams, he discovered that
// in bed he had been changed into a monstrous verminous bug.")
// Exemple avec getJSON de JQuery:
//================================
// getJSON :: String -> {} -> Task Error JSON
var getJSON = curry(function(url, params) {
return new Task(function(reject, result) {
$.getJSON(url, params, result).fail(reject);
});
});
getJSON('/video', {id: 10}).map(_.prop('title'));
// Task("Family Matters ep 15")
// Fonctionne aussi avec des valeurs non asynchrones et complètement déterministes
Task.of(3).map(function(three){ return three + 1 });
// Task(4)
Les fonctions que je désigne par reject
et result
matérialisent respectivement nos
callbacks d'erreur et de succès. Comme vous pouvez le constater, nous appliquons nos
fonctions sur la Task
en travaillant avec les valeurs à venir comme si elles étaient déjà à
portée de main. map
doit à présent vous paraître bien familier.
Si vous êtes de plus déjà accoutumé des Promises vous ferez assez vite l'analogie entre map
et then
avec Task
tenant ici le rôle de Promise. Ne vous prenez toutefois pas le chou avec
les Promises, nous n'en utiliserons pas pour la simple et bonne raison qu'elles ne sont pas
pures mais l'analogie reste valable.
À l'instar d'IO
, Task
attendra patiemment notre feu vert avec de s'exécuter conrètement. En
fait, en raison de cette attente, Task
peut se substituer assez facilement à IO
pour
n'importe quel travail asynchrone; readfile
et getJSON
ne requièrent aucun IO
superflu
afin d'être purs. Cerise sur le gâteau, Task
fonctionne parfaitement bien avec map
; on y
dépose des directives futures comme une liste de tâches dans une capsule temporelle - il s'agit
là d'un exemple manifeste de procrastination technologiquement sophistiquée.
Afin de démarrer le processus il nous faut appeler fork
qui agit pareillement à
unsafePerformIO
. En outre, comme son nom le suggère, cette méthode va exécuter la tâche
sans interrompre le processus courant. De fait on peut voir ici de nombreuses façons
d'implémenter ce comportement, notamment à l'aide de Threads, mais il ne s'agira ici que d'un
appel asynchrone classique qui trouvera sa place au milieu de l'Even-Loop déjà en marche.
Penchons-nous sur fork
un peu plus:
// Application pure
//=====================
// blogTemplate :: String
// blogPage :: Posts -> HTML
var blogPage = Handlebars.compile(blogTemplate);
// renderPage :: Posts -> HTML
var renderPage = compose(blogPage, sortBy('date'));
// blog :: Params -> Task Error HTML
var blog = compose(map(renderPage), getJSON('/posts'));
// Exécutions impures
//=====================
blog({}).fork(
function(error){ $("#error").html(error.message); },
function(page){ $("#main").html(page); }
);
$('#spinner').show();
À l'appel de fork
, notre tâche s'active et s'efforce à nous ramener dans les plus brefs
délais quelques articles à afficher sur notre page. Entre parenthèses, il est tout à fait
bienvenu d'afficher une bête animation de chargement pendant ce temps là; nous sommes libre de
le faire étant donné que l'appel à fork
n'est pas bloquant. À la suite de ce petit temps
d'attente, à moins de voir surgir une erreur inopinée, nous afficherons la page.
Arrêtez-vous quelques instants à présent et prenez le temps d'admirer ô combien le flot d'exécution paraît linéaire ici. Tout se lit de haut en bas, de gauche à droite même si techniquement l'exécution n'est pas vraiment séquentielle en fin de compte. Laissons ces sauts être exécutés par le programme et gardons pour nous cette lecture qui nous offre une force de réflexion et de raisonnement immense.
Doux Jésus ! Avez-vous vu cela également ? Task
rend tout simplement Either
caduque dans ce
cas-ci. Et il le faut car notre précédent flot de contrôle ne s'applique plus vraiment dans
le monde asynchrone où l'on doit traiter avec des erreurs potentielles cependant incertaines.
Par chance (si tant est que la chance est à voir là-dedans), les tâches ou futures nous
fournissent d'ores et déjà des mécanismes de gestion d'erreurs.
Loin de moi l'idée de mettre à la retraite nos IO
et Either
après de si courtes carrières
toutefois. Je vous prie d'accepter ce petit exemple qui atténue certains aspects complexes pour
mettre l'accent sur les propos précédents:
// Postgres.connect :: Url -> IO DbConnection
// runQuery :: DbConnection -> ResultSet
// readFile :: String -> Task Error String
// Application pure
//=====================
// dbUrl :: Config -> Either Error Url
var dbUrl = function(c) {
return (c.uname && c.pass && c.host && c.db)
? Right.of("db:pg://"+c.uname+":"+c.pass+"@"+c.host+"5432/"+c.db)
: Left.of(Error("Invalid config!"));
}
// connectDb :: Config -> Either Error (IO DbConnection)
var connectDb = compose(map(Postgres.connect), dbUrl);
// getConfig :: Filename -> Task Error (Either Error (IO DbConnection))
var getConfig = compose(map(compose(connectDb, JSON.parse)), readFile);
// Exécution impure
//=====================
getConfig("db.json").fork(
logErr("couldn't read file"), either(console.log, map(runQuery))
);
Ce petit exemple illustre bien l'utilisation concomitante de Either
et IO
pour gérer la
partie en succès de notre tâche. Task
s'occupe de gérer les impuretés liées à la lecture d'un
fichier de façon asynchrone tandis que nous devons toujours gérer la cohérence de la
configuration à l'aide d'Either
ainsi que la connexion chancelante avec la base de données
via un IO
. De fait, toutes ces structures sont toujours en course lorsque l'on a affaire à
du code synchrone.
On pourrait continuer encore longtemps mais il n'y a plus tellement à raconter à présent.
En pratique vous en conviendrez, il faudra gérer de multiples tâches asynchrones en même temps et nous ne possédons pour l'heure aucun outil nous permettant de faire face à un tel scénario. Cela viendra très prochainement lorsque nous attaquerons les Monades et leurs amis, mais pour l'instant, il nous faut regarder d'un peu plus près les Maths qui rendent tout cela possible.
Nous l'avons évoqué précédemment, les foncteurs proviennent de la théorie des catégories et par conséquent répondent à des lois particulières. Dans un premier temps, explorons ces propriétés:
// identity
map(id) === id;
// composition
compose(map(f), map(g)) === map(compose(f, g));
La loi d'identité est immédiate mais importante. Vous remarquerez que ces lois sont des portions de code que l'on peut exécuter afin d'asseoir leur légitimité.
var idLaw1 = map(id);
var idLaw2 = id;
idLaw1(Container.of(2));
//=> Container(2)
idLaw2(Container.of(2));
//=> Container(2)
Ça fonctionne à merveille ! Passons à la composition:
var compLaw1 = compose(map(concat(" world")), map(concat(" cruel")));
var compLaw2 = map(compose(concat(" world"), concat(" cruel")));
compLaw1(Container.of("Goodbye"));
//=> Container(' world cruelGoodbye')
compLaw2(Container.of("Goodbye"));
//=> Container(' world cruelGoodbye')
En théorie des catégories les foncteurs prennent des éléments et des morphismes d'une catégorie et leur associent une catégorie différente. Par définition, cette nouvelle catégorie se doit de posséder une loi d'identité et de composition pour les morphismes mais ce sont deux propriétés assurées de par les deux lois que l'on vient de voir.
Notre définition de catégorie est sans doute quelque peu vague. Sommairement, une catégorie est
une sorte de réseau d'éléments interconnectés par des morphismes. Ainsi, un foncteur associera
les éléments d'une catégorie à une autre mais en conservant la structure de ce réseau. Soit un
élément a
d'une catégorie source C
que l'on associe à une catégorie D
par le foncteur
F
; on se réfère alors au nouvel élément comme F a
(que retrouve-t-on mis bout-à-bout ?).
C'est peut-être plus clair avec un dessin:
Par exemple, Maybe
associe notre catégorie de types et de fonctions vers une catégorie où
chaque élément peut ne pas exister et où chaque morphisme effectue une vérification à null. Du
point de vue du code, cela se traduit par deux choses: l'utilisation de map
pour passer des
fonctions et l'encapsulation de nos types à l'intérieur de foncteurs. Au sein de ce nouveau
monde, les lois précédentes nous assurent que nos types classiques et nos fonctions seront
encore composables. Techniquement, chaque foncteur de notre code nous amène vers une
sous-catégorie de types et de fonctions de telle façon que l'on nomme ces foncteurs des
endofoncteurs. Afin de conserver les choses aussi simples que possible nous considérerons ces
sous-catégories comme de nouvelles catégories à parts entières.
Il est possible de visualiser les associations d'un morphisme et de ses éléments correspondant au travers du schéma suivant:
Non seulement nous visualisons l'association d'un morphisme vers une nouvelle catégorie au
moyen du foncteur F
, mais nous pouvons aussi constater que le diagramme commute, c'est-à-dire
que l'ordre dans lequel on emprunte les flèches n'a pas d'importance, on arrive au même
résultat. Des chemins différents reflètent des comportements différents mais aboutissent en fin
de compte à un même type. Ce formalisme nous offre par ailleurs une certaine aptitude à
raisonner sur notre code sans avoir à réellement examiner tous les scénarios possibles vu
qu'ils conduisent tous au même résultat. Prenons un petit exemple:
// topRoute :: String -> Maybe String
var topRoute = compose(Maybe.of, reverse);
// bottomRoute :: String -> Maybe String
var bottomRoute = compose(map(reverse), Maybe.of);
topRoute("hi");
// Maybe("ih")
bottomRoute("hi");
// Maybe("ih")
Ou encore plus visuellement:
Les foncteurs peuvent aussi s'accumuler ainsi:
var nested = Task.of([Right.of("pillows"), Left.of("no sleep for you")]);
map(map(map(toUpperCase)), nested);
// Task([Right("PILLOWS"), Left("no sleep for you")])
Ce que nous avons ici est une liste d'éléments potentiellement en erreur imbriquée dans une
future. On utilise map
chaque fois pour pénétrer l'une des structures afin d'appliquer notre
fonction sur les éléments en racine. Il n'y a aucun callback, ni if/else, ni boucle
d'ailleurs; ni plus ni moins qu'un contexte explicite. Il nous faut toutefois appliquer trois
fois l'opérateur map
ce qui, je vous l'accorde, est un peu exagéré. On peut en revanche
composer des foncteurs pour y remédier. Oui, vous m'avez bien entendu:
var Compose = function(f_g_x){
this.getCompose = f_g_x;
}
Compose.prototype.map = function(f){
return new Compose(map(map(f), this.getCompose));
}
var tmd = Task.of(Maybe.of("Rock over London"))
var ctmd = new Compose(tmd);
map(concat(", rock on, Chicago"), ctmd);
// Compose(Task(Maybe("Rock over London, rock on, Chicago")))
ctmd.getCompose;
// Task(Maybe("Rock over London, rock on, Chicago"))
Il n'y a ici plus qu'un seul appel à map
. La composition sur les foncteurs est de plus
associative. Souvenez-vous plus tôt, nous avions défini un foncteur particulier que nous avions
appelé Container
mais qui n'est en realité rien de plus que le foncteur Identité
.
Normalement, cela sonne une petite cloche en vous: si nous avons l'identité et l'associativité
sur la composition alors nous avons de quoi définir une catégorie. Et cette catégorie
particulière possède des catégories comme éléments et des foncteurs comme morphismes; il n'en
faudra guère plus pour faire exploser notre cerveau. Nous ne creuserons pas plus loin de ce
côté là mais fort est de constater la puissance et la beauté de ce que toute cette théorie
implique et permet d'exprimer.
Nous avons vu quelques foncteurs mais vous vous doutez bien qu'il en existe une infinité d'autres. Parmi les omissions notables on retrouve les structures itérables comme les arbres, les listes, les tableaux associatifs, les couples et qu'on se le dise, les streams d'événements et les observables ont aussi leur place chez les foncteurs. Quelques autres servent à l'encapsulation ou expriment tout simplement des types particuliers. Les foncteurs sont omniprésents et nous en ferons désormais une utilisation extensive tout au long de ce livre.
Quelques questions se posent maintenant:
- Peut-on appeler une fonction avec de multiples foncteurs en arguments ?
- Comment gérer une liste ordonnée d'actions asynchrones impures ?
Nous n'avons pas encore toutes les clés en main pour nous aventurer dans ce monde là. Il nous faut à présent porter notre attention sur les monades.
require('../../support');
var Task = require('data.task');
var _ = require('ramda');
// Exercice 1
// ==========
// Utiliser _.add(x,y) et _.map(f,x) pour créer une fonction qui incrémente une valeur à
// l'intérieur d'un foncteur
var ex1 = undefined
//Exercice 2
// ==========
// Utiliser _.head pour récupérer le premier élément de la liste
var xs = Identity.of(['do', 'ray', 'me', 'fa', 'so', 'la', 'ti', 'do']);
var ex2 = undefined
// Exercice 3
// ==========
// Utiliser safeProp et _.head afin de récupérer la première lettre du prénom de l'utilisateur.
var safeProp = _.curry(function (x, o) { return Maybe.of(o[x]); });
var user = { id: 2, name: "Albert" };
var ex3 = undefined
// Exercice 4
// ==========
// Récrire l'exercice 4 à l'aide de Maybe afin d'éviter l'emploi d'une structure conditionnelle
var ex4 = function (n) {
if (n) { return parseInt(n); }
};
var ex4 = undefined
// Exercice 5
// ==========
// Écrire une fonction qui récupérera un article (getPost) et mettra en majuscule le title de l'article
// getPost :: Int -> Future({id: Int, title: String})
var getPost = function (i) {
return new Task(function(rej, res) {
setTimeout(function(){
res({id: i, title: 'Love them futures'})
}, 300)
});
}
var ex5 = undefined
// Exercice 6
// ==========
// En utilisant checkActive() et showWelcome(), écrire une fonction qui débloque un accès ou
// retourne une erreur.
var showWelcome = _.compose(_.add( "Welcome "), _.prop('name'))
var checkActive = function(user) {
return user.active ? Right.of(user) : Left.of('Your account is not active')
}
var ex6 = undefined
// Exercice 7
// ==========
// Écrire une fonction de validation qui vérifie que length > 3. La fonction doit retourner un
// Right(x) le cas échéant ou un Left("You need > 3") sinon.
var ex7 = function(x) {
return undefined // <--- write me. (don't be pointfree)
}
// Exercice 8
// ==========
// Utiliser l'ex7 précédent et Either en tant que foncteur afin d'enregistrer l'utilisateur
// s'il est valide ou échouer en fournissant un message d'erreur. Gardez à l'esprit que les deux
// arguments d'either doivent retourner le même type.
var save = function(x){
return new IO(function(){
console.log("SAVED USER!");
return x + '-saved';
});
}
var ex8 = undefined