Il y a 11 ans -
Temps de lecture 12 minutes
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 typeC
.
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]
où 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
.
Commentaire
0 réponses pour " Les types monadiques de Scala – Le type Either "
Published by sdaclin , Il y a 8 ans
Clap clap, présentation très claire, merci beaucoup.