Exceptions: la voie à suivre ou un cul-de-sac?

Introduction

Dans un projet précédent, j’ai intégré une équipe qui avait placé la levée d’exceptions et leur traitement au cœur de leur mécanisme logiciel. Cette approche permettait une gestion très systématique des situations qui sortaient de la voie normale tout en devant être traitées de manière rigoureuse. Par exemple, toute utilisation d’un identifiant ne correspondant pas à un élément dans la base de données levait une exception de type MissingElement, cette exception remontait jusqu’au contrôleur répondant à une requête HTTP, où elle était interceptée et convertie en une réponse 404 (NotFound). Si, lors du traitement de la requête, on se rend compte que l’utilisateur ne peut réaliser une tâche induite par la requête, une exception NotAllowed est levée, et le contrôleur l’intercepte et y réagit en retournant une réponse 401 (Unauthorized).

En dépit de l’efficacité du code produit (des situations prévues mènent à la même réponse, définie à un seul endroit, et ce quel que soit l’emplacement dans le code où la situation se produit), plusieurs aspects de l’approche me turlupinaient.

Tout d’abord, le terme Exception m’amène à penser naïvement que ce mécanisme devrait être utilisé dans des situations exceptionnelles. L’absence d’élément correspondant à un identifiant n’est pas exceptionnelle. Repérer que l’utilisateur ne dispose pas des permissions nécessaires pour accéder à une ressource n’est pas exceptionnel. S’il ne s’agit pas des happy paths des scénarios correspondants, nous restons dans des circonstances relativement banales et prévisibles. Inversement, se rendre compte que la base de données ne répond plus est (espérons-le) veritablement exceptionnel et hors de notre responsabilité: il s’agit de circonstances non contrôlables. En utilisant les exceptions pour des cas probables ou courants, on s’éloigne de leur sémantique originale.

Ensuite, la levée d’une exception interrompt le flux normal d’exécution. En le rendant non-linéaire, l’introduction d’une exception complexifie la compréhension du code pouvant la générer. Le code appellant a le choix entre gérer les éventuelles exceptions levées lors d’un appel, ce qui l’alourdit par la présence de code dédié à ces situations « hors flux », ou laisser l’exception percoler, ce qui l’éloigne davantage de l’endroit où elle a été levée, ce qui complexifie également la compréhension du comportement du système. Pour ces raisons, les exceptions sont parfois appelées péjorativement fancy goto.

Les exceptions sont particulièrement ardues à gérer dans un contexte distribué. Plus encore que dans le cas d’une succession d’appels, les exceptions tendent alors à perdre le contexte des erreurs qui en sont la cause, ce qui contrarie leur identification et leur résolution. Si une tâche doit être réalisée sur un ensemble d’éléments, comment réagir lorsqu’une exception survient au sein d’une des transformations? Arrêter l’ensemble du traitement? Ignorer l’élément problématique? Retenter la transformation en question? Par l’échappement du flux normal de traitement que constitue l’exception, il est difficile d’offrir une réponse structurée à son apparition.

Le relâchement du lien entre l’apparition d’un problème et son traitement est contraire à la composabilité de l’application: lorsqu’on souhaite combiner des fonctions pouvant générer des exceptions, il faut également combiner la gestion des exceptions qui en découlent, ce qui n’est pas trivial étant donné que la gestion du problème est implicite et externalisée. Dans le même ordre d’idée, la propagation implicite des erreurs brise la modularité du code: un bloc de code qui utilise des exceptions est souvent couplé à ses gestionnaires d’erreurs, ce qui rend difficile sa réutilisation dans un autre contexte.

Selon les langages de programmation, l’utilisation d’exceptions peut s’avérer coûteuse en termes de mémoire et d’usage du processeur. Lorsqu’une exception se produit, le runtime doit procéder à un « stack unwinding »: la pile du processus doit être inspectée, pour y chercher le code qui pourrait réagir à la levée de l’exception. L’objet représentant une exception doit contenir une copie de cette pile, afin que les développeurs puissent comprendre plus facilement la cause du problème.

Alternatives

La critique est aisée, mais l’art est difficile. Après tout, le mécanisme d’exception a été introduit afin de pallier au manque de structure des approches précédentes (valeur de retour spéciales, valeur nulle, variables globales pour l’état du système, etc.). Alors que pouvons-nous mieux faire?

La programmation fonctionnelle propose quelques outils pour encadrer la gestion des situations qui dérapent. J’ai l’habitude de parler de trinité monadique pour décrire trois structures fonctionnelles, associées à une sémantique claire, dont l’objectif est de faire en sorte que les situations qui sortent du happy path soient considérées comme les autres. L’objectif principal est ici de maintenir le flux de traitement sous contrôle en toute circonstance. Ces structures sont, en Scala, nommées Option, Either et Try. On retrouve des équivalents dans les autres langages fonctionnels. D’autres langagages encore, comme Java, tendent à les adopter.

