Mes 2 patterns autour des promises

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.

14 réflexions au sujet de « Mes 2 patterns autour des promises »

  1. Matthieu Guillermin

    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 !

    Répondre
    1. naholyr Auteur de l’article

      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 faire Promise#then(array => array.map(foo)).

      Répondre
  2. Popy

    > 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

    Répondre
    1. naholyr Auteur de l’article

      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 😉

      Répondre
        1. Greg

          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.

          Répondre
    1. naholyr Auteur de l’article

      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.

      Répondre
    1. naholyr Auteur de l’article

      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.

      Répondre
  3. lionelB

    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)

    Répondre
    1. naholyr Auteur de l’article

      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).

      Répondre

Laisser un commentaire