Blog Arolla

Callbacks strike Back

Cet article se place dans la continuité de l’article précédent: Il y a peut être une option pour continuer ¡¿.
Avant de présenter de nouvelles techniques – les promises / deferred / future – nous commencerons par transposer les techniques vues précédement en javascript. En poussant le bouchon un peu plus loin, nous verrons les limites de Maurice et comment, à force de promesses, des alternatives se proposent. A l’issu de cet article TL;TR, les fonctions de rappel ne devraient plus avoir de secret pour vous!

Preambule: Javascript ça function-ne pas mal!

L’une des richesses de Javascript par rapport à d’autres langages (comme “Java”) est que la function est un objet de première classe: Une fonction est un objet et il est possible de l’affecter à une variable.

var add = function(a,b) { return a + b; }

Il est même possible de définir des fonctions qui renvoient d’autres fonctions, on parle alors de fonction d’ordre supérieur:

var adder = function(a) {
  return function(b) {
    return add(a, b);
  }
};

var add7 = adder(7);
assert(add7(0) === 7);
assert(add2(35) === 42);

Enfin, on peux même définir des fonctions qui prennent d’autres fonctions en paramètres et renvoient des fonctions:

var curry = function(func, a) {
  return function(b) {
    return func(a,b);
  }
}

var add7 = curry(add, 7);
assert(add7(0) === 7);
assert(add7(35) === 42);

Un autre exemple, que l’on rencontre sans doute plus fréquement, en jquery :

$("#login").on('click', function (event) {
    $("#login-pane").show();
});

Une fonction de rappel est invoquée lorsqu’un clic est détecté sur l’élément login; elle affiche alors l’élément login-pane.

Notes:

Les techniques vu précédemment

Première technique :

Ajouter une fonction supplémentaire comme paramètre lors de l’appel d’une méthode. Cette fonction pourra alors être appellée avec le résultat du calcul lorsque celui sera disponible.

Reprenons le code Java de notre première technique et transposons le en un équivalent javascript:

public class QuizService {
 ...
 public void create(String quizContent, Effect<Quiz> effect) {
 Quiz quiz = quizFactory.create(nextId(), quizContent);
 effect.e(quiz);
 }
}

devient:

QuizService.prototype.create = function(quizContent, callback)
{
  var factory = this.quizFactory;
  var quiz = factory.create(this.nextId(), quizContent);
  callback(quiz);
}

La transposition est relativement claire. Illustrons cela par un exemple d’appel:

quizService.create("<question4aChampion>...", function(quiz) {
  displayQuiz(quiz);
});

Une fois le quiz créé, il est passé en paramètre à notre fonction qui se contente de demander son affichage. Comme les arguments sont identiques cela peux même se réduire à (n’oubliez pas qu’une fonction est un objet de première classe):

quizService.create("<question4aChampion>...", displayQuiz);

Passons rapidement à la deuxième technique:

Deuxième technique :

La fonction de rappel est définie comme prenant un résultat dont le type peut varier en fonction du déroulement du calcul…

public class QuizService {
  public void create(String quizContent,
                     Effect<Either<Quiz,Failure>> effect) {
    if(quizIsUnique(quizContent)) {
      Quiz quiz = quizFactory.create(nextId(), quizContent);
      Either<Quiz,Failure> left = Eithers.left(quiz);
      effect.e(left);
    }
    else {
      // Failure is a Pojo but could
      // also be an UniqueConstraintException
      Failure failure = new Failure(Code.NonUniqueQuiz);
      Either<Quiz,Failure> right = Eithers.right(failure);
      effect.e(right);
    }
  }
}

Là, les choses vont commencer à se simplifier:

QuizService.prototype.create = function(quizContent, callback)
{
  if(this.quizIsUnique(quizContent)) {
    var factory = this.quizFactory;
    var quiz = factory.create(this.nextId(), quizContent);
    callback(null, quiz);
    // or callback.apply(null, [null, quiz]);
  }
  else {
    var failure = new Failure(Code.NonUniqueQuiz);
    callback(failure);
    // or callback.apply(null, [failure]);
  }
}

Pour aller plus loin: le Rituel d’invocation