En Scala, le type Option représente une valeur qui peut exister. Le type a deux sous-types: Some, si la valeur existe, et None si elle n’existe pas. Utiliser un Option permet de se passer des valeurs nulles et donc d’éviter l’erreur à un milliard de dollars. Si cela permet au compilateur de taper sur les doigts du développeur lorsqu’il risque d’oublier une valeur manquante, l’intérêt est, selon moi, avant tout sémantique: la possibilité d’une valeur n’est pas la même chose qu’une valeur ou une absence de valeur.

Contrairement à l’univers Java dans lequel on a tendance à limiter l’usage des Optional (l’équivalent des Option de Scala) aux retours de fonctions pouvant ne pas donner de résultat, en Scala, le type Option est couramment employé pour indiquer une valeur qui peut exister ou non, dans les circonstances les plus banales. Par exemple, si un utilisateur peut avoir une adresse e-mail, la variable associée à cette adresse aura pour type Option[String] (ou, mieux encore, Option[Mail], Scala privilégiant le typage riche et spécifique à l’usage de types passe-partout), afin de marquer explicitement la possibilité d’une non-existance.

Un Option ne permet pas de détailler pourquoi une valeur est manquante. L’évaluation d’une fonction, ou une variable, est une valeur éventuelle, point. Si on souhaite plus de détails, par exemple pour réagir plus finement à la cause d’une valeur manquante, un Either est préférable. Ce type a deux sous-types: Left et Right. Lorsqu’une fonction est de type Either[A,B], on sait qu’elle va retourner soit objet de type Left[A], soit un objet de type Right[B]. Par convention, en Scala, si le retour est de type Right[B], cela signifie que la fonction retourne le type « normal » B, tandis qu’un retour de type Left[A] indique une anomalie de type A. D’autres langages ont adopté la convention inverse, mais peu importe: le traitement du retour d’une telle méthode doit obligatoirement considérer les deux sous-types de réponse possibles, typiquement grâce au pattern matching:

def fun(): Either[Error, Response] = ???

fun() match {
  case Left(err) => // traitement de err, de type Error
  case Right(resp) => // traitement de resp, de type Response
}

Plusieurs langages de programmation récents, tels que Go et Rust, proposent une approche similaire, bien qu’ils ne soient pas fonctionnels. Les méthodes y ont typiquement deux valeurs de retour: la valeur « normale » et la valeur « exceptionnelle ». En fonction des circonstances, l’une de ces valeurs est nulle et l’autre est effective. À charge du code appellant de tester ce double retour et de se comporter en fonction de la valeur concrètement disponible. J’ai personnellement tendance à préférer l’approche fonctionnelle, qui considère qu’il n’y a qu’un retour et qui repose sur son typage fort pour forcer le code appellant à traiter correctement et complètement la valeur retournée.

L’évaluation de fonctions pouvant lever une exception est gérée par l’utilisation d’un type appellé Try. Ce type a exactement deux sous-types: Success et Failure. Il s’agit donc d’une approche fort similaire, bien que sémantiquement différente, à celles proposées par Option et Either.

On « encapsule » un appel risquant de lever une exception comme suit:

def div(a: Float, b: Float): Float throws IllegalArgumentException = 
  if(b == 0.0) throw new IllegalArgumentException("Denominator cannot be zero")
  else a / b
}

Try(div(5.1, 0.0)) match {
  case Success(value) => // traitement de value 
  case Failure(err) => // traitement de l'exception 
}

Notez que, contrairement au try de Java, le Try de Scala n’est pas une construction syntaxique du langage. Il ne s’agit pas d’une structure particulière que le compilateur gère lors de l’analyse syntaxique, mais d’une classe ordinaire dont la charge utile est basée sur l’évaluation d’une expression arbitraire.

Au-delà de la trinité monadique

Techniquement parlant, vous n’avez pas besoin d’un trio de monades: Either pourrait remplacer Option et Try. Si les trois existent, c’est pour mieux préserver le sens de la situation problématique.

Le cas de Future

Les Future, qui représentent une évaluation de fonction qui prendra fin « à un moment », peuvent facilement échouer. Leur évaluation peut être annulée par l’utilisateur, le programme ou le système d’exploitation. Une interruption du processus parent ou la levée d’une exception peut couper court à son traitement. Un Future peut donc, lui aussi, se conclure par deux types distincts: la valeur attendue, ou un Throwable qui a été levé durant l’évaluation du Future. Il s’agit d’une sous-classe de Either[Throwable, T], avec un léger raffinement sémantique.

Le cas des typages métier

Puisque l’intérêt d’avoir plusieures monades vient d’une sémantique plus précise, il y a des situations où celles-ci ne sont pas les plus appropriées.

Prenons l’exemple d’une fonction devant récupérer une valeur métier (mettons, un objet représentant une personne) depuis un service REST. On sattend à ce que le service puisse réagir de différentes manières:

  • Il fournit une réponse valide. C’est évidemment le scénario idéal.
  • Il indique que la réponse demandée n’existe pas, avec un code HTTP 404.
  • Il fournit une réponse qui n’est pas valide, par exemple parce qu’il manque un attribut obligatoire à la réponse.
  • Il ne répond pas à temps.

