Besoin d'un break ? Faites un tour sur http://lesjoiesducode.fr

Passer de Java à Scala – Partie 7 – Abstraction de contrôles

Passer de Java à Scala – Partie 7 – Abstraction de contrôles

Dans ce chapitre, nous allons découvrir comment utiliser ce que nous avons vu dans la partie précédente pour créer des structures de contrôles personnalisées.

Retrouvez le code source des exemples ici : https://github.com/alexandrelanglais/scala-tutorial-7

Retrouvez tous les articles de la série « Passer de Java à Scala »

Réduire la duplication de code

En programmation impérative, ce qui varie dans une fonction, ce sont les paramètres, le corps ne changeant pas. En programmation fonctionnelle, avec la notion de fonctions comme objets de première classe, le corps même d’une fonction peut varier en fonction des arguments passés en paramètres !

Ces fonctions prenant d’autres fonctions en paramètres nous donnent des opportunités pour condenser et simplifier le code.

Imaginons que nous développons une API d’exploration de fichiers sur le disque. On peut créer un objet de ce type :

object FileMatcher {
  private def filesHere = (new java.io.File(".")).listFiles

  def filesEnding(query: String) =
    for (file <- filesHere
         if file.getName.endsWith(query))
      yield file
}

Avec cette fonction, nous fournissons aux clients de l’API la possibilité de rechercher les fichiers du répertoire courant se terminant par une certaine chaine de caractères.

Pour l’instant, pas de duplication de code. Maintenant imaginons que nous voulions rajouter une méthode contains() pour rechercher les fichiers dont le nom contient une chaine de caractères. On pourrait enrichir notre librairie en écrivant :

object FileMatcher {
  private def filesHere = (new java.io.File(".")).listFiles

  def filesEnding(query: String) =
    for (file <- filesHere
         if file.getName.endsWith(query))
      yield file

  def filesContaining(query: String) =
    for (file <- filesHere
         if file.getName.contains(query))
      yield file
}

Là, on voit bien la duplication de code entre les fonctions filesEnding et filesContaining. Seul file.getName change de méthodes pour filtrer les fichiers.

Ajoutons encore une fonctionnalité pour les clients, la possibilité d’effectuer des regex sur le nom des fichiers :

object FileMatcher {
  private def filesHere = (new java.io.File(".")).listFiles

  def filesEnding(query: String) =
    for (file <- filesHere
         if file.getName.endsWith(query))
      yield file

  def filesContaining(query: String) =
    for (file <- filesHere
         if file.getName.contains(query))
      yield file

  def filesRegex(query: String) =
    for (file <- filesHere
         if file.getName.matches(query))
      yield file

}

Comment pourrait-on réfactorer ce code pour n’en faire qu’une seul fonction ? On voit bien que la seule différence à chaque fois est la méthode appelée sur file.getName.

Dans l’idéal, on aimerait pouvoir faire quelque chose du genre :

def filesMatching(query: String, method) =
  for (file <- filesHere
       if file.getName.method(query))
    yield file

On passe une référence sur la méthode en paramètre (method) et on l’applique à file.getName. Certains langages dynamiques acceptent cette syntaxe, mais ce n’est pas le cas de Scala.


On ne peut pas passer de noms de méthode comme si c’était une valeur. En revanche, on peut passer une valeur de fonction qui reproduit le même concept :

def filesMatching(query: String, matcher: (String, String) => Boolean) = { 
  for (file <- filesHere
       if matcher(file.getName, query)) 
    yield file 
}

Le paramètre matcher est une fonction littérale qui prend 2 arguments en paramètres : le nom de fichier et le filtre, et renvoie un booléen si le nom de fichier matche ou non.

En ayant défini cette méthode, on peut réécrire nos méthodes précédentes comme suit :

def filesEnding(query: String) =
  filesMatching(query, _.endsWith(_))

def filesContaining(query: String) =
  filesMatching(query, _.contains(_))

def filesRegex(query: String) =
  filesMatching(query, _.matches(_))

Lors de l’appel de filesMatching, on retrouve les placeholders étudiés dans l’article précédent représentant les 2 String du matcher spécifiés dans la définition de filesMatching. Chaque fois, la méthode du matcher renvoie bien un booléen.

Ces placeholders agissent de la même manière que si l’on avait écrit :

(fileName: String, query: String) => fileName.endsWith(query)

Maintenant, intéressons-nous au paramètre query de filesMatching. Ce paramètre ne fait que transiter de filesMatching vers le matcher, et nos méthodes filesEnding, filesContaining et filesRegex prennent toutes en paramètre ce query ! Il est donc inutile de le passer une seconde fois à la méthode fileMatching.

On peut donc simplifier encore un peu le code en écrivant seulement :

def filesMatching(matcher: (String) => Boolean) = {
  for (file <- filesHere
       if matcher(file.getName))
    yield file
}

def filesEnding(query: String) =
  filesMatching(_.endsWith(query))

def filesContaining(query: String) =
  filesMatching(_.contains(query))