du direct, un peu d’apply, un zeste de call et une pincée de this Il existe plusieurs façon d’invoquer une fonction lorsque l’on dispose d’une reference sur celle-ci. Il est possible de l’invoquer directement lorsque l’on connait les arguments exacts auxquels elle s’attend: callback(null, quiz). Dans ce cas tout se passe bien tant que l’on ne se soucie guère de la notion de this. L’instance référencée par this au moment de l’execution du callback est alors non maitrisé, ce qui dans la plupart des cas ne pose pas de réel problème tant qu’on ne l’utilise pas. En revanche quand on est amené à utiliser le this il devient alors indispensable de connaitre ce qu’il référence. Ce qui est typiquement le cas en JQuery:

$(".button").click(function() {
 var buttonId = $(this).attr("id");
 console.log( "Button clicked: " + buttonId);
});

Ligne 2, le this référence l’élément qui a été cliqué. C’est JQuery qui se charge d’invoquer la fonction de rappel en lui associant en this l’élément qui vient d’être cliqué. Cette association se fait par l’intermédiaire des fonctions apply et call (Eh oui une fonction est un objet à part entière et dispose elle-même de fonctions!).

Il existe enfin une petite subtilité, en définissant le this on ne définit pas seulement la valeur d’une variable, mais aussi l’objet sur lequel la fonction est invoquée. Les plus curieux pourront donc consulter les articles “8.7.3 The call() and apply() Methods – Javascript: The Definitive Guide” et Function.prototype.apply method pour plus d’informations sur les subtilités de ces fonctions. On pourra aussi consulter (8.3.2 Variable-Length Argument Lists: The Arguments Object – Javascript: The Definitive Guide) sur leur usages conjointement avec la variable spéciale arguments, ce qui permet de traiter efficacement les fonctions dont le nombre de paramètres peut varier d’une invocation à l’autre.

Qu’avons-nous fait? Eh bien notre fonction de rappel peut prendre deux paramètres: le premier, s’il est non nul (voir aussi Falsy values in javascript) désigne une erreur, tandis que le second désigne le résultat en cas de succès.
Par convention, on place généralement l’erreur comme premier paramètre.

Voyons alors le code appellant:

quizService("...", function(err, quiz) {
  if(err) {
    displayErrorFeedback(err);
  }
  else {
    displyFlashFeedback(quiz);
  }
})

Allez hop, on enchaine avec la troisième technique:

Troisième technique :

La fonction de rappel est définie comme prenant un résultat dont le contenu est optionnel

Avant:

public class QuizService {
  public void save(Quiz quiz, Effect<Option> effect) {
    try {
      repository.save(quiz);
      effect.e(Options.none());
    }
    catch(RepositoryException re) {
      Failure failure = new Failure(re);
      effect.e(Options.some(failure));
    }
  }
}

Après:

QuizService.prototype.save = function(quiz, callback) {
  try {
    this.repository.save(quiz);
    callback();
  }
  catch(e) {
    callback(e);
  }
}

Bon… en pratique on verra (ou ecrira) très rarement du code comme ça! mais plutôt:

QuizService.prototype.save = function(quiz, callback) {
  this.repository.save(quiz, function(err,res) {
    callback(err);
  });
}

ou même beaucoup plus simplement:

QuizService.prototype.save = function(quiz, callback) {
  this.repository.save(quiz, callback);
}

Illustrons cela par un exemple d’utilisation:

quizService.save(quiz, function(err) {
  if(typeof err !== "undefined") {
    displayErrorFeedback(err);
  }
  else {
    displayFlashFeedback(Code.QuizSaved);
  }
}
});

Que pouvons-nous constater? Qu’il est possible d’invoquer la même fonction javascript avec ou sans un nombre variable de paramètres. C’est à la charge de la fonction invoquée de vérifier le nombre de paramètres passés, éventuellement leurs types, afin de choisir le comportement qu’elle doit adopter.
Dans notre cas, on vérifie l’existence d’un paramètre qui, le cas échéant, sera référencé par la variable err. En l’absence de paramètre, cette variable sera considérée comme indéfinie.