Avec une approche basée sur des exceptions, la méthode aurait sans doute eu pour type Person, avec la possibilité d’émettre des exceptions pour les trois dernières situations:

public record Person(...)

public interface ServiceException extends RuntimeException 

public class NotFound implements ServiceException { ... }

public class InvalidResponse implements ServiceException { ... }

public class Timeout implements ServiceException { ... }

public Person get(Int id) throws ServiceException { ... }

try {
  Person response = get(42);
} catch(NotFound e) { ... }
catch(InvalidResponse e) { ... }
catch(Timeout e) { ... }

Avec un système de typage, nous pourrions avoir

case class Person(...)

sealed trait ServiceResult

case class Response(p: Person) extends ServiceResult 

object NotFound extends ServiceResult 

object InvalidResponse extends ServiceResult

object Timeout extends ServiceResult

def get(id: Int): ServiceResult = ???

get(42) match {
  case Response(p) => ???
  case NotFound => ???
  case InvalidResponse => ???
  case Timeout => ???
}

La différence peut sembler légère, et se limiter aux spécificités des syntaxes de Java et Scala. Cependant, il n’en n’est rien: là où le code Java « espère le meilleur et se prépare au pire », avec une différence nette dans la gestion de la situation idéale et des autres situations, la version Scala retourne toujours un objet représentant le résultat de l’appel, quel qu’il puisse être, et force l’utilisateur à considérer tous les cas possibles de manière similaire, grâce au pattern matching.

Passage d’un monade à un autre

Il existe diverses fonctions utilitaires, permettant par exemple de convertir facilement un Try en Option. Cela permet de se ramener à des situations plus ordinaires, pour autant que le métier le permette. Par exemple, un Failure peut être converti en None si cela a du sens pour le métier. Après quoi, un Optional[T] peut devenir un T, toujours sous réserve que cela a du sens pour le métier, et en particulier qu’une valeur par défaut puisse être utilisée.

Évidemment, pour les typages ad hoc, des conversions explicites doivent être crées.

Prise en compte des scénarios déviants dans un flux de traitement

Certaines bibliothèques fonctionnelles vont un pas plus loin, et imposent cette représentation sémantique dans les flux de traitement distribués. Alors que Akka et Pekko ne prennent pas de précaution particulière concernant la levée possible d’une exception et serre les fesses en espérant que tout se passe bien, Zio et d’autres modélisent explicitement le fait qu’une évaluation puisse mal se passer, en décrivant explicitement de quelles manières les choses pourraint mal tourner. Une évaluation, qui se base sur le résultat fourni par l’évaluation précédente, est donc forcée de prendre en compte aussi bien le résultat « normal » qu’un résultat atypique bien qu’anticipé. Sémantiquement, c’est pratiquement équivalent à l’usage systématique d’un Either comme type de retour pour les fonctions pouvant mal tourner.

Différences entre Java et Scala

En conclusion, la manière de gérer les variantes atypiques d’un flux d’exception ne repose pas nécessairement sur des exceptions, car elles ont leurs limitations.

Java et Scala diffèrent significativement dans leurs approches respectives concernant cette problématique, ce qui reflète leurs philosophies respectives en matière de programmation.

Bien qu’on ne puisse pas véritablement parler de cul-de-sac, le mécanisme d’exception, parce qu’il permet un échappement du flux d’exécution normal, comporte plusieurs problèmes intrinsèques que les approches monadiques tentent d’éviter. Dans un environnement distribué, le besoin qu’a le système de se souvenir des différents contextes dans le cadre duquel une exception a pu survenir est également problématique.

Scala privilégie une approche fonctionnelle pour gérer les erreurs en utilisant des structures comme Try, Success, et Failure, qui permettent de représenter les résultats d’opérations qui peuvent échouer sans lever directement d’exception.

Cette approche favorise le développement de flux de données davantage sécurisés et rend les erreurs plus explicites dans le code.

Scala ne néglige pour autant pas complètement le système d’exception, ne serait-ce que parce que les nombreuses bibliothèques Java, avec lesquelles il s’intègre, s’appuient parfois lourdement sur ce mécanisme. Des outils permettent de « scalaïser » facilement le comportement des fonctions de ces bibliothèques.

De plus, le mécanisme d’exception reste présent en Scala, mais aucune exception n’y est vérifiée, contrairement à ce qui se fait en Java: en Scala, les exceptions sont réservées aux problèmes imprévisibles, ou du moins à ceux pour lesquels il n’y a pas grand chose à faire. Quant aux autres, ils sont ramenés en douceur vers une structure de données qui en fait un résultat d’évaluation comme un autre, de sorte que les situations problématiques soient gérées de la même manière que celles ordinaires.

N’est-ce pas, en fin de compte, ce que nous pouvons souhaiter de mieux en tant qu’ingénieurs logiciels?

Laisser un commentaire