Publié par

Il y a 2 mois -

Temps de lecture 17 minutes

Découvrir la programmation fonctionnelle #3 | Boucles

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-comprehensions.

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.

  1. La première instruction exécutée est: a <- Some(1) qui est l’équivalent de val a = 1. D’une manière générale, a prend la valeur du contenu de la boîte.
  2. La seconde instruction exécutée est: b <- Some(2) qui est l’équivalent de val b = 2.
  3. 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 :

  1. Un générateur : a <- uneInstructionRenvoyantUneBoiteDeQuelqueChose()
  2. Une définition : a = uneValeur
  3. 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 :

  1. Boîte(Boîte(QuelqueChose)).flatten devient Boîte(QuelqueChose)
  2. Boîte(Boîte(QuelqueChose1), Boîte(QuelqueChose2), Boîte(QuelqueChose3)).flatten devient Boî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 donne List(1, 2, 3)
  • List(List(1,2), Nil, List(3,4)).flatten donne List(1, 2, 3, 4)
  • Some(Some(3)).flatten donne Some(3)

Dans le cas d’objets vides on a simplement :

  • List(Nil).flatten donne Nil
  • Some(None).flatten donne None

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.

Publié par

Commentaire

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.