Pourquoi ne pas utiliser if(err) puisque undefined est évaluée comme false (rappel: Falsy values in javascript)? Uniquement au cas où l’instance décrivant l’erreur serait évaluée à false (se serait maladroit mais bon… sait-on jamais). Les tatillons pourraient dire: “mais pourquoi tout à l’heure on ne s’est pas préoccupé de ça?” hummm… et bien tout simplement parce qu’il faut y aller petit à petit et donner différentes techniques au fur et à mesure.

Passons très vite sur la quatrième technique:

Quatrième technique :

La fonction de rappel est définie comme une fonction ne prenant aucun paramètre, elle est invoquée pour signaler que l’action désirée est effectuée

QuizService.prototype.save = function(quiz, onceSaved) {
  this.repository.save(quiz, function(err,res) {
    onceSaved();
  });
}

Et pourquoi parler de Javascript maintenant?

Eh bien parce qu’il devient difficile de ne pas voir que son utilisation devient de plus en plus présente avec des interfaces utilisateur de plus en plus riches. Il suffit de voir l’approche RIA et son nombre croissant de framework: Backbone.js, AngularJS, Ember.js, KnockoutJS, … et même Batman.js! (voir Todo MVC pour la comparaison d’une TODO list réalisée avec les différents frameworks).
Et malgré ce que peuvent en dire certains, sa présence côté serveur – popularisée par la plateforme NodeJs -a de quoi nous interpeller. D’ailleurs une grande partie du succès de NodeJs réside dans son approche non bloquante, et toute son API est tournée autour des techniques que nous venons de voir: des appels asynchrones auxquels on passe des fonctions de rappel.

Reveille le Numérobis qui sommeille en toi!

Voyons à quoi ressemblerait une application prenant en compte à chaque appel cette notion de fonction de rappel:

checkUniqueness(data, function(err) {
  if(err)
    displayError(err);
  else
    createQuiz(data, function(err, quiz) {
      if(err)
        displayError(err);
      else
        saveQuiz(quiz, function(err, quizId) {
          if(err)
            displayError(err);
          else
            lookupQuiz(quizId, function(err, quiz) {
              if(err)
                displayError(err);
              else
                sendQuiz(quiz)
            })
        })
    })
})

Mouais… c’est un bien bel escalier qui se dessine; certains parlnte même de pyramide funeste:

As we all know, asynchronous I/O leads to callback API’s, which lead to nested lambdas, which lead to… the pyramid of doom

Why coroutines won’t work on the web

Le code devient difficile à lire, à comprendre et par conséquent à maintenir et à faire évoluer!

Pour la petite histoire, c’est justement en arrivant à ce constat en écrivant le code implémentant les différents motifs vus dans AMQP 101 – Part 1 que j’ai cherché une alternative plus élégante pour écrire la même fonctionalité. Il me fallait trouver une manière plus lisible d’écrire et donc de comprendre les exemples, tout ça pour toi cher lecteur! (On vous bichone bien non?)

Des promesses, des promesses et encore des promesses

Que ce cache sous ce titre? Et bien une nouvelle technique: les Promises

Voyons tout d’abord le résultat auquel on souhaite arriver en reprenant l’example précédent:

checkUniqueness(data)
  .then(displayError, function(data) {
    return createQuiz(data);
  })
  .then(displayError, function(quiz) {
    return saveQuiz(quiz);
  })
  .then(displayError, function(quizId) {
    return lookupQuiz(quizId);
  })
  .then(displayError, sendQuiz);

que l’on peut alléger en (souvenez-vous: une fonction est un objet de première classe – hummm je radote là?):

checkUniqueness(data)
  .then(displayError, createQuiz)
  .then(displayError, saveQuiz)
  .then(displayError, lookupQuiz)
  .then(displayError, sendQuiz);

On retrouve alors notre enchainement d’appels, mais cette fois chaque appel est lié au précédent via la méthode then. Cette methode enregistre alors deux fonctions de rappel: une pour le traitement d’erreur et une pour le traitement du résultat.
Ces fonctions de rappel seront alors invoquées lorsque l’appel précédent sera terminé en fonction de son échec ou de son résultat. L’erreur ou le résultat est alors automatiquement transmis en paramètre aux fonctions de rappel enregistrées.

