Gérer les erreurs par le système de types

Un grand nombre de développeurs a pour habitude d’utiliser uniquement les exceptions pour     gérer les erreurs dans leurs programmes. Ce mode de fonctionnement ne correspond pas au paradigme fonctionnel. Nous allons dans cet article observer comment gérer les erreurs en adéquation au paradigme fonctionnel à travers des cas d’utilisation courant en utilisant le système de types. Nos exemples seront écrits en Scala.

Gérer l'absence de valeurs

Un appel en base de données infructueux, un objet mal instancié, la gestion de valeurs manquantes sont monnaie courante pour tout programmeur. En Java  nous faisons régulièrement face à des cas de valeurs absentes, NullPointerException est même l’erreur la plus courante. Rémy-Christophe Schermesser avait écrit un article pour nous expliquer comment ne plus avoir de ce type d’erreur en Java (https://blog.octo.com/comment-ne-plus-avoir-de-nullpointerexception-en-java/). Il conclut en nous disant que la meilleure solution est d’utiliser le pattern Option. Contrairement à Java, ce pattern est intégré à Scala et Haskell.

Le type Option comprend le singleton None et les instances de la classe Some. Une variable de ce type vaut Some(valeur) si elle contient une valeur et None() dans le cas contraire. Option est le type d'une valeur éventuelle. Pour extraire la valeur, il faut obligatoirement s’occuper du cas où la valeur est None :

def show(x: Option[String]) = x match {
  case Some(s) => s
  case None => "?"
}

Pour comprendre l'intérêt du type Option, nous allons prendre l’exemple d’une banque :

case class User(id: Int, name: String, age: Int, amount: Int)

val octobank = Map(1 -> User(1, "Pierre", 24, 100), 2 -> User(2, "Paul", 24, 100))

Nous définissons ici une banque contenant deux utilisateurs (Paul et Pierre).

On définit une méthode permettant de récupérer un client par id :

def findById(id: Int): Option[User] = octobank.get(id)

A noter que le type de retour de cette méthode est Option[User], en effet findById peut très bien ne pas trouver un User pour une id donnée.

Ensuite on affiche le nom du User ayant pour id 2 :

findById(2).foreach( user => println(user.name))

ou

findById(2) match {
   case Some(user) => println(user.name)
   case None => println("")
}

mais l'IDE m'empêche de faire :

Jusqu’ici tout va bien, le résultat affiché est Paul. Que se passe-t-il si l’on cherche le nom du User ayant pour id 3 ?

findById(3).foreach( user => println(user.name))

Cette commande ne nous donne rien, car findById(3) nous renvoie None. On peut interpréter le type Option comme une collection pour laquelle l’ensemble des méthodes map, flatmap, foreach … sont déjà implémentées. La méthode foreach va seulement traiter les instances Some[User] et pas les instances None. Ceci nous évite donc toutes les vérifications préalables visant à s’assurer que findById a bien fonctionné. Les Options permettent donc de chaîner des opérations tout en évitant les lignes de code se protégeant d’une éventuelle valeur null. Voici un exemple d’un chaînage un peu plus sophistiqué que le dernier :

findById(2).filter(_.name.head == 'P')
.map(_.name.toUpperCase).foreach(println)

ou avec une for-comprehension :

for (a <- findById(2) if a.name.head == 'P') yield println(a.name.toUpperCase)

On commence par chercher l’utilisateur ayant pour id 2, on garde ce dernier seulement si la première lettre de son nom commence par P. Ensuite on transforme son nom en majuscule puis on l’affiche. Le tout sans jamais vérifier si l’utilisateur ayant pour id 2 existe vraiment.

En résumé, l’avantage d’utiliser le type Option est double. D’une part, cela donne un code plus clair et concis par rapport à l’utilisation d’exceptions et de vérifications de valeurs. D’autre part, cela empêche la lecture directe de la variable en forçant celui qui l’utilise soit à utiliser les primitives déjà implémentées soit à extraire proprement la valeur en l'obligeant à gérer le cas None.

Gérer les exceptions

Try est utilisé pour représenter un calcul qui peut soit retourner une valeur (Success) soit une exception (Failure).

Reprenons notre exemple précédent, sauf que cette fois, nous allons récupérer l’id du User à chercher dans un fichier.

val input = Source.fromFile("id.txt").getLines.mkString

Il faut maintenant le caster en entier pour pouvoir chercher le client, le problème est que le fichier id.txt peut contenir autre chose qu’un entier, il faut donc gérer ce cas. Nous allons utiliser Try.

val id = Try(input.toInt)

Supposons que le fichier id.txt contient “1a3”. input.toInt va échouer et id vaudra Failure(java.lang.NumberFormatException: For input string: "1a3"), on peut observer que Failure encapsule l’exception.

Supposons à présent que le fichier id.txt contient “13”. Dans ce cas input.toInt va fonctionner et id vaudra Success(13).

Comme pour Option, Try peut être vu comme une collection. Il nous est donc possible d’utiliser les fonctions map, flatMap, foreach … Pour afficher l’id du ficher incrémenté de 1, on fera donc :

id.map(_ + 1).foreach(println)

Là encore, pas besoin de s’assurer que id a bien une valeur.

Gérer la validation de données

Lorsque notre programme reçoit des données de l’extérieur, il se doit la plupart du temps de les valider. On pense notamment au remplissage de formulaires par un utilisateur. Là encore Scala possède un type spécial pour gérer ces validations, le type Validation. Ce dernier ne fait pas partie de base de Scala mais d’une bibliothèque additionnelle Scalaz (prononcé Scala-Zed). C’est un objet qui hérite soit de “Success” soit de “Failure”. Ce type a notamment l’avantage de permettre d’accumuler les erreurs que le programme rencontre sans stopper son exécution, un “fail-slow”. Cependant nous allons commencer par prendre un exemple de “fail-fast”.

Supposons que notre banque OctoBank souhaite que son système puisse ajouter de nouveaux clients. La création d’un utilisateur est bien évidemment soumise à certaines règles. Chez OctoBank il y a en trois :

Le nom du client ne peut pas contenir de chiffre :

def checkName(user:User):ValidationNel[String, User] = {
    if (user.name.forall(_.isLetter))
      user.successNel
    else
      "Client's name cannot contains digit".failNel
  }

Le client doit avoir plus de 18 ans :

def checkAge(user:User):ValidationNel[String, User] = {
    if (user.age >= 18 ) user.successNel
    else "A client must be an adult !".failNel
  }

L’apport initial du client doit être supérieur à 10€ :

def checkAmount(user:User): ValidationNel[String, User] = {
    if (user.amount > 10) user.successNel
    else "A client must have at leat 10€ in the account".failNel
  }

Ecrivons maintenant la fonction qui va vérifier tout ça :

def checkUser(p : User) : ValidationNel[String, User] = {
    for {
     a <- checkName(p)
     b <- checkAge(a)
     c <- checkAmount(b)
   } yield c
}

Petit point de syntaxe :

for {
    x <- c1
    y <- c2
    z <- c3 
} yield {...}

est un sucre syntaxique pour :

c1.flatMap(x => c2.flatMap(y => c3.map(z => {...})))

Arthur, 19 ans, fait son premier apport de 100€. Le banquier fait appel à la fonction checkUser :

checkUser( User(3, “Arthur”, 19, 100 )

Le programme lui répond :

Success(User(3,Arthur,19,100))

Super, OctoBank a un nouveau client.

Thomas (pseudo : Thomas01) , 17 ans, fait son premier apport de 5€. Là encore le banquier fait appel à la fonction checkUser :

checkUser( User(4, “Thomas01”, 17, 5) )

Le programme lui répond :

Failure(NonEmptyList(Client's name cannot contains digit))

Ok, sauf que le banquier (récemment recruté chez OctoBank) aurait bien aimé savoir que Thomas était aussi trop jeune et que son apport est pas assez important.

Modifions donc la fonction checkUser :

def checkUser(p: User) = (
    (checkAge(p) |@| checkName(p) |@| checkAmount(p)))({(_,_,u) => u})

L'opérateur |@| est un constructeur applicatif, dans notre cas cela veut simplement dire qu'il permet d'appliquer une fonction sur deux valeurs de type Validation. Par exemple pour additionner deux instances Success :

scala> (Success(1) |@| Success(2)) { _ + _ }
res1: Validation[String,Int] = Success(3)

Revenons à notre banquier. Ce dernier fait de nouveau appel à la fonction :

checkUser( User(4, “Thomas01”, 16, 100) )

et cette fois-ci, le programme lui répond :

Failure(NonEmptyList(A client must be an adult !, Client's name cannot contains digit, A client must have at leat 10€ in the account))

Le banquier a toutes les informations maintenant.

Conclusion

Utiliser le système de types pour gérer les erreurs nous permet de séparer les cas d’échecs des cas de succès en utilisant un type pour chacun d’eux. En utilisant cette approche, un grand nombre d’erreurs peuvent être évitées grâce à la vérification de type dans l’IDE ou au pire à la compilation, adieu les NullPointerException.

Utiliser le type pour gérer les erreurs contribue aussi à avoir un code plus concis et plus clair.