Les types monadiques de Scala – Le type Either

Dans un premier article, nous avons introduit le type monadique Option. Nous avons vu que ce type permet de traduire l’absence de valeur ou de résultat et comment l’exploiter efficacement à l’aide des méthodes map et flatMap. Si vous n’avez pas eu l’occasion de le lire, je vous encourage fortement à le faire avant de commencer la lecture de ce qui suit.

Dans ce nouvel article, je vous propose d’aborder le type monadique Either, particulièrement utile pour la gestion des erreurs et qui peut remplacer de manière très avantageuse les mécanismes de checked exceptions. Nous approfondirons à cette occasion notre compréhension des monades et nous verrons comment combiner deux types de monades différents.

Définition du type Either

Comme dans le dernier article, je me permettrai un abus de langage en parlant des types Either et Option même s’ils réfèrent à des types abstrait (aussi appelé constructeur de type) et non à des types concrets (comme Either[A, B] ou Option[A]).

Le type Either[A, B] est un type abstrait générique ayant deux sous types concrets à savoir Left[A, B] et Right[A, B]. Dans le précédent article sur le type Option, nous avions présenté l’implémentation d’une méthode divide retournant une instance de Some[Double] ou None si le dénominateur est 0. Nous pourrions changer notre approche et utiliser le type Either pour retourner soit la valeur calculée, soit un message d’erreur :

scala> def divide(x:Double, y:Double):Either[String, Double] = if (y == 0) Left("Can't divide by 0") else Right(x/y)
divide: (x: Double, y: Double)Either[String,Double]

