Publié le 06/05/2019 Par Clément Carreau

On entend souvent dire que les monades sont des objets incompréhensibles. Puis, lorsque quelqu’un commence à les comprendre, il n’est plus capable d’expliquer ce qu’elles sont simplement. Clément a pourtant trouvé une parle rare dans la langue de Shakespeare, et nous propose sa traduction Anglais/Français et Js/Scala !!!

Traduction française et en Scala de l’excellent article de Dan Piponi et de son interprétation en JS.

Les monades sont probablement un des concepts à la fois les plus connus de l’univers de la programmation fonctionnelle et sûrement également l’un des moins bien compris. Il est temps de clarifier les incompréhensions et de lever les points d’interrogation sur les monades.

Pour la plupart des gens, voilà comment se passe la compréhension des monades :

Et vous risquez fort de tomber dans une boucle infinie tant les articles traitant du sujet frôlent l’incompréhensible. Heureusement pour nous, Dan Piponi a écrit un excellent article que je me suis empressé de traduire en Scala pour vous le partager.

Le contexte des monades

Contrairement aux langages purement fonctionnels, si vous prenez un langage comme Java ou le C++, on ne peut pas dire que ces derniers respectent les lois mathématiques. Ce sont des langages à « effets de bord ».

Envisageons par exemple une fonction Java prenant un entier et retournant un entier. De prime abord, on pourrait croire par cette définition que l’on a affaire à une simple fonction mathématique pour laquelle pour tout x, on retourne un y. Or il n’en est rien. Notre fonction peut très bien lire depuis une base de données, écrire dans un fichier ou effectuer tout autre action avec le « monde extérieur », ce qu’un langage purement fonctionnel ne permet pas. Pour les besoins de cet article, on rappelle que Scala n’est pas un langage purement fonctionnel.

Comment « débugger » les fonctions

Implémentons alors une telle fonction en Scala : 

def add2(value: Int) = value+2

Imaginons maintenant que nous aimerions pouvoir « débugger » de telles fonctions.
Nous pourrions alors retravailler notre méthode de la sorte :

def add2(value: Int) {
   println("add2 called")
   value + 2
}

Cela ne vous rappelle rien ? C’est le fameux « effet de bord », l’interaction avec le monde extérieur, représenté ici par la console. Mais si l’on souhaite rester purement fonctionnel, c’est interdit. Il nous faut donc faire en sorte que le log fasse partie de la sortie de la méthode. Rien ne doit être « caché ».

def add2(value: Int) = (value + 2, "add2 called")

Imaginons désormais que nous souhaitons pouvoir « débugger » la composition de plusieurs appels de fonctions en faisant un appel de type f(g(x)).

Avec nos méthodes de base, nous pourrions tout à fait écrire la chose suivante :

add2(add2(0)) // 4

Le problème ce que nous ne pouvons pas composer des méthodes de type (Int) => (Int, String). En effet, le type de sortie de f n’est plus le type d’entrée de g.

Créons désormais une classe et une fonction permettant de composer deux fonctions débuggables, f et g.

object Monad {
   def compose[A, B, C](f: B => (C, String), g: A => (B, String)) = {
      (value: A) => {
         val (gRes, gLog) = g(value)
         val (fRes, fLog) = f(gRes)
         (fRes, fLog+gLog)
      }
   }
}

Nous avons donc bien un moyen de composer des fonctions « débuggables ».

La méthode compose

Évidemment, nous aurions pu modifier notre fonction add2 afin qu’elle accepte aussi une String en deuxième argument mais nous aurions dû faire de même pour toutes les fonctions que nous souhaitions rendre « débuggables ». Il aurait donc fallu rajouter beaucoup de code, ce qui aurait pu rendre confuse la logique « métier ». Mieux vaut donc ne pas toucher aux fonctions et les composer ensemble par un biais extérieur, à savoir ici, la fonction compose.

Il serait désormais intéressant de simplifier la méthode compose. En effet, une application de type f(g(x)) serait beaucoup plus simple et aussi plus logique. Pour cela, la seule solution est d’avoir une méthode permettant de transformer n’importe quelle « débuggable » en méthode composable. Nous appellerons cette méthode bind.

object Monad {
   def bind[A, B](f: A => (B, String)): ((A, String)) => (B, String) = {
      (value: (A, String)) => {
         val (newVal, log) = f(value._1)
         (newVal, value._2 + log)
      }
   }
   def compose[A, B, C](g: A => B, f: B => C) = (value: A) => f(g(value))
}

Nous pouvons désormais composer nos fonctions comme suit :

import Monad._
compose(bind(add2), bind(add2))(3, "") //7

Désormais, tout ce que l’on désire composer acceptera (Int,String).

La méthode unit

Comme vous avez pu le remarquer, nous avons précédemment dû passer l’argument

(3, "")

. Il serait plus confortable d’avoir une fonction permettant de créer automatiquement, et de la bonne façon, une valeur « débuggable ». Appelons alors cette méthode unit.

def unit[A](x: A) = (x, "")

Nous avons donc désormais

compose(bind(add2), bind(add2))(unit(3))

La fonction lift

Qu’en est-il des fonctions que l’on souhaite composer mais pour lesquelles nous ne voulons pas de log en sortie ? Actuellement, il n’est pas possible de composer de telles fonctions.
Il nous faudrait donc un système équivalent à unit mais pour les fonctions. Nous appellerons cette fonction lift.

def lift[A, B](f: A => B): A => (B, String) = (value: A) => unit(f(value))

Comme vous pouvez le constater, il suffit de passer le retour de la fonction à unit pour que, ainsi, n’importe quelle fonction devienne une fonction « débuggable » (avec un log vide). De ce fait, nous pouvons alors la composer.

Voici un exemple de ce qui est désormais possible de faire :

Nous allons définir des fonctions que l’on voudrait composer, certaines retournent un log.

def add2(value:Int) = (value+2, "add2 called. ")
def intToString(value:Int) = value.toString
def stringToInt(value:String) = (value.toInt, "stringToInt called. ")

Afin de pouvoir les composer, il est nécessaire de se servir de nos fonctionnalités bind et lift :

val add2_ = bind(add2)
val intToString_ = bind(lift(intToString))
val stringToInt_ = bind(stringToInt)

Seule intToString utilise lift afin de retourner un log vide.

Nous pouvons désormais les composer comme suit :

val f = compose(unit[Int], compose(compose(add2_, intToString_), stringToInt_))
f(3) // (5,"add2 called. stringToInt called. ")

Nous nous retrouvons désormais avec :

  • lift qui nous permet de convertir une fonction « simple » en fonction « débuggable »
  • bind qui nous permet de transformer une fonction « débuggable » en fonction « composable »
  • unit qui nous permet de transformer une valeur « simple »” en type requis pour le « débuggage »

Comment écrire une monade Writer

Ces 3 fonctions (ou plutôt ces 2 fonctions si l’on ne compte pas lift qui se base sur unit) sont les pierres angulaires des monades. Le code d’exemple que nous venons d’écrire correspond au Writer Monad d’Haskell.

Au final, une monade n’est rien d’autre qu’un design pattern vous disant deux choses :

  • Pour deux fonctions que vous souhaitez composer, il existe une méthode bind vous permettant de faire le lien entre les deux.
  • Il existe une méthode unit qui permet de transformer votre valeur d’entrée en valeur requise par la forme composable de vos méthodes.

Mais comment se fait-il qu’Option, List, Either et tant d’autres monades Scala ne ressemblent pas à ce que l’on a vu plus haut ? Ne serait-il pas possible de réécrire ce que nous avons vu du Writer d’une autre façon ?

Et bien si ! Vous pouvez remarquer que notre objet Monad ne servait qu’à nous donner accès aux méthodes nous permettant de composer nos fonctions, tandis qu’en Scala, Option, List, Either et tant d’autres sont des valeurs dont nous nous servions et que nous instancions.
Nous allons donc beaucoup simplifier les choses en nous basant sur ces exemples pour écrire l’équivalent d’une monade Writer.

En s’inspirant du code Scala, on peut déjà imaginer quelques modifications. En regardant le code d’Option, on peut s’apercevoir de 3 choses importantes :

  • Option attend une valeur dans son constructeur (plus besoin de unit, ce sera le constructeur)
  • L’équivalent de notre méthode bind s’appelle flatMap
  • Il existe une méthode map servant à travailler sur la valeur encapsulée dans la monade

Première étape : nous avons besoin d’un constructeur pour notre Writer.

case class Writer[A](value:A, log_String="")

Ici, nous partons d’une valeur par défaut, et si non renseigné, d’un log vide.

Deuxième étape : passons désormais à la méthode permettant de travailler sur la valeur encapsulée (de type A) et non sur le log.

def map[B](f: A => B): Writer[B] = Writer(f(value), log)

Dernière étape : il est nécessaire de permettre la modification de cette valeur tout en ajoutant un log, la fameuse méthode flatMap :

def flatMap[B](f: A => Writer[B]) : Writer[B] = {
   f(value) match {
      case Writer(fValue, fLog) => Writer(fValue, log + fLog)
   }
}

Nous pouvons alors utiliser notre monade comme Option, comme dans l’exemple ci-dessous :

def multiply(n:Int)(value:Int) = {
   Writer(value*n, s"multiplied by $n")
}
Writer(5).map(_*3).flatMap(multiply(3))

Ou bien encore :

Writer(5)
   .map(_*3)
   .flatMap(x => Writer(x*3, "mutlipled by 3."))
   .flatMap(x => Writer(x+2, "added 2."))
   .flatMap(x => Writer(x.toString, "turned into a String."))

Pas encore de commentaires

Publier un commentaire

Auteur

Clément Carreau

Issu d'une formation dans les systèmes distribués c'est avec une certaine logique que Clément s'est tourné vers le monde du big data dès la fin de sa formation. La constante évolution du domaine, les opportunités techniques et les problématiques associées sont autant de raisons l'ayant poussé à faire ce choix. Clément a rejoint Meritis en 2016 et évolue aujourd'hui en tant qu'ingénieur big data, travaillant conjointement avec des data-scientists à la mise en place de solutions machine learning.