Dialogue interne

« Et bim ! Encore une erreur. Que ce soit un code 500, je veux bien. Mais avec le message "Houston. We’ve got a problem.", l’humour c’est bien, mais je ne vais pas aller très loin avec ça. Ma seule solution serait de fouiller dans le code source pour comprendre ce qui s’est passé. Au fait, j’espère que j’y ai les accès ? »

Dans le monde Java et avec d'autres langages, pour gérer les erreurs, on pense souvent au mécanisme des exceptions ou de valeurs particulières comme -1, null ou NaN. Et on y pense d'ailleurs souvent un peu trop. -1 va bien fonctionner tant que vous n’oubliez pas de le gérer et à condition que la réponse nominale de votre fonction est un entier positif. null, il faut penser à le gérer aussi et c'est facile de l'oublier, sinon NullPointerException ne serait pas si courant. NaN se passe bien tant que vous ne cherchez pas à faire de comparaison, car NaN ≠ NaN. Et puis les exceptions... comme dirait Nicolas Rinaudo, les exceptions c'est pire que le goto, sachant que dans le cas des goto, on sait où est-ce que ça atterrit !

Ceci dit, les checked exceptions proposent une idée intéressante car elles doivent apparaître dans la signature des fonctions et donnent des indications sur leur comportement. Mais c’est généralement une écriture qui alourdit le code et ça se termine avec des unchecked exceptions.

La communauté FP propose néanmoins de reprendre cette idée pour mettre en valeur le fait qu’une fonction puisse retourner un résultat exceptionnel. Cela passe par des types comme Option, Either ou IO. Et ces types sont nécessaires, en particulier parce qu’ils sont accompagnés d’un ensemble d’opérations standards qui permettent de conserver un code linéaire (ie. sans boucles ni if explicites). Cependant, ils ne sont pas suffisants, car ils ne donnent aucune indication sur la nature de l’erreur.

Une erreur peut être d'ordre technique, dans la mesure où on a atteint une limite physique (out of memory, timeout, stack overflow...). Elle peut être d'ordre métier, dans le mesure où un contexte sort du comportement attendu par le métier (ie. identifiant de compte invalide, produit non disponible, badge déjà validé...). Mais la façon de gérer une erreur, quelle qu'elle soit, est une décision métier.

Puisque la gestion d'une erreur est une décision métier, au niveau technique on ne devrait pas chercher à émettre une erreur. D'ailleurs, on ne devrait même pas réfléchir au résultat nominal à priori. On devrait en fait qualifier une situation sur le plan métier.

Prenons un exemple simple. Et pour paraphraser François Armand, nous allons prendre l’exemple de la division euclidienne.

Il est en parti vrai d'écrire

def divide(a: Int, b: Int): Int

Seulement, si b vaut 0, à quelle genre de réponse doit-on s’attendre ? On voit rapidement que -1 n’est pas une réponse valide. null n’est pas permis par définition du type Int. On pourrait envoyer une exception... ce qui reviendrait à ajouter du code alourdissant celui existant pour traiter l’exception, si on ne l’oublie pas.

Écrivons maintenant

def divide(a: Int, b: Int): Option[Int]

Cette version indique clairement, que dans certains cas nous n’aurons pas la réponse attendue : None. En plus, le compilateur va nous obliger à gérer le cas où nous obtiendrons None. Ce ne sera pas avec une structure syntaxique lourde (try... catch) mais avec avec des opérations qu’on peut intégrer dans des expressions (map, flatMap, getOrElse...) et le "if" n’est pas nécessaire.

Nous allons maintenant représenter des périodes de temps (où le temps est représenté par un entier pour des raisons de simplification).

case class Period(start: Int, end: Int)

Nous allons éluder le cas où start > end.

Je veux constituer une liste de Period qui ne se chevauchent pas plus par rapport à un certain timing.

Avant de parler de cas nominaux et cas exceptionnels, nous allons d’abord chercher à qualifier les différents contextes. Nous avons donc des périodes qui ne se chevauchent pas, des périodes qui se chevauchent un peu et des périodes qui se chevauchent beaucoup ou complètement.

Je vous propose de représenter quelques cas en utilisant des têtes de chiots pour représenter le temps passé dans les périodes, parce que pourquoi pas !

Voici les cas où il n'y a pas de chevauchement.

# period 1 before period 2
period 1 [🐶🐶🐶]
period 2          [🐶🐶🐶]

# period 1 after period 2
period 1          [🐶🐶🐶]
period 2 [🐶🐶🐶]

Voici les cas avec chevauchement

# period 2 overlaps period 1 by 2 🐶
period 1 [🐶🐶🐶🐶]
period 2    [🐶🐶🐶🐶]

# period 1 overlaps period 2 by 3 🐶
period 1    [🐶🐶🐶🐶]
period 2  [🐶🐶🐶🐶]

# period 1 includes period 2
[🐶🐶🐶🐶🐶]
  [🐶🐶]

# period 1 == period 2
[🐶🐶🐶]
[🐶🐶🐶]

Si on regroupe d'un côté les deux premiers cas (chevauchement partiel) et de l'autre les deux derniers cas (chevauchement total), nous avons au total trois cas possibles.

sealed trait OverlappingPeriodQualification
case class PeriodsDoNotOverlap(  period1: Period, period2: Period)          extends OverlappingPeriodQualification
case class PeriodsPartiallyOverlap(period1: Period, period2: Period, by: Int) extends OverlappingPeriodQualification
case class PeriodsFullyOverlap(    period1: Period, period2: Period)          extends OverlappingPeriodQualification

À partir de là, nous pouvons effectuer la qualification.

def qualify(period1: Period, period2: Period): OverlappingPeriodQualification =
  if ((period1.end <= period2.start) || (period2.end <= period1.start))
    PeriodsDoNotOverlap(period1, period2)
  else if (period1.start >= period2.start) {
    if (period1.end <= period2.end)
      PeriodsFullyOverlap(period1, period2)
    else
      PeriodsPartiallyOverlap(period1, period2, period2.end - period1.start)
  }
  else if (period2.start >= period1.start) {
    if (period2.end <= period1.end)
      PeriodsFullyOverlap(period1, period2)
    else
      PeriodsPartiallyOverlap(period1, period2, period1.end - period2.start)
  }

Une fois la qualification réalisée, on peut passer à l'interprétation des valeurs obtenues. La phase d'interprétation consiste à convertir des valeurs en effets. C'est en quelque sorte la partie présentation. Le code ci-dessous correspond à une interprétation possible des valeurs de OverlappingPeriodQualification.

def interpret(qualification: OverlappingPeriodQualification): Try[Unit] =
  qualification match {
    case PeriodsDoNotOverlap(_, _) => Success(())
    case PeriodsFullyOverlap(p1, p2) => Failure(new Exception("periods overlap"))
    case PeriodsPartiallyOverlap(p1, p2, delta) =>
      if (delta > 2) Failure(new Exception("periods overlap"))
      else Success(())
  }

Il est possible d'ajouter d'autres interpréteurs qui envoient des exceptions, qui permettent de tracer les qualifications, qui effectuent d'autres actions. Le mieux est de faire en sorte que cette phase d'interprétation est lieu le plus tard possible dans le code.

La séparation qualification / interprétation offre une certaine vertu, en particulier, de permettre d'avoir une souplesse pour les tests, pour les modifications du code et de délimiter plus clairement un domaine métier. L'objectif est avant tout de clarifier le code et d'aider le développeur à intervenir plus efficacement sur les applications.

Photo by Chris Benson on Unsplash.