scala> divide(4, 0)
res0: Either[String,Double] = Left(Can't divide by 0)

scala> divide(4, 2)
res1: Either[String,Double] = Right(2.0)

Si la valeur du diviseur est 0, la fonction divide retourne le message d’erreur encapsulé dans un Left. Dans le cas contraire, elle retournera le quotient dans un Right. Par convention, les erreurs sont retournées à gauche et le résultat à droite. Cette convention n’est pas forcément très explicite mais en l’appliquant scrupuleusement, il n’y a pas de soucis à avoir. Un moyen mnémotechnique pour ne pas les confondre est de voir que Right peut se traduire en « Correct » comme dans it’s all right! et de voir que Left vient du verbe anglais to leave et qu’il peut se traduire en « Parti », car en cas d’erreur nous sommes parti du traitement.

Traitement des valeurs de type Either

Nous pouvons maintenant nous demander comment exploiter la valeur retournée par notre méthode. La première méthode est la suivante :

scala> val result = divide(4, 2)
result: Either[String,Double] = Right(2.0)

scala> if (result.isRight) {
     |   "Result is = " + result.right.get
     | } else {
     |   result.left.get
     | }
res2: java.lang.String = Result is = 2.0

Ce code procédural peut être avantageusement remplacé par l’utilisation de la méthode fold. Pour un type Either[A, B], la méthode fold prendra en argument deux fonctions :

  • la première de type f: A => C qui sera appliquée à la valeur gauche,
  • la seconde de type g: B => C qui sera appliquée à la valeur droite et retournera un résultat de type C.
scala> result.fold(
     |   { y => y }, 
     |   { x => "Result is = " + x })
res3: java.lang.String = Result is = 2.0

Composition avec le type Either

Imaginons maintenant que nous souhaitons ajouter les résultats de deux divisions effectuées avec la méthode divide :

scala> def add(x:Either[String, Double], y:Either[String, Double]) =
     | if (x.isRight && y.isRight) {
     |     Right(x.right.get + y.right.get)
     | } else if (x.isLeft) {
     |     x
     | } else {
     |     y
     | }
add: (x: Either[String,Double], y: Either[String,Double])Either[String,Double]

scala> add(divide(4, 2), divide(3, 4))
res4: Either[String,Double] = Right(2.75)

scala> add(divide(4, 0), divide(3, 4))
res5: Either[String,Double] = Left(Can't divide by 0)

Cette implémentation de add est correcte mais peut facilement aboutir à une erreur d’implémentation (surtout si nous augmentons le nombre de Either à composer). Heureusement, il existe une façon beaucoup plus sûre d’implémenter cette fonction add en appliquant le principe : flatmap that shit !.

scala> def add(eX:Either[String, Double], eY:Either[String, Double]) = eX.right.flatMap { x => eY.right.map { y => x + y } }
add: (eX: Either[String,Double], eY: Either[String,Double])Either[String,Double]

Nous retrouvons ici les méthodes map et flatMap que nous avions rencontrées dans l’article sur les Options. La différence avec le type Either est que nous appliquons ces méthodes sur la projection à gauche ou à droite (.left, .right) de la valeur. Dans notre cas, nous souhaitons appliquer ces méthodes sur la projection à droite des valeurs de type Either, afin de n’appliquer les transformations que si le résultat de la méthode divide est un succès (et donc une instance de Right).

La méthode map appliquée à la projection à droite du type Either[A, B] prend en argument une fonction f: B => C et retourne un objet de type Either[A, C]. La méthode flatMap appliquée à la projection à droite du type Either[A, B] prend en argument une fonction f: B => Either[A, C] et retourne un objet de type Either[A, C]. Dans notre cas, A est de type String et B et C sont de type Double.

Nous pouvons aussi utiliser une for comprehension pour définir cette même méthode :

def add(eX:Either[String, Double], eY:Either[String, Double]) = for {
    x <- eX.right
    y <- eY.right
} yield x+y

Là encore, l’utilisation d’une for comprehension apporte un peu plus de lisibilité, qui sera accentuée dans le cas où nous avons non plus deux, mais trois monades ou plus pour lesquelles nous souhaitons extraire la donnée. Le compilateur transforme ensuite la for comprehension en suite de (n-1) flatMap et un map.

Comment faire maintenant si nous souhaitons appliquer un traitement sur la projection à gauche ? Nous allons modifier légèrement notre implémentation de la méthode add :

scala> def add(eX:Either[String, Double], eY:Either[String, Double]):Either[String, Double] = eX.right
       .flatMap { x => eY.right.map { y => x + y } }.left.map { m => "Error during add process : " + m }
add: (eX: Either[String,Double], eY: Either[String,Double])Either[String,Double]

scala> add(divide(4, 0), divide(3, 4))
res6: Either[String,Double] = Left(Error during add process : Can't divide by 0)

Pattern matching

Comme pour le cas de l’Option, il est possible de faire du pattern matching sur le type Either afin de récupérer la valeur qu’il contient :

def addParams(x:Double, y:Double) = divide(x, y) match {
  case Left(error) => BadRequest(error)
  case Right(result) => Ok("The result of %d / %d is %d".format(x, y, result))
}

Nous aurions bien évidemment pu utiliser la méthode fold pour arriver au même résultat.

Composition de Either et Option

Il peut arriver de rencontrer des méthodes retournant des Options et d’autres des Either et que nous souhaitions les combiner pour obtenir un résultat. Prenons l’exemple de notre méthode divide retournant une valeur de type Either et d’une autre méthode retournant une Option contenant un taux quelconque :

def findValue(id:Long):Option[Double] = if (id == 1) None else Some(id - 2)

Cette méthode retourne Some(taux) si l’id passé en argument correspond à un taux existant et None dans le cas contraire.

Nous allons maintenant coupler cette méthode avec divide (définie plus haut) pour effectuer un calcul simple :

def calcValue(id:Long):Either[String, Double] =
    findValue(id).map( x => Right(x) ).getOrElse(Left("The ID doesn't match")) // transform the Option to Either
    .right.flatMap( x => divide(1, x) )                                        // compose it with another Either

De prime abord, cette méthode peut sembler un peu complexe. Nous pouvons la décomposer en deux parties pour mieux la comprendre :

findValue(id).map( x => Right(x) ).getOrElse(Left("The ID doesn't match"))

la méthode findValue retourne une Option[Double]. Or, nous souhaitons la combiner avec une méthode retournant un type Either[String, Double]. La méthode map nous permet de transformer notre Option[Double] en Option[Right[Nothing, Double]]. Nous souhaitons ensuite extraire la valeur de l’Option et nous utilisons pour ce faire la méthode getOrElse qui retournera un Left contenant un message d’erreur dans le cas où findValue retournerait None. A l’issue de cette première étape, nous avons transformé notre Option[Double] en Either[String, Double].

L’étape suivante consiste à combiner l’appel à la méthode divide. Ceci se fait simplement en appelant flatMap sur la projection à droite du résultat issu de la première étape. Pour rappel, le flatMap est défini comme suit :

Either[A, B].right.flatMap(B => Either[A, C]):Either[A, C]

Nous pouvons aussi vérifier que notre méthode retourne bien le résultat escompté :

scala> calcValue(3)
res7: Either[String,Double] = Right(1.0)

scala> calcValue(2)
res8: Either[String,Double] = Left(Can't divide by 0)

scala> calcValue(1)
res9: Either[String,Double] = Left(Id doesn't match)

Intuitions

Nous commençons à mieux appréhender ce qu’est une monade et comment l’utiliser. Nous avons vu qu’elles sont caractérisées par la présence des méthodes map et flatMap permettant d’appliquer des transformations sur leur contenu.

Nous pourrions maintenant nous intéresser à une implémentation possible de ces deux méthodes en commençant par la monade Option :

trait Option[+A] {
  def map[B](f: A => B):Option[B] = ...

  def flatMap[B](f: A => Option[B]):Option[B] = ...
}

case object None extends Option[Nothing]
case class Some[A](a:A) extends Option[A]

Nous avons ici une définition du type abstrait Option[A] et de ses deux sous types Some[A] et None. Notez que nous utilisons le mot-clé case afin de pouvoir utiliser le pattern matching sur Some et None. Nous définissons aussi A comme étant covariant dans Option[+A] afin de permettre l’assignation d’un None à une valeur de type Option[A]A est quelconque. Nothing étant sous type de n’importe quel type et A étant covariant, Option[Nothing] est un sous type de Option[A] ce qui permet d’avoir une assignation du genre :

val d:Option[Double] = None

Voici une implémentation possible des méthodes map et flatMap :

def map[B](f: A => B):Option[B] = this match {
    case None => None
    case Some(a) => Some(f(a))
  }

  def flatMap[B](f: A => Option[B]):Option[B] = this match {
    case None => None
    case Some(a) => f(a)
  }

On peut voir que l’implémentation est assez simple. Si nous sommes en présence d’un None, les deux méthodes renverront None. Dans le cas où l’Option renferme une valeur, celle-ci est extraite grâce au pattern matching et appliquée à la fonction f.

Nous pouvons aussi imaginer l’implémentation partielle de la monade Either. Celle-ci est un peu moins triviale car nous devons considérer les types relatifs aux projections à droite et à gauche comme vous pouvez le constater dans le code suivant :

trait Either[+A, +B] {
  def left = new LeftProjection(this)
  def right = new RightProjection(this)
}

// right projection of an Either type
class LeftProjection[+A, +B](e:Either[A, B]) {
  def map[C](f: A => C):Either[C, B] = e match {
    case Left(a) => Left(f(a))   // returns a Left with a transformed value a
    case Right(b) => Right(b)
  }

  def flatMap[C, BB >: B](f: A => Either[C, BB]):Either[C, BB] = e match {
    case Left(a) => f(a)         // returns a new Either calculated from f applied to a
    case Right(a) => Right(a)
  }
}

// left projection of an Either type
class RightProjection[+A, +B](e:Either[A, B]) {
  def map[C](f: B => C):Either[A, C] = e match {
    case Left(a) => Left(a)
    case Right(b) => Right(f(b)) // returns a Right with a transformed value a
  }
  def flatMap[AA >: A, C](f: B => Either[AA, C]):Either[AA, C] = e match {
    case Left(a) => Left(a)
    case Right(b) => f(b)        // returns a new Either calculated from f applied to a
  }
}
case class Left[A, B](a:A) extends Either[A, B]
case class Right[A, B](b:B) extends Either[A, B]

Nous voyons que Left et Right sont deux sous types de Either qui lui même implémente les méthodes left et right permettant d’obtenir les projections à gauche et à droite. Les implémentations des méthodes map et flatMap dans les projections à gauche et à droite sont opposées dans le sens où l’application d’une fonction sur une projection à gauche ne s’appliquera que sur une valeur gauche (Left) et inversement.

Nous ne nous attarderons pas sur les définitions des types des classes et des méthodes dans cet article, ce sujet pourrait faire l’objet d’un article à lui tout seul. Néanmoins, vous connaissez maintenant suffisamment bien les méthodes map et flatMap pour comprendre ce qu’elles attendent en argument et ce qu’elles retournent.

Conclusion

Voilà pour ce nouvel article sur les types monadiques en Scala et plus particulièrement sur le type Either[A, B]. Nous avons pu voir comment utiliser cette nouvelle monade, comment la composer avec d’autres du même type et avec la monade Option et pour finir, nous avons vu comment ces deux monades pourraient être implémentées, ce qui a conduit au passage à comprendre un peu mieux leur fonctionnement grâce à cette vue sous le capot.

Dans notre prochain article, je vous proposerai l’étude d’une autre monade, probablement l’une des plus utilisées puisqu’il s’agit de la Liste.

Billets sur le même thème :

Laisser un commentaire