Il s’agit là du principe des Promises en gros: tu m’invoques, je te renvoie un accusé de reception sur lequel tu peux t’enregistrer, quand j’aurai fini mon traitement je te promets que je te passe son résultat.

L’interêt est alors de pouvoir enchainer ces promises et ainsi de décrire tout l’enchainement du traitement qui devra être effectué à mesure que les résultats passent d’une étape à l’autre. Le découplage entre les étapes apparait clairement, et il devient plus facile d’insérer ou de modifier des étapes.

Afin de faire apparaitre les promises, le code précédent pourrait être réécrit de la manière suivante (il s’agit strictement du même code, la seule différence est de faire apparaitre explicitement les variables intermédiaires au lieu de chainer les appels directement):

var promise0 = checkUniqueness(data);
var promise1 = promise0.then(displayError, function(data) {
                  return createQuiz(data);
                });
var promise2 = promise1.then(displayError, function(quiz) {
                  return saveQuiz(quiz);
                });
var promise3 = promise2.then(displayError, function(quizId) {
                  return lookupQuiz(quizId);
                });
promise3.then(displayError, sendQuiz);

A quoi pourrait ressembler une implémentation très simpliste (voir q pour une implémentation plus complète et beaucoup plus robuste):

var Promise = function () {
  this.resolved = false;
  this.callbacks = [];
};

Promise.prototype.resolve = function(error, data) {
	this.data = data;
  this.error = error;
	this.callbacks.forEach(function(cb) {
    if(error)
      cb[0](error);
    else
      cb[1](data);
  });
	this.callbacks = [];
	this.resolved = true;
}

Promise.prototype.then = function(errCallback, okCallback) {
	if(this.resolved) {
        if(this.error)
          errCallback(this.error);
        else
          okCallback(this.data);
    }
    else {
    	this.callbacks.push([errCallback, okCallback]);
	}
}

Ok… et comment on transforme nos méthodes précédentes pour intégrer cela? Et bien cela nécessite d’adapter légèrement nos API afin qu’elles intègrent directement les Promises:

var checkUniqueness = function(data) {
  var promise = new Promise();
  db.checkUniqueness(function(error) {
    promise.resolve(error, data);
  });
  return promise;
}

var createQuiz = function(data) {
  var promise = new Promise();
  service.createQuiz(data, function(error, quiz) {
    promise.resolve(error, quiz);
  });
  return promise;
}

...

Biensûr tout cela a encore plus d’interêt lorsque les appels db.checkUniqueness(...) et service.createQuiz(...) sont asynchrones:
Les deux méthodes précédentes sont invoquées et avant même que le résultat ne soit disponible la promise correspondante est retournée afin de continuer à définir le traitement à effectuer. La chaine de traitement est définie en même temps que la base de données ou que notre service travaille.

Quelques explications?

checkUniqueness est invoquée avec les données à vérifier data. Une promise est instancié spécialement pour l’occasion afin d’être notifiée lorsque le traitement sera terminé. La vérification d’unicité est alors déléguée à la base de donnée (fallait bien trouver un responsable!) et en attendant son verdict, la promiseest retournée telle quelle.
Il est alors possible d’enregistrer plusieurs callback dessus en utilisant la méthode then(cbErr,cbOut) (exemple: .then(displayError, function() { return createQuiz(data); })).
Les callback ainsi enregistrés seront alors notifiés dès que le résultat sera disponible, c’est à dire lorsque la méthode promise.resolve(err,out) est invoquée avec le résultat du traitement.

Le public averti pourra s’appercevoir qu’une promise est – elle-même – basée sur le mécanisme de callback et se place elle-même en fonction de rappel sur le traitement effectué.

L’exemple précédent nous a montré comment transformer des appels imbriqués en des déclarations séquentielles.
Exploitons désormais quelques possibilités offertes par les librairies autour de ces promises (nous baserons notre discours sur la librairie q).

Voyons comment nous pouvons étendre les mêmes techniques à des traitements effectués en parallèle, et plus particulièrement ceux nécessitant des points de “synchronisation”: une fois que mes sous-tâches sont terminées alors seulement la suite de mon traitement peut continuer.