def filesRegex(query: String) =
  filesMatching(_.matches(query))

Maintenant, le matcher ne prend plus qu’un seul paramètre (le nom du fichier) et renvoie toujours un booléen.

Je vous renvoie à l’article précédent concernant les clôtures, les variables liées et libres. En effet, dans la forme _.endsWith(_), on a 2 variables liées à la fonction anonyme (String, String) et pas de variable libre.

Dans la 2è forme _.endsWith(query), on a une variable liée (celle représentée par l’underscore _) et une variable libre, query, qui est enfermée par la clôture créée par filesMatching.

C’est uniquement grâce à ce mécanisme de clôtures de Scala que nous avons pu nous débarrasser du paramètre query dans filesMatching !


Simplifier le code client

Le code précédent démontre que l’on peut simplifier la codification d’une API. Les Collections en Scala sont un bon exemple de passage de fonctions à des méthodes, avec toutes les itérations disponibles de ces collections (filter, forall, foreach…) toutes définies dans le trait Traversable.

Pour démontrer à quel point ces fonctions simplifient le code client, prenons la méthode exists() en exemple : si l’on souhaite savoir si une liste d’entiers contient des valeurs négatives, on pourrait écrire le code suivant :

object TraversableExists extends App {
  val list = List(1, 2, 3)
  val list2 = List(1, -2, 3)

  def containsNeg(l: List[Int]):Boolean = {
    var exists = false
    for(n <- l)
      if(n < 0)
        exists = true

    exists
  }

  println(containsNeg(list))
  println(containsNeg(list2))
}

La sortie serait :

false
true

Grace à la fonction exists() du trait Traversable, dont la définition est la suivante :

def exists(p: (A) ⇒ Boolean): Boolean

On peut passer à cette méthode un prédicat (p) qui prend en paramètre (A, un type générique défini par la liste, ici Int) et renvoie un Boolean. On retrouve bien le concept de notre méthode filesMatching précédente opù l’on passait un matcher de même facture.

Pour savoir si une liste contient des nombre négatifs, on peut donc écrire plus concisement :

println(list.exists(_ < 0))
println(list2.exists(_ < 0))

Et de la même manière, si l’on veut savoir si la liste contient des nombres pairs :

println(list.exists(_ % 2 == 0))
println(list2.exists(_ % 2 == 0))

Curryfication

En Scala, il est possible d’écrire des structures de contrôles qui semblent être natives au langage. Mais avant de pouvoir faire cela, il est nécessaire de comprendre ce qu’est le mécanisme de « curryfication » relatif à la programmation fonctionnelle.

Une fonction curryfiée s’applique à plusieurs liste d’arguments au lieu d’une seule. Prenons une définition classique :

scala> def sum(x: Int, y: Int) = x + y
sum: (x: Int, y: Int)Int

scala> sum(1, 2)
res0: Int = 3

Il est possible de curryfier cette fonction de la sorte :

scala> def curryedSum(x: Int)(y: Int) = x + y
curryedSum: (x: Int)(y: Int)Int

scala> curryedSum(1)(2)
res2: Int = 3

Ce qui se passe en transparence est en fait 2 appels de fonctions enchainés. La première invocation prend un seul paramètre x et retourne une valeur de fonction pour la 2è invocation qui elle prend en paramètre y.

La première invocation pourrait être matérialisée comme suit :

scala> def premiereInvocation(x: Int) = (y: Int) => x + y
premiereInvocation: (x: Int)Int => Int

Appeler cette première invocation avec le paramètre 1 par exemple, nous donne la 2è fonction invoquée :

scala> val deuxiemeInvocation = premiereInvocation(1)
deuxiemeInvocation: Int => Int = $$Lambda$1057/1905486482@61359e87

Enfin, appeler deuxiemeInvocation avec le paramètre 2 nous donne le résultat :

scala> deuxiemeInvocation(2)
res3: Int = 3

Ces 2 méthodes sont juste là pour illustrer le fonctionnement de curryedSum.

Il est également possible d’obtenir une référence vers la 2è invocation de curryedSum, grace aux fonctions partiellement appliquées vues lors du chapitre précédent :

scala> val unPlus = curryedSum(1)_
unPlus: Int => Int = $$Lambda$1064/1814730197@24d8f87a

On peut ainsi écrire un appel vers la deuxième invocation comme ceci :

scala> unPlus(2)
res4: Int = 3

Ecrire de nouvelles structures de contrôle

Bien que la syntaxe du langage soit figée, il est possible de créer de nouvelles structures de contrôles dans les langages possédant des fonctions de première classe comme Scala.

Par exemple, voici la fonction deuxFois qui répète 2 fois une opération et renvoie le résultat :

scala> def deuxFois(op: Double => Double, x: Double) = op(op(x))
deuxFois: (op: Double => Double, x: Double)Double

scala> deuxFois(_ + 1, 100)
res5: Double = 102.0

Le type de op dans l’exemple est Double => Double, c’est à dire une fonction qui prend un Double comme argument et retourne un Double.

Quand vous voyez du code qui se répète, vous pouvez songer à implémenter une nouvelle structure de contrôle

Imaginons que l’on ait une opération dans un programme qui se répète de nombreuses fois : ouvrir un fichier, écrire dedans puis le fermer. On peut créer une nouvelle structure de contrôle pour éviter de dupliquer du code :

def withPrintWriter(file: java.io.File, op: PrintWriter => Unit): Unit = {
  val writer = new PrintWriter(file)
  try {
    op(writer)
  } finally {
    writer.close()
  }
}

Cette fonction prend une fonction en paramètre, op, qui prend elle même un PrintWriter en paramètre, writer.

On peut ensuite appeler notre nouvelle structure de contrôle ainsi :

withPrintWriter(
  new File("test.txt"),
  (theWriter: PrintWriter) => theWriter.println(new java.util.Date)
)

Notez qu’il n’est pas nécessaire de spécifier le type de theWriter, on peut plus simplement écrire :

withPrintWriter(
  new File("test.txt"),
  writer => writer.println(new java.util.Date)
)

Le principal avantage est qu’il est désormais impossible d’oublier de fermer le fichier après écriture. Cette technique est le loan pattern, ou le design pattern de « prêt » : la fonction withPrintWriter prête son PrintWriter à la fonction passée en paramètre.


Pour que la structure de controle personnalisée ait l’air native au langage, on peut utiliser des accolades au lieu des parenthèses. En Scala, à partir du moment où l’on passe exactement 1 paramètre à une fonction, on peut utiliser des accolades au lieu des parenthèses :

scala> println("Yo")
Yo

scala> println { "Yo" }
Yo

Bon très bien, mais la fonction withPrintWriter prend 2 paramètres, alors comment faire ?

Vous vous rappelez de la curryfication vue plus tôt ? Et bien on va pouvoir se servir de ce concept pour donner l’impression que notre nouvelle structure de contrôle est native au langage :

def withPrintWriter(file: java.io.File)(op: PrintWriter => Unit): Unit = {
  val writer = new PrintWriter(file)
  try {
    op(writer)
  } finally {
    writer.close()
  }
}

withPrintWriter(new File("test.txt")) {
  writer => writer.println(new java.util.Date)
}

Comme op est le dernier paramètre de la fonction, on peut le passer entre accolades en guise de dernier élément de notre structure de contrôle.

Paramètres par nom

A la différence d’une structure de contrôle native, notre fonction prend entre ses accolades une variable writer.

Et si on voulait quelque chose ressemble plus à un if ou un while où il n’y aurait pas de valeur à passer entre les accolades ? Scala nous le permet avec les paramètres par nom.

Imaginons que nous voulions programmer une fonction d’assertion myassert.  Sans utiliser les paramètres par nom, nous écririons :

object MyAssertV1 extends App {
  var assertionsEnabled = true

  def myAssert(predicate: () => Boolean) =
    if (assertionsEnabled && !predicate())
      throw new AssertionError

  myAssert(() => 5 > 3)
}

L’utilisation de predicate: () => Boolean empêche 5 > 3 d’envoyer la valeur true à la fonction.

L’exemple précédent fonctionne mais ce serait mieux de pouvoir écrire directement myAssert(5 > 3) mais en l’état, on obtiendrait une erreur de compilation :

Error:(10, 14) type mismatch;
 found   : Boolean(true)
 required: () => Boolean
  myAssert(5 > 3)

Les paramètres de fonction par nom existent précisément pour ce cas de figure. Pour les utiliser, il faut remplacer () => Boolean par => Boolean :

def myAssert(predicate: => Boolean) =
  if (assertionsEnabled && !predicate)
    throw new AssertionError

Maintenant, l’utilisation de myAssert ressemble à une structure de contrôle native :

myAssert(5 > 3)

Ce fonctionnement par nom n’est valide que pour les paramètres : cela n’existe pas pour les variables ou les champs de classe.


Pourquoi ne pas utiliser simplement un booléen ?

def myAssert(predicate: Boolean) =

Et bien précisément pour ce que j’ai dit plus haut : si l’on passe directement un booléen, la valeur envoyée sera true. En utilisant un paramètre par nom, celui-ci ne sera évalué qu’au moment de son utilisation.

Vous avez noté la variable assertionEnabled ? Et bien, si cette valeur est false, le predicate ne sera pas évalué, ce qui permet par exemple d’exécuter le code suivant sans avoir d’erreur :

assertionsEnabled = false
myAssert(5 / 0 == 0)
Process finished with exit code 0

Là ou on aurait une exception sans utiliser de paramètre par nom :

Exception in thread "main" java.lang.ArithmeticException: / by zero

Conclusion

Voilà pour l’abstraction de contrôle ! On peut utiliser ce mécanisme dans notre code pour éviter la duplication de code et factoriser plusieurs traitements récurrents.

Ce chapitre et les précédents étaient très orientés fonctionnels, dans le prochain article nous verrons plus en détail les notions de POO en Scala.

Laisser un commentaire