Publié par

Il y a 5 mois -

Temps de lecture 11 minutes

Découvrir la programmation fonctionnelle #4 | Erreurs

« 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 valeur
  • None : 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 :

  1. Ajouter un commentaire précisant que la fonction peut renvoyer une exception (ou utiliser l’annotation @throws qui est purement à titre indicatif)
  2. 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 le Try se sont toutes bien exécutées et valeur représente la valeur de retour.
  • Failure(erreur) : Une des instructions a placées dans le bloc Try a échoué et erreur constitue un Throwable 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]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 gauche
  • Right[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 Options. 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.

Publié par

Commentaire

Laisser un commentaire

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

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.