Par exemple si nous souhaitons générer tout un recueil de quiz:

Commençons par définir une fonction utilitaire de création de quiz, regroupant ce que nous avons pu voir jusqu’à présent. La fonction retourne une promise sur la création du quiz:

function generateQuiz(errorHandler) {
  var promise =
        generateData()
          .then(errorHandler, checkUniqueness)
          .then(errorHandler, createQuiz)
          .then(errorHandler, saveQuiz);
  return promise;
}

Une fonction utilitaire qui génèrera nbQuiz en invoquant la fonction précédente:

function generateBook(nbQuiz, errorHandler) {
  var i,
      promises = [];
  for(i=0; i < nbQuiz; i++) {
    var promise = generateQuiz(errorHandler);
    promises.push(promise);
  }

  return return Q.all(promises);
}

Le retour de notre méthode generateBook est une promise. Les fonctions de rappels qui seront “branchées” dessus seront alors invoquées lorsque les nbQuiz questionnaires auront été générés, créés et enregistrés.

generateBook(25, errorHandler)
  .then(errorHandler, generateTableOfContent)
  .then(errorHandler, sendToFrance3)
  ...;

En continuant de creuser, on peux trouver beaucoup d’autres techniques. On s’apperçoit que cela ouvre énormément de possibilités quand à la gestion de tâches asynchrones, éventuellement concurrentes, et ce de manière lisible.

Qui est concerné?

Suis-je concerné? Je pensais que le Javascript était executé par un seul fil d’execution et là on me parle de traitement asynchrone ?

One of the fundamental features of client-side JavaScript is that it is single-threaded: a browser will never run two event handlers at the same time, and it will never trigger a timer while an event handler is running, for example. Concurrent updates to application state or to the document are simply not possible, and client-side programmers do not need to think about, or even understand, concurrent programming.

Javascript The Definitive Guide 6th Ed – 22.4 WebWorkers

Et en quoi cela nous concerne-t-il si l’on développe sur le navigateur?

Eh bien parce qu’il n’est pas toujours question de traitement effectué sur le navigateur, mais il peut aussi s’agir d’interaction avec des systèmes tierces comme une requête AJAX:

A corollary is that client-side JavaScript functions must not run too long: otherwise they will tie up the event loop and the web browser will become unresponsive to user input. This is the reason that Ajax APIs are always asynchronous and the reason that client-side JavaScript cannot have a simple, synchronous load() or require() function for loading JavaScript libraries.

Javascript The Definitive Guide 6th Ed – 22.4 WebWorkers

Everything except network operations happens in a single thread

So, You Want to Be a Front-End Engineer?

Les bibliothèques JQuery et Dojo fournissent d’ailleurs des API selon le modèle des promises appellé chez eux deferred (Dojo ~ Deferreds et JQuery ~ Deferreds).

De plus, comme l’auront noté les petits malins, HTML5 définit la notion des WebWorkers qui permet d’effectuer des traitements en dehors du fil d’execution Javascript de la page. Il est donc tout à fait possible d’effectuer des traitements asynchrones même sur un navigateur en les déléguant à des WebWorkers (Nous reviendrons sans doute sur eux dans un prochain article).

Quand à la plateforme NodeJs, et bien, elle intègre par construction la nature asynchrone de chaque appel IO (Input/Output). Les spécifications sur lesquelles s’appuient la plateforme prévoit même une API unifiée et standard pour les promises (CommonJS ~ Promises/A pour lequel q est compatible).

Et pour ceux qui jamais au grand jamais ne toucheront à du javascript!?

Et bien, tout d’abord, vous avez tort de ne pas vous y interesser. Ensuite, ces notions non-bloquantes deviennent de plus en plus présentes et l’on retrouve tous ces concepts dans les langages intégrant les notions de process/acteur (erlang et scala dont l’excellente librairie akka) et du coup dans les frameworks comme Play! ou GPars.

Webographie

Une très belle présentation:

  • kriskowal / q : A tool for making and composing asynchronous promises in JavaScript

Livres

1 comment for “Callbacks strike Back

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *