« The greatest mistake is to imagine that we never err. »
Thomas Carlyle
Dans les précédents articles de la série sur la programmation fonctionnelle, nous avons prôné un style de programmation “pur”.
Entres autres, cela revient à éviter les effets de bords dans nos différentes fonctions. Ce style de programmation n’est pas sans conséquences. En effet, cela nous impose une contrainte forte qui est de toujours retourner une valeur dans nos fonctions. Mais que renvoyer lorsque notre programme plante sauvagement parce qu’il n’a pas réussi à récupérer des informations d’une base de données ? Ou qu’il n’a pas réussi à lire un fichier ?
Avant d’entamer cet article, j’attire votre attention sur le fait que nous aspirons à avoir du code 100 % fonctionnellement pur. Cependant, il est presque impossible de l’obtenir. Les raisons sont simples, nos programmes interagissent souvent avec des systèmes externes tels que des bases de données, des fichiers, des API etc. Ces systèmes sont susceptibles de renvoyer des erreurs indépendantes de notre volonté. C’est la raison pour laquelle nous avons besoin de gérer ces erreurs.
Votre mission, si toutefois vous l’acceptez, est de maximiser le code “pur” et d’isoler les fonctions à effets de bord comme celles qui vont lire des fichiers, appeler une API, exécuter une requête SQL etc.
Dans la suite de cet article, nous allons présenter différents types Scala nous permettant de gérer les erreurs efficacement afin de cerner leurs atouts respectifs.
Pour les amateurs de Java, vous pourrez retrouver ces types dans des librairies fonctionnelles en telles que Vavr.
Option
I call it my billion-dollar mistake. It was the invention of the null reference in 1965.
– Sir Tony Hoare
Sir Tony Hoare, le créateur du mot-clé NULL
s’est lui même excusé de sa création. Le problème de NULL
est qu’il veut tout et rien dire en même temps.
Je vous laisse lire l’article de Paul Draper qui traite très bien le sujet.
En programmation fonctionnelle pure, il vaut mieux l’éviter car nos fonctions doivent renvoyer des valeurs bien définies. C’est pour éviter d’avoir à manipuler des NULL
que le type Option
existe.
Le type Option
est un type permettant de nous indiquer la présence d’une valeur. Ce type peut prendre 2 valeurs :
Some(une_valeur)
: nous avons effectivement une valeurNone
: nous n’avons pas de valeur.
Cela permet de gérer un premier niveau d’erreur. En effet, dans le cadre d’un formulaire soumis avec des champs optionnels, ces champs optionnels peuvent être soumis avec une valeur None
et dans le cas ou ces champs ont été renseignés Some(valeur)
.
case class User(email: String, phone: Option[String] = None, adress: Option[String] = None) val user = User(email= "test@gmail.com")
Le type option a toutefois ses limites. Prenons l’exemple d’une fonction permettant de convertir une String
en Int
.
def convertStringToInt(s: String): Option[Int] = Try(s.toInt).toOption
Ici, nous nous attendons soit à Some(valeur)
ou à None
. Cependant, dans le cas du None
nous ignorons complètement l’origine de l’erreur et donc pour le corriger cela devient très vite compliqué.
Dans la plupart des systèmes, pour pouvoir assurer un monitoring optimal, il serait plus intéressant d’avoir plus d’informations sur l’erreur qui s’est produite et non pas se limiter au fait d’avoir pu ou pas effectuer une action.
Le type Option
sert donc généralement a représenté une valeur non obligatoire (optionnelle).
Il existe un type similaire en Java8 le type Optional.
Attention, lorsque vous souhaitez créer une option à partir de maValeur
. Utilisez Option(maValeur)
qui peut résulter en une instance de Some
ou de None
car si vous utilisez Some
directement et que maValeur
vaut NULL
alors le résultat sera Some(NULL)
et non pas None
.
Try
Un des problèmes du langage Scala est que les exceptions ne sont pas contrôlées (Checked Exception vs Unchecked exception). En Java, la signature de la méthode indique que la fonction peut renvoyer une erreur ce qui n’est pas le cas en Scala. Cela nous laisse donc 2 choix :
- Ajouter un commentaire précisant que la fonction peut renvoyer une exception (ou utiliser l’annotation @throws qui est purement à titre indicatif)
- Utiliser le type
Try
Vous l’aurez compris nous allons privilégier la seconde option.
Try
est un type un peu comme Option
qui permet représenter une instance de Success
ou de Failure
Success(valeur)
: Les instructions placées dans leTry
se sont toutes bien exécutées etvaleur
représente la valeur de retour.Failure(erreur)
: Une des instructions a placées dans le blocTry
a échoué eterreur
constitue unThrowable
avec le détail de l’erreur.
Il permet d’apporter une précision supplémentaire quant à la nature du code qui va être exécuté et de renvoyer la bonne erreur en cas d’échec.
Prenez, l’exemple de ces 2 méthodes:
def convertStringToInt(s: String): Int = s.toInt
et
def convertStringToInt(s: String): Try[Int] = Try(s.toInt)
Les deux méthodes suivantes sont différentes au niveau de la valeur de retour. Mais on note clairement que l’implémentation renvoyant un Try
est beaucoup plus claire et moins sujette à des erreurs car permet de tout de suite prévenir les utilisateurs de la fonction sur la nature de la fonction à générer des effets de bord.
Try
est également particulièrement utile lorsque vous faites appel à du code externe ou une API qui peut vous renvoyer des erreurs.
Seules les exceptions NonFatal
sont rattrapées par Try
. Les exceptions provenant du système, de la JVM ou autres lanceront une exception (voir scala.util.control.NonFatal)
Notez que je ne parle pas dans cette article du try ... catch
qui existe en Java mais également en Scala. La raison est simple : le try ... catch
est une structure de contrôle tandis que le Try
de Scala
est une expression. Elle permet donc de chainer des opérations fonctionnelles juste après tout en pouvant récupérer une potentielle exception.
Either
Either
porte la signature Either[L, R]
où L
et R
représentent des types de retour. Ce type permet de renvoyer des types qui sont différents pour une même méthode en fonction des cas. Pour la gestion d’erreur, il peut donc servir à stocker une erreur dans le cas d’un échec d’une part et une valeur en cas de succès. Il se décompose comme suit :
Left[L]
: Constitue la valeur de la partie gaucheRight[R]
: Constitue la valeur de la partie droite
Either
est soit une instance de Left
soit une instance de Right
mais pas les deux en même temps.
Par convention, la partie gauche sert généralement à stocker les erreurs tandis que la partie droite sert à stocker une valeur (right is … right!).
def convertStringToInt(s: String): Either[Exception, Int] = Try(s.toInt) match { case Success(v) => Right(v) case Failure(e) => Left(e) }
Depuis la version Scala 2.12 Either
est devenu un type monadique. Nous verrons ce que cela signifie dans les prochains chapitres. Cependant, vous pouvez retenir que, dorénavant, nous pouvons faire appel à la fonction map
afin d’obtenir un comportement similaire aux Option
s. C’est-à-dire que, dans le cas où Either
représente une instance de Right
, alors la fonction map
s’applique sur la valeur contenu dans la partie Right
et un nouvel Either
est renvoyé avec la nouvelle valeur Right
. Dans le cas où Either
représente une instance de Left
, alors l’Either
est renvoyé sans aucune modification.
Depuis Scala 2.12, Either
est devenu right-biased. C’est à dire que le type considère que les fonctions map
, flatMap
etc. doivent s’appliquer sur la partie droite du Either
car celle-ci est censée contenir la valeur attendue tandis que la partie gauche contient essentiellement les erreurs.
Avant Scala 2.12, le Either
ne permet pas d’effectuer des opérations map, flatMap dessus. Cependant, des librairies fonctionnelles telles que cats implémentent les fonctions Map, FlatMap, … afin d’obtenir le même comportement qu’en Scala 2.12.
Créez vos erreurs
Avant de terminer cette article, je souhaite aborder le sujet des exceptions customs. La librairie Java (ou autres) fournis des classes d’erreurs différentes. Mais le problème de ces erreurs de base est qu’elles ne sont pas toujours flexibles/représentatives de nos erreurs.
Afin d’éviter ce problème, une bonne pratique est de créer ses propres erreurs customs. Une fois créées, elles permettront d’avoir une gestion plus fine et d’indiquer précisément le type d’erreur qui est survenu au cours de l’exécution.
Vous trouverez ci-dessous, un exemple d’exceptions customs qui peuvent être générées lors de la lecture d’un ficher.
Ces classes sont vôtres alors vous pouvez les customiser autant que vous le souhaitez. En rajoutant par exemple des codes d’erreurs.
package exception abstract class FileException(msg: String, cause: Throwable) extends Exception(msg, cause) case class NotFoundException(msg: String, cause: Throwable) extends FileException(msg, cause) case class UnexpectedException(msg: String, cause: Throwable) extends FileException(msg, cause) case class FileParsingException(msg: String, cause: Throwable) extends FileException(msg, cause) object FileParsingException { def apply(msg: String, cause: Throwable = null): FileParsingException = new FileParsingException(msg, cause) }
Agencer son code
Maintenant que vous savez quels sont les outils qui vous permettront de gérer efficacement vos erreurs, il est important de mettre de l’ordre dans tout ça et de ne pas lancer des exceptions n’importe où dans votre code afin de garder le plus de code possible fonctionnellement pur.
En effet, si vos fonctions renvoyaient des erreurs, elles ne seraient pas pures. Il faudrait donc déléguer la gestion des erreurs qu’elles auraient rencontrées et juste retourner le type d’erreurs rencontrées avec les potentiels messages d’erreur.
Pour ce faire et afin de centraliser la gestion des erreurs, vous pouvez par exemple avoir plusieurs niveaux de traitement constitués de 3 étapes principales :
- Le lancement : Il s’agit d’une classe main qui s’occupe de la lecture des paramètres, de l’exécution de la classe globale et de la fin du process ou du lancement des exceptions.
- L’orchestration : Une classe d’orchestration globale du traitement qui déroule le processus jusqu’à son terme sans lancer d’exception.
- L’exécution : Des sous-classes appelées par la classe d’orchestration dont la fonction représente une sous-tâche du programme. Celles-ci doivent s’exécuter sans lancer d’exception.
Ci-dessous, vous trouverez une description schématique :
Dans cette “architecture”, il est important de noter que seule la classe Main
se charge de lancer des exceptions. Elle collecte toutes les erreurs qui auraient pu survenir durant l’exécution du traitement et se charge de retourner l’erreur adéquate le cas échéant.
Les méthodes à éviter
Parce que leur style est très impératif ou leur utilisation est susceptible de lancer des exceptions, voici une liste de méthodes que je vous conseille d’éviter autant que vous le pouvez.
- Style impératif :
Option::isDefined
,Either::isRight
,Either::isLeft
,Try::isFailure
,Try::isSuccess
,Either::left
,Either::right
.
- Effet de bord :
Option::get
,Try::get
.
Nous pouvons nous passer de ces méthodes car nous pouvons aisément appliquer la logique nécessaire en utilisant les méthodes map
, flatmap
, filter
etc car seul le résultat final nous intéresse.
Gardez toutefois en tête qu’aucune règle n’est absolue. Je vous conseille d’éviter ces méthodes. Cependant, il y a des cas où elles peuvent s’avérer être utiles.
Conclusion
Dans ce chapitre, nous nous sommes penchés sur la gestion fonctionnelle des erreurs. Nous avons pu découvrir les types Try
, Option
, Either
de la librairie officielle.
Notez que pour ce dernier celui-ci ne possède la méthode map
qu’à partir de la version de 2.12 de Scala. Vous pouvez toutefois l’obtenir en utilisant des librairies externes comme cats.
Avec cette base, nous pourrons nous pencher sur des termes qui font un peu plus peur à savoir les monades, functor, semigroup etc.
Mais avant cela, j’ai besoin d’introduire un nouveau concept les Typeclasses. Le prochain article sera donc consacré à la définition de ces Typeclasses. En attendant la suite, n’hésitez pas à poser vos questions en commentaire.