TL;DR: Les promesses c’est meilleur avec du sucre fonctionnel.
En réaction à l’article « My five promise patterns », j’avais envie de partager ma façon de faire. En effet si je le rejoins sur une grande partie, je trouve qu’il a oublié d’insister sur des points bien plus essentiels…
Choisir sa librairie, mais rester proche du natif
À part Q (dernier sur tous les benchmarks, sauf quand il y a aussi jQuery) il n’y a pas vraiment de mauvais choix. Personnellement j’utilise souvent Bluebird qui représente pour moi le meilleur rapport fonctionnalités/performances (voire performances tout court). Ceci étant dit, j’ai lancé son benchmark sur ma propre machine et j’ai trouvé des résultats bien différents de ce que l’auteur avance 😉 Je pense donc de plus en plus utiliser when.js.
Moralité : les promesses étant une partie fondamentale (flow control) de votre programme, il ne faut pas se laisser enfermer dans une librairie qui vous priverait de gains de performances significatifs. Donc au final je préfère éviter au maximum d’utiliser les fonctionnalités supplémentaires genre Promise#map()
& co.
La seule méthode qui ne fait pas partie de l’implémentation native est Promise#spread()
que j’utilise souvent en conjonction avec Promise.all()
mais aussi pour retourner des tuples.
Déclarer ses fonctions pour garder un chaînage lisible
Ce conseil, je ne l’applique encore pas assez souvent, mais chaque fois que je le fais je ne le regrette pas : garder une chaîne de promesses propres se fait principalement en passant à Promise#then()
une fonction déjà déclarée.
Pour l’exemple, je ne mettrai aucun commentaire explicatif, de manière à bien se rendre compte du gain apporté. Première version brute :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | readFile() .then(function (content) { return JSON.parse(content); }) .then(function (items) { return items.map(function (item) { return getUser(item.name) .then(function (user) { if (!user) { return createUser(item); } else { return user; } }); }); }) .then(function (users) { return users.map(function (user) { return user.name + " => " + (user.new ? "created" : "found"); }); }) .then(console.log) .catch(console.error) |
Pas évident de comprendre du tout premier coup ce qui est fait. Alors évidemment, garder sa chaîne d’appels propre et courte en ne la polluant pas de déclaration permet de gagner énormément en lisibilité :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | readFile() .then(parseUsersFile) .then(getOrCreateUsers) .then(getUsersReport) .then(console.log) .catch(console.error) function parseUsersFile (content) { return JSON.parse(content); } function getOrCreateUsers (items) { return items.map(function (item) { return getUser(item.name) .then(function (user) { if (!user) { return createUser(item); } else { return user; } }); }); } function getUserReports (users) { return users.map(function (user) { return user.name + " => " + (user.new ? "created" : "found"); }); } |
Déclarer ses fonctions (encore) pour contrôler ses arguments
J’ai banni Function#bind()
depuis un petit moment, déjà parce que c’est l’incarnation de ce qui fait qu’on ne peut avoir confiance en this
(que j’ai banni aussi, depuis encore plus longtemps), et surtout pour sa forme permettant de fixer des arguments. C’est certes plus court, mais c’est à peine plus lisible et ça peut faire des blagues. Bon, avec les promesses on n’a normalement pas le problème puisqu’on sait exactement quel nombre (et normalement type) d’argument sera passé au callback de then()
, mais j’ai gardé ce réflexe, et je trouve maintenant que:
1 2 3 4 5 6 7 | // ceci est plus lisible… function createUser (data) { return DB.insert("users", data); } // …que ceci var createUser = DB.insert.bind(DB, "users"); |
La deuxième version
- ne dit pas directement quels arguments elle attend (nombre, noms, types…),
- ne permet pas de fixer son prototype (c’est tout l’objet de son chapitre Cold calling et de ladite blague précédemment pointée),
- nécessite de répéter
DB
, - et éloigne de 10 caractères au lieu d’un seul la fonction et son premier argument (ça peut sembler con mais c’est de la pollution visuelle)…
Bref, pour moi la deuxième version n’a aucun intérêt, et je conseille vivement de s’en tenir à la déclaration explicite de ses fonctions.
Quel rapport avec les promesses ? On a souvent des besoins génériques sur le résultat d’une promesse, et déclarer des fonctions à chaque fois devient vite relou : appeler une méthode, récupérer une propriété, lancer un mapping, un filtre… Et on est vite tenté de jouer du Function#bind()
pour gagner quelques caractères. C’est à mon sens une erreur.
Déclarer des fonctions (ah oui ?) pour les besoins génériques
J’utilise souvent lodash pour ce genre de choses :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | // Récupération d'une propriété getItem().then(_.property("name")); // Application d'une fonction de transformation sur un tableau (map) getItems().then(_.partialRight(_.map, transformation)); // Application d'un filtre getItems().then(_.partialRight(_.filter, filtre)); // Récupération d'une propriété sur chaque élément d'un tableau getItems().then(_.partialRight(_.pluck, "name")); // Appel d'une méthode sur une liste getItems().then(_.partialRight(_.invoke, "method", args…)); // Appel d'une méthode sur le résultat: ah ben non ^^ getItem().then(function (object) { return object.method(args…); }); // Retourner une autre valeur après le dernier "then" utile faireUnTrucAvec(user).then(faireAutreChoseQuiRetourneUnResultatInutile).then(_.constant(user)) // etc… |
C’est sympa, mais je suis en train de changer de fusil d’épaule car c’est finalement assez lourd à lire. Je préférerais largement ce genre de chose :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | // Récupération d'une propriété getItem().then(get("name")); // Application d'une fonction de transformation sur un tableau (map) getItems().then(map(transformation)); // Application d'un filtre getItems().then(filter(filtre)); // Appel d'une méthode sur le résultat getItem().then(call("method", args…)); // Retourner une autre valeur après le dernier "then" utile faireUnTrucAvec(user).then(faireAutreChoseQuiRetourneUnResultatInutile).then(resolve(user)); // Magie de la programmation fonctionnelle, tout ça se combine ensuite tout seul : // Récupération d'une propriété sur chaque élément d'un tableau getItems().then(map(get("name"))); // Appel d'une méthode sur une liste getItems().then(map(call("method", args…))); // etc… |
Je n’irai pas plus loin dans l’apologie de la programmation fonctionnelle, de peur de me heurter en commentaires à des concepts que mon cerveau n’a pas pas réussi à appréhender (monades & co) ou que j’ai la flemme d’appliquer (mon typage en JS se porte très bien sans Maybe par exemple 😛 ), mais vous voyez l’idée 😉
Choisir une librairie fonctionnelle
J’en ai testé quelques-unes, des « académiques » et des « pragmatiques », et rien ne m’a convenu : l’idéal étant simplement un set de fonctions qui retourneront des fonctions à un paramètre (le résultat de la promesse) pour bien marcher avec le chaînage sans avoir besoin de jouer du bind
ou du partial
. Syndrome NIH ou non, j’ai du coup créé mon propre projet : fun-helpers
Déclarer ses fonctions (sérieux ?) toujours plus haut
J’ai souvent le cas d’une fonction dont le traitement dépend de la valeur promise mais aussi d’une variable dans le contexte, par exemple :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | // Initial version function saveItem (item) { return item.getStorage().then(function (storage) { if (storage) { return storage.persistItem(item); } else { return Storage.create().then(function (storage) { return storage.persisteItem(item); }); } }); } // Refacto #1 function saveItem (item) { function persist (storage) { return storage.persistItem(item); } return item.getStorage().then(function (storage) { if (storage) { return persist(storage); } else { return Storage.create().then(persist); } }); } // Refacto #2 function saveItem (item) { function persist (storage) { return storage.persistItem(item); } function createIfNotFound (storage) { if (storage) { return storage; } else { return Storage.create(); } } return item.getStorage() .then(createIfNotFound) .then(persist); } |
L’objectif « garder une chaînage léger » est atteint. Le jeu est ensuite pour moi de sortir persist
et createIfNotFound
de leur contexte. Par exemple :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | function persist (item) { return function (storage) { return storage.persistItem(item); } } function createStorageIfNotFound (storage) { if (storage) { return storage; } else { return Storage.create(); } } function saveItem (item) { return item.getStorage() .then(createStorageIfNotFound) .then(persist(item)); } |
Pourquoi cette gymnastique ?
- Sortir entièrement une fonction de son contexte permet de faire le tri dans ses dépendances
- Déclarer une fonction oblige à la nommer, et donc à réfléchir à ce qu’elle fait (si trouver un nom est très compliqué, c’est probablement qu’elle en fait trop)
- Sortir les fonctions de leur contexte permet de garder ledit contexte (donc la fonction principale) propre et net
- On rend ces fonctions les plus indépendantes possibles, et donc potentiellement réutilisables
Et histoire de prêcher pour ma paroisse, ça aurait pu donner ça directement avec fun-helpers :
1 2 3 4 5 | function saveItem (item) { return item.getStorage() .then(fun.ifndef(Storage.create)) .then(fun.call("persist", item)); } |
En conclusion
throw
ou reject
je m’en tape, catcher les erreurs est évident (*), par contre organiser correctement son code à l’aide des bons outils me semble plus important et finalement beaucoup moins trivial. La programmation fonctionnelle est indiscutablement à la mode, et si on ne comprend pas forcément immédiatement son intérêt il me semble que dans le cas des Promises cela devient vraiment évident. En conclusion donc voici selon moi tout ce qui importe pour tenir de belles promesses :
- Choisir des outils performants et à son goût
- Programmer fonctionnel
Et en fait c’est même valable hors Promise 😉
(*) P.S: Au sujet des erreurs, il faut quand-même faire très attention que la spec ne dit rien de ce qui doit être fait des erreurs non interceptées justement, elles sont généralement tout simplement ignorées. J’aime beaucoup Bluebird pour sa gestion à mon sens très élégante de ce problème. Je n’ai pas vu beaucoup d’informations à ce sujet sur les implémentations natives, et ça m’embête beaucoup car ça m’a souvent été d’une grande aide en cours de développement.
Intéressant ton article. J’ai pas mal joué avec ça avec Scala (Future/Promise) et je suis en train de m’y mettre en JS (côté client pour le moment).
Je retrouve pas mal de conseils qui s’appliquent aussi en Scala, notamment le fait de « déclarer ses fonctions » et de les extraire de leur contexte. C’est exactement la conclusion à laquelle je suis arrivé dans du code Scala. On gagne beaucoup en lisibilité et réutilisabilité des fonctions.
Par contre, il y a un truc qui me trouble en JS, c’est le nom de la fonction `then()`. En Scala, on l’appelle `map()` car au final, tu transformes une Promise de quelque chose en Promise d’autre chose. Je trouve plus logique que ça porte le même nom que la même opération sur les Array. Mais on s’approche des monades là, alors j’arrête :p
En tout cas, je confirme que « programmer fonctionnel » c’est important. Et en plus on y prend goût !
Question d’habitude sans doute, je suis pas fan du nom
map()
, je trouve ça perturbant de l’appliquer à autre chose qu’un Array… Du coup c’est sans doute cohérent que ça porte un nom différent selon le langage ! Vous ça vous choque pas, map c’est une opération de monade (c’est ça ?) mais nous on n’a pas encore passé ce cap 😉En JS la plupart du temps
Promise#map(foo)
c’est un raccourci spécifique à l’implémentation pour fairePromise#then(array => array.map(foo))
.> on ne peut avoir confiance en this (que j’ai banni aussi, depuis encore plus longtemps)
Pour moi, ça sonne aussi newb qu’un javaïste qui dit « oulala le typage faible c’est pourrit tu peux pas savoir les arguments qu’on te file ». Sans vouloir te vexer, ou alors juste un peu.
Sinon je trouve un peu foufou de devoir insister sur le besoin de déclarer les functions, ou de faire le ménage dans les dépendences :p
Ben il y a toujours un pan de la population des développeurs qui veut un code le plus court possible, quoi qu’il en coûte. C’est ce qui mène à l’utilisation de bind au lieu de passer par des fonctions nommées.
Un autre syndrome (dont je souffre souvent) c’est qu’on est dans le flot d’écriture, on a un map à écrire, on écrit la fonction anonyme sur le fil… J’ai moi-même souvent du mal à m’arrêter, remonter 5-10 lignes plus haut pour déclarer une nouvelle fonction et retourner à la suite de mon code. Mais petit à petit je m’y applique 😉 au lieu d’écrire
.map(function () …)
et de la déplacer ensuite, j’écris.map(transformeBidule)
et j’implémente ensuite 😉Ca c’est le problème de se passer de classes et fonctions déclaratives. Vu qu’on est pas contraint, on part en couilles dans tous les sens.
Bof, c’est un peu en bois cet argument. Le nom de « god object » est pas là pour rien non plus. Y’a moyen de partir en couilles de toute manière, avoir un code lisible et organisé se fait très bien en fonctionel aussi: on groupe les fonctions dans des namespaces relatif aux types de data sur lesquel ces fonctions s’appliquent. Ça marche pas mal.
Après, avec es6 pour les one-liner, quand y’a juste besoin d’un truc genre _.pluck j’utilise
.map(x => x.foo)
c’est du js standard (quoique « nouveau »), et ça se comprend immédiatement.
Est ce que tu connais Ramda (https://www.npmjs.org/package/ramda), c’est un truc similaire à lodash/underscore mais fait de façon fonctionelle.
Par exemple, toutes les fonctions sont automatiquement « curried », et les collections/objets se mettent en dernier (façon clojure/haskell), ce qui rend la composition bien plus simple.
Je ne connaissais pas Ramda, c’est exactement l’esprit de ce qu’il me fallait. Après je n’aime pas trop l’idée du currying automatique (avec tous les if sur le nb d’arguments que ça implique, et les problèmes dès qu’on va passer ça à des fonctions natives qui balancent des petits arguments supplémentaires surprise genre
Array#map
). Mais c’est un contre-argument qui peine à m’auto-convaincre donc j’y viendrai sûrement 😉En attendant je préfère rester sur mon format fixe (fonctions qui retournent une fonction à un argument) qui est de toute façon ce dont j’ai toujours eu besoin même hors promise.
Déclarer ses fonctions
Profiter du hoisting pour mettre le flow du programme en haut (ainsi on lit dès le début ce que le programme fait).
C’est valable pour tout le code qu’on écrit, qu’on soit déjà dans une fonction ou pas.
Concernant la lib à utiliser, j’aime bien l’hyper simple `npm install promise` https://www.npmjs.org/package/promise, qui fournit juste ce qu’il faut.
Comme je le disais, ZE feature de Bluebird qui me semble indispensable c’est le warning en cas d’uncaught error sur des promesses, qui sinon resteraient purement muettes.
J’avoue que je me suis fait avoir 3 fois en un mois sur ça. Je capte pas pourquoi ils ont pas fait quelque chose pour contrer ça. Un throw si pas catché, doit y avoir une bonne raison.. ?
Je le fais à la main, .catch(setTimeout(throw,0))
Cool cet article !!
Je suis aussi partisan du nomage des petites fonctions unitaires pour avoir une chaine de then « human readable » \o/
Je suis amateur du bind et du this, par contre j’affecte toujours le résultat et c’est cette variable que j’utilise, ca permet notament dans le cas de handler de faire le ménage.
Sinon, j’hésite encore à me faire un mini bundle lo-dash (il est possible de généré des bundle avec ce que l’on veut comme features) ou faire une petite lib qui correspond à ce que j’utilise (finallement property / partial)
Check Ramda ou fun-helpers 😉
Dans le dernier cas je la garde ultra light (5K, 22 fonctions, et si tu utilises browserify un require(« fun-helpers/lib/get ») pour avoir juste le bout que tu veux) donc ça peut s’introduire sans douleur (hum).
« Choisir sa librairie » -> « Choisir sa bibliothèque »
désolé :p