Un programme sans boucle et sans structure de données ne vaut pas la peine d’être écrit.
– Alan Jay Perlis
Sans épiloguer sur cette citation, les programmes informatiques ont un grand besoin de manipuler une certaine quantité de données. Le traitement manuel n’étant pas envisageable, traiter ces éléments requiert de manipuler des structures de contrôle Ô combien répétitives nommées: boucles.
Dans la première partie de la série sur la découverte de la programmation fonctionnelle #1, nous avons vu la notion de fonction ainsi que des définitions fondamentales nous permettant de commencer notre apprentissage sur des bases solides. Puis, dans la seconde partie sur la découverte de la programmation fonctionnelle #2 nous avons traité l’immutabilité et la récursivité introduisant des concepts clés. L’immutabilité nous a permis d’introduire le concept du “update as you copy”. En d’autres termes, lors d’une modification, favoriser la création d’un nouvel objet qui est la copie du précédent mais possédant les modifications souhaitées. La récursivité, quant à elle, nous a permis de découper des problèmes complexes en une suite de sous problèmes plus simple à traiter. La récursivité s’avère être un allié très puissant pour la résolution de problèmes cependant sa complexité généralement due au niveau d’imbrication des différents appels récursifs peut introduire des bugs accidentels.
Dans cet article, nous parlerons dans un premier temps des boucles dans un contexte global. Puis nous introduirons quelques boucles fonctionnelles permettant d’écrire des fonctions pures. Nous finirons par une exploration d’un concept puissant que nous traiterons avec Scala : les for-comprehension
s.
Les boucles
Reprenons l’exemple de la fonction count
qui utilise la mutabilité locale écrite dans l’article précédent :
def count(l: List[String]): Int = { var cnt = 0 l.foreach(el => cnt += 1) cnt }
A noté que l.foreach
est une manière de boucler sur la liste l
. Néanmoins, cette méthode est souvent utilisée pour induire des effets de bord car elle ne retourne pas de valeur.
Suite au chapitre sur la récursivité, nous pouvons à présent réécrire la méthode count
en utilisant la récursivité :
def count(l: List[String]): Int = l match { case Nil => 0 case h::t => 1 + count(t) }
Mieux ! Nous pouvons la réécrire en une fonction récursive terminale (Tail Recursive) :
def count(l: List[String]): Int = { @tailrec def go(l: List[String], acc: Int = 0): Int = l match { case Nil => acc case h::t => go(acc + 1, t) } go(l) }
Pour des cas aussi simples que la fonction count
, sachez qu’il existe également des fonctions appelées fold
permettant d’implémenter ce type de boucle assez simplement. Il en existe 2 versions. Une version head recursive (foldRight
) et une version tail recursive (foldLeft
).
Ci-dessous leur signature en Scala :
def foldLeft[B](z: B)(op: (B, A) => B): B
def foldRight[B](z: B)(op: (A, B) => B): B
Décodons tout ceci :
B
: représente un type générique de retour. Il a pour vocation d’être remplacé par un type de base (Int, Double, …) ou un type complexe (List, Option, etc.).A
: représente un type générique d’entrée. Idem, il sera remplacé par un type de base ou un type complexe.z
: pour zéro représente la valeur de départ.op
: est-ce qu’on appelle une lambda ou fonction anonyme. Elle se compose de 2 parties séparées par une flèche (=>). La première partie représente le type des paramètres de la lambda et la seconde partie le type de la valeur de retour. Cette fonction a pour rôle de transformer notre type d’entrée en notre type de retour.
Sachant cela, nous pouvons réécrire notre méthode count
avec la fonction foldLeft
qui est à préférer au foldRight car elle est récursive terminale :
def count(l: List[String]): Int = l.foldLeft(0){ (acc: Int, el: String) => acc + 1}
Ceci dit, nos besoins sont souvent un peu plus complexes que compter le nombre d’éléments d’une liste. Nous souhaitons par exemple calculer le double de chaque nombre ou appliquer des transformations complexes à chaque élément de la liste. Pour ce faire, nous avons dans notre boîte à outils une méthode dédiée: la fonction map
.
La fonction map
Étant une des fonctions les plus utilisées en programmation fonctionnelle, la fonction map
nous permet d’effectuer une opération sur l’ensemble des éléments d’une collection ou sur le “contenu” d’un objet. Elle prend en paramètre une fonction qui sera appelée sur le(s) élément(s) correspondant(s).
À titre d’exemple, nous pouvons multiplier le contenu d’une liste d’entiers avec une lambda : x => x * 2
qui à chaque x
associe son double :
val l = List(1, 2, 3, 4) val l2 = l.map(x => x * 2)
Le résultat obtenu dans l2
est le suivant :
scala> l2 res0: List[Int] = List(2, 4, 6, 8)
l2
est une nouvelle liste créée à partir de l
dont le contenu représente les éléments de l
multipliés par 2.
La fonction map permet également d’effectuer une opération sur des objets de type Option
afin d’éviter de récupérer le contenu de l’option avant de pouvoir effectuer notre opération. La récupération du contenu doit être évitée en programmation fonctionnelle car elle est sujette à des effets de bords comme une exception lancée.
Prenons l’exemple suivant :
val a = Some(2)
Si nous voulons appliquer une fonction sur le contenu de l’Option nous devrons récupérer son contenu avant d’appliquer notre fonction comme suit :
val b = a.get * 2
Ce qui pose un problème de transparence référentielle.
On rappelle que la transparence référentielle est le fait de pouvoir remplacer un appel de fonction par la valeur attendue de la fonction. Or, l’appel à la fonction get
lance une exception si l’Option vaut None
.
Nous pourrions éviter ce problème en faisant un test sur la valeur de l’Option :
val b = a match { case None => None case Some(p) => Some(p * 2) }
Ou alors, dans un style impératif en utilisant la méthode isDefined
de l’Option
:
val b = if (a.isDefined) Some(a.get * 2) else None
Dans ce cas précis, la méthode map
nous facilite la vie en nous évitant d’effectuer ce contrôle explicitement :
scala> val a = Some(2) scala> val b = a.map(x => x * 2) scala> b res1: Option[Int] = Some(4)
Et si a
vaut None
:
scala> val a = None scala> val b = a.map(x => x * 2) scala> b res1: Option[Int] = None
La valeur None
est bien conservée.
De façon imagée, la méthode map ouvre une boîte, appelle une fonction sur son contenu et referme la boîte avec le résultat de l’appel à la fonction.
Gardez toutefois à l’esprit que la boîte peut être vide et dans ce cas la fonction map se contente de retourner une boîte vide.
Dans la suite, nous utiliserons le terme “boîte” pour désigner les Classes Scala communes: Option, List, Set, Array, Either, Future, Try, etc.
À noter que Future n’est pas un bon exemple de programmation fonctionnelle pure car elle n’est pas référentiellement transparente. La raison vient du fait que Future calcule puis met dans le cache ses résultats.
Ce qui peut avoir des conséquences désastreuses lorsque l’on encapsule des calculs qui peuvent avoir des effets de bords.
Un autre problème des Future
est que leur exécution commence immédiatement. On ne peut donc pas choisir le moment où le code sera exécuté.
Vous pouvez lire ce post Reddit qui explique très bien le sujet. Vous pouvez également lire cet article de Medium qui détaille un peu plus les Future
et les problèmes que l’on peut rencontrer en les manipulant.
Des boucles mais pas que…
Supposons que nous possédions deux Options et que nous souhaitions sommer leur contenu si les deux Options sont définies. Avec ce que nous venons de voir nous pourrions nous en tirer en écrivant :
val maybeA = Some(1) val maybeB = Some(2) val c = maybeA match { case None => None case Some(x) => maybeB.map(p => x + p) }
Cette méthode n’est pas très élégante. Qu’arrivera-t-il s’il y a plus de 2 Options ?
C’est là que les for-comprehension
entrent en jeu. La même instruction se ré-écrit plus simplement :
val maybeA = Some(1) val maybeB = Some(2) val c = for { a <- maybeA b <- maybeB } yield a + b
Avant de vous expliquer comment cela fonctionne, il faut que vous compreniez l’enchaînement des instructions exécutées.
- La première instruction exécutée est:
a <- Some(1)
qui est l’équivalent deval a = 1
. D’une manière générale,a
prend la valeur du contenu de la boîte. - La seconde instruction exécutée est:
b <- Some(2)
qui est l’équivalent deval b = 2
. - Pour finir, la troisième instruction sera le calcul de a + b qui sera de nouveau encapsulé dans une nouvelle boîte.
Dans cet enchaînement, chaque instruction est dépendante de la précédente. C’est à dire que si a
n’arrive pas à stocker le contenu de la boîte maybeA (c’est-à-dire: la boîte est vide) alors il renvoie immédiatement une boîte vide sans passer par les 2 instructions qui lui succèdes. Il en est de même pour la seconde instruction.
Cela est particulièrement utile pour une suite de processus qui n’ont pas de sens individuellement mais qui, mise l’une à la suite des autres, prennent tout leur sens.
Un exemple classique serait une connexion à une base de données suivie d’une requête SQL avant de fermer la connexion SQL. Dans cet exemple, la requête SQL ne peut pas être exécutée sans connexion. De la même manière, la fermeture d’une connexion n’a pas de sens si elle n’a jamais été ouverte.
La boucle for
Avant de parler des for-comprehension
, je souhaiterais faire un petit aparté afin de vous parler de la boucle for
. Comme vous le savez, la boucle for
permet d’itérer sur une collection d’éléments. Cependant, il existe une différence fondamentale entre ce type de boucle dans des langages comme Java, C et un langage tel que Scala. La différence provient du fait que dans les premiers langages, for
ne renvoie pas de valeur. Tandis que, sur Scala, nous avons la possibilité de renvoyer une valeur en manipulant un opérateur yield
.
Cette différence est majeure, cela nous permet d’éviter d’avoir à modifier des variables mutables à chaque tour de boucle. Cela nous permet donc de stocker le résultat d’un for
directement dans une valeur :
val uppercaseLines = for(line <- lines) yield line.toUpperCase()
Ce type de boucles est appelé for-comprehension
ou for-monadique
.
Les for-comprehension comment ça marche ?
La syntaxe pour utiliser un for-comprehension
est la suivante : for( enumerators ) yield expr
.
Enumerators
peut prendre 3 formes :
- Un générateur :
a <- uneInstructionRenvoyantUneBoiteDeQuelqueChose()
- Une définition :
a = uneValeur
- Un filtre :
if uneAutreInstructionRenvoyantUnBooleen()
Quant à expr
c’est une suite d’instructions déterminant la valeur de retour qui sera contenu dans notre fameuse boîte.
Voici un exemple reprenant toutes ces définitions :
val c = for { a <- Some(1) //générateur b = 2 //définition if a > 0. //filtre } yield a + b
Le résultat de cette série d’instructions sera :
scala> c c: Option[Int] = Some(3)
En réalité, for-comprehension
est un sucre syntaxique qui effectue une succession d’appels à la fonction flatMap
suivi d’un appel à la fonction map
.
La somme des Options est traduite par le compilateur en Some(1).flatMap(a => Some(2).map(b => a + b))
. Cette expression ne sort pas d’un chapeau et nous verrons plus loin comment on aurait pu le découvrir.
Vous trouverez dans la documentation officielle les méthodes derrière chaque type d’usage du yield
et le sucre syntaxique qu’elles cachent.
Pour l’instant, ne vous intéressez pas à la fonction flatMap
nous allons la définir par la suite.
Au lieu d’utiliser les classes Options où tout est déjà implémenté. Nous allons définir notre propre classe Option
que nous allons appeler Maybe
afin de découvrir les rouages derrière :
sealed trait Maybe[+A] case class Yep[+A](v: A) extends Maybe[A] case object Nope extends Maybe[Nothing]
L’équivalent d’un trait
en Java 8 est une Interface
. Vous trouverez des explications détaillées sur les traits dans la documentation officielle de Scala.
Ce que nous venons de faire est de créer ce que l’on appelle un ADT ; pour Algebraic Data Type ; qui s’appelle Maybe
et qui peut soit être une instance de Yep
contenant une valeur v
, soit un Nope
.
À la différence d’un trait, un sealed trait permet de renvoyer un warning à la compilation, lorsque le pattern matching n’est pas exhaustif. Cela nous permettra de voir le problème tout de suite plutôt qu’à l’exécution après plusieurs semaines de production.
Pour rappel, le pattern matching est le fait de lister les différents cas possibles pour une valeur afin de traiter chaque cas.
Exemple :
val a = Option(uneValeurDéfinie) val toto = a match { case None => uneValeur case Some(p) => uneAutreValeur }
Pour en revenir à notre petite expérience, tentons maintenant de faire la somme du contenu d’un Maybe
et d’un nombre :
val c = for { a <- Yep(1) b = 2 } yield a + b On line 2: error: value map is not a member of Yep[Int]
Cela renvoie immédiatement une erreur nous indiquant que la fonction map
n’est pas définie pour Yep[Int]
.
De la même manière, si nous tentons de sommer le contenu de deux Yep[Int]
cela plante avec une erreur sur flatMap
cette fois-ci :
val c = for { a <- Yep(1) b <- Yep(2) } yield a + b On line 2: error: value flatMap is not a member of Yep[Int]
Afin de résoudre la première erreur nous pouvons implémenter la fonction map :
sealed trait Maybe[+A] { def map[B](f: A => B): Maybe[B] = this match { case Yep(a) => Yep(f(a)) case Nope => Nope } } case class Yep[+A](v: A) extends Maybe[A] case object Nope extends Maybe[Nothing]
Maintenant ré-essayons :
scala> for { a <- Yep(1) b = 2 } yield a + b res0: Maybe[Int] = Yep(3)
À présent, implémentons la fonction flatMap pour la seconde erreur :
sealed trait Maybe[+A] { def flatMap[B](f: A => Maybe[B]): Maybe[B] = this match { case Nope => Nope case Yep(a) => f(a) } } case class Yep[+A](v: A) extends Maybe[A] case object Nope extends Maybe[Nothing]
Tentons à nouveau d’exécuter notre exemple :
val c = for { a <- Yep(1) b <- Yep(2) } yield a + b On line 3: error: value map is not a member of Yep[Int]
Nous constatons que cette fois-ci, c’est la fonction map
qui est demandée. La différence avec notre exemple précédent c’est que nous avons plusieurs générateurs.
Cette fois avec la fonction map
:
sealed trait Maybe[+A] { def map[B](f: A => B): Maybe[B] = this match { case Yep(a) => Yep(f(a)) case Nope => Nope } def flatMap[B](f: A => Maybe[B]): Maybe[B] = this match { case Nope => Nope case Yep(a) => f(a) } } case class Yep[+A](v: A) extends Maybe[A] case object Nope extends Maybe[Nothing]
Appelons le bout de code :
for { a <- Yep(1) b <- Yep(2) } yield a + b res0: Maybe[Int] = Yep(3)
Cette fois-ci cela fonctionne.
Pour vous entraîner, vous pouvez reprendre l’exemple suivant et essayer de le faire marcher :
val c = for { a <- Yep(1) b = 2 if a > 0 } yield a + b
Cette fois-ci, le compilateur plantera pour la fonction withFilter. La signature de la méthode à implémenter est : def withFilter(p: A => Boolean): FilterMonadic[A, M[A]]
.
withFilter
est une implémentation lazy de filter.
FilterMonadic
est un trait implémentant les fonctions map
, flatmap
, foreach
et withFilter
. C’est un filtre monadique mais pour l’instant ne vous embêtez pas avec ce détail. Vous pouvez simplement implémenter la méthode def withFilter(p: A => Boolean): Maybe[A]
afin de le faire fonctionner.
Il existe également un autre sucre syntaxique pour le for-comprehension
que j’ai délibérément omis de vous montrer dans ce chapitre car il induit des effets de bords; Il s’agit du foreach.
En effet, si vous essayez de lancer cette suite d’instructions, vous vous heurterez à l’erreur suivante: error: value foreach is not a member of Yep[Int]
.
-
val a = Yep(2) for(b <- a){ println(b) }
Pour la résoudre, il suffira d’implémenter la méthode foreach possédant cette signature def foreach[U](f: A => U): Unit
pour Maybe
.
Il existe un semblant de for comprehension en Python nommé les List Comprehension
vous pouvez voir plus de détails dans cet article.
Flatmap
Afin que vous compreniez comment fonctionne flatMap
. Je dois vous parler de flatten
qui permet de réduire le niveau d’imbrication d’une donnée.
Pour ce faire, flatten
a 2 types de comportements notoires :
Boîte(Boîte(QuelqueChose)).flatten
devientBoîte(QuelqueChose)
Boîte(Boîte(QuelqueChose1), Boîte(QuelqueChose2), Boîte(QuelqueChose3)).flatten
devientBoîte(QuelqueChose1, QuelqueChose2, QuelqueChose3)
.
le résultat de l’appel à la fonction
Prenons l’exemple des classes List
et Option
:
List(List(1, 2, 3)).flatten
donneList(1, 2, 3)
List(List(1,2), Nil, List(3,4)).flatten
donneList(1, 2, 3, 4)
Some(Some(3)).flatten
donneSome(3)
Dans le cas d’objets vides on a simplement :
List(Nil).flatten
donneNil
Some(None).flatten
donneNone
Une fois que vous avez compris cela, définir flatMap
est beaucoup plus simple.
En effet, vous pouvez voir flatMap
comme une opération map
suivie d’une opération flatten
(ie: flatMap = flatten ∘ map
en notation mathématique lisez “flatten rond map”).
Conclusion
Dans ce chapitre, nous nous sommes penchés sur les boucles fonctionnelles. Nous avons pu découvrir les méthodes fold
, map
, flatten
et flatMap
ainsi que les for-comprehension
qui sont des implémentations pures ayant un degré suffisant de liberté nous permettant ainsi de manipuler efficacement nos collections. La découverte des for-compréhension
nous permet, entre autres, de manipuler plus efficacement une catégorie d’objet nommé des monades et d’appliquer des transformations à celles-ci. Sachez que tout au long de cet article, nous avons utilisé le terme peu commun de boîte qui en réalité s’avère être une monades (monads en anglais). Nous définirons plus précisément, dans un prochain article ces structures particulières que sont les monades ainsi que les foncteurs (fonctors en anglais) que nous avons partiellement vu.
Pour le prochain article, nous nous pencherons sur la gestion fonctionnelle des erreurs. En attendant la suite, n’hésitez pas à poser vos questions en commentaire.