Les implicites en Scala

Introduction

Il est une fonctionnalité en Scala qui a fait coulé beaucoup d’encre (électronique ou non). Il s’agit des implicites. Certains en font un argument central dans la propagande de ce langage de programmation, tandis que d’autres y voient la preuve de l’obscurantisme dont aiment s’entourer ses adaptes. Éloignons-nous des querelles de clochers et faisons le point sur cette fonctionnalité.

Qu’est-ce qu’ils ont encore été inventer?

En Scala, les implicites sont une fonctionnalité qui a été développée afin de permettre au compilateur de résoudre certains arguments, de réaliser certaines conversions ou de prendre en compte certaines définitions automatiquement. Il s’agit donc initialement de simplifier la vie du développeur en lui permettant de considérer qu’un certain contexte de programmation n’est pas fourni explicitement mais est, au contraire, … implicite. On espère ainsi réduire l’écriture de code redondant ou inutile et donc clarifier le code restant, en n’écrivant que ce qui est vraiment pertinent.

Par exemple, un objet représentant une base de données est un excellent candidat pour devenir un paramètre implicite: de nombreuses fonctions en ont besoin pour réaliser leur travail. En fait, toutes les fonctions qui reposent sur une base de données ont besoin d’un tel objet. Cela est tellement commun qu’on souhaiterait pouvoir invoquer ces fonctions en omettant cet objet: il se fond dans le contexte et ajoute plus de bruit qu’autre chose. Pour autant, les fonctions doivent réellement récupérer un tel objet, sans quoi elles ne pourraient pas fonctionner.

Supposons que cet objet ait une méthode run qui exécute des requêtes SQL arbitraires et faisons de cet objet un paramètre implicite pour une fonction qui retourne tous les utilisateurs contenus dans la base de données qui relèvent d’un département particulier (le code est inspiré de Slick, une bibliothèque de mapping fonction-relationnel pour Scala) :

def getUsers(departmentId: Int)(implicit db: Database): Seq[User] = {
   db.run(users.filter(_.departmentId === departementId).result)
}

Le paramètre db est relégué dans un deuxième jeu de parenthèses de la fonction: c’est un cas particulier de curryfication. Le mot-clef implicit indique, sans surprise, que le paramètre db est implicite. On peut alors utiliser la fonction comme suit:

// Définition d'une variable implicite
implicit val defaultDB: Database = ???

// Appel de la fonction sans passer explicitement la base de données
val users: Seq[User] = getUsers(42) // Résout `db` comme `defaultDB`

Nous avons donc simplement omis la base de données lors de l’utilisation de la fonction, ce qui simplifie la lecture du code.

De manière similaire, les fonctions et les classes peuvent être implicites.

Critique

Les implicites sont parfois critiqués parce qu’ils peuvent complexifier la compréhension de l’origine d’une valeur ou d’un comportement. De manière générale, le problème vient de ce que les implicites altèrent le comportement du code à des endroits où on ne s’y attend pas nécessairement. Ce comportement du code a donc tendance à lui échapper, ce qui est une violation du principe d’encapsulation.

Deux implicites de même type dans la même portée provoqueront des erreurs de compilation parfois assez subtiles, et ce comportement n’est pas évident à la lecture du code.

De plus, les implicites définis dans une portée large (comme import scala.concurrent.ExecutionContext.Implicits.global) peuvent introduire des dépendances invisibles. Lors de l’écriture de code, il est alors difficile de déterminer comment celui-ci se comportera, car les implicits définis par l’import altèrent le code de manière insidieuse.

En résumé, l’ajout d’un implicite altère le code à des endroits parfois imprévisibles. Ajoutez un import, et votre programme change subitement de comportement. La gestion des implicites devient complexe lorsque plusieurs implicites de même type sont utilisés dans la même portée.

On peut répondre à ces critiques en rappelant que le comportement du code reste, à tout moment, parfaitement déterministe: il existe des règles strictes et clairement définies qui déterminent la manière dont les implicites sont utilisés au sein du code. Cet argument perd cependant de sa force au fur et à mesure que l’usage des implicites se généralise: même s’il est toujours possible de déterminer le comportement du code, cela devient de plus en plus difficile pour un développeur.

Il convient donc, quoi qu’il arrive, d’utiliser les implicites de manière raisonnable.

Utilisations avantageuses des implicites

Au vu de ce qui précède, il semble que les implicites ne servent que de sucre syntaxique pour éviter aux développeurs de transmettre en tant que paramètres les éléments les plus communs de leur code. Il n’en est rien, cependant, et les implicites facilitent également la vie des développeurs d’autres manières. Il convient donc de déterminer ce qu’on peut considérer comme raisonnable. Cela dépend bien sûr partiellement de chaque développeurs. Quelques cas d’utilisation typiques ressortent, néanmoins.

Pimp My Code

« Pimp my code » est une approche de programmation permettant la modification de classes ou de bibliothèques entières sans en modifier le code existant. Elle repose principalement sur l’usage d’implicites pour enrichir facilement les classes existantes.

Considérez par exemple la classe java.util.String. Elle est définie dans le JDK de Java et est utilisée telle quelle en Scala. Supposons que nous aurions aimé que cette classe ait une méthode asHex() qui convertit la chaîne de caractères en un entier, en supposant que la chaîne de caractères soit une représentation hexadécimale de l’entier. Une exception est levée si ce n’est pas le cas.

Les concepteurs de la classe String n’ont pas prévu une telle méthode, avec raison: elle est trop spécifique, et dépasse le cadre de ce qu’on attend normalement d’une telle classe. Nous ne pouvons pas étendre la classe, car elle est indiquée comme finale. En programmation orientée objet, la réponse classique à ce problème est le design pattern décorateur. Mais cela demanderait que nous indiquions, pour chaque méthode de la classe d’origine, de simplement transmettre l’appel à l’objet sous-jacent. Ce qui est plutôt lourd pour une classe comme String.

Nous allons plutôt pimper la classe String en en proposant une version légèrement enrichie. Créons une nouvelle classe qui ajoute la méthode qui nous intéresse.

object StringEnrich {
  implicit class StringOps(val s: String) {
    def asHex: Int = {
      try {
         Integer.parseInt(s, 16)
       } catch {
          case e: NumberFormatException => throw new IllegalArgumentException(s"Invalid hexadecimal string: '$s'")
       }
    }
  }
}

object Main extends App {
  import StringEnrich._

  val str: String = "1A3F"
  println(str.asHex) // Résultat : 6719
}

La variable str est de type String, mais a été silencieusement convertie en StringOps afin d’y ajouter la méthode asHex. Fondamentalement, nous n’avons fait qu’implémenter la nouvelle méthode, et déclarer la manière de convertir un objet de type String en objet de type StringOps. Le compilateur détermine dans quelles circonstances une telle conversion est nécessaire pour satisfaire à l’appel de méthode. Nous avons donc évité un héritage de classe et une décoration qui aurait été plus lourde et plus fragile.

Conversion d’une chaîne de caractères en JSON

L’exemple précédent, s’il laisse entrevoir les possibilités des implicites, peut sembler un peu gadget. Le même principe est pourtant appliqué à des situations très concrètes. Par exemple, la bibliothèque Spray, qui permet le support du format JSON, utilise le même principe pour faciliter la conversion de chaînes de caractères (et d’autres objets) en objets JSON. En important le contenu de DefaultJsonProtocol, la conversion devient implicite:

import spray.json._
import DefaultJsonProtocol._

val source: String = """{ "some": "JSON source" }"""
val jsonAst = source.parseJson

println(jsonAst.prettyPrint)

Plutôt sympa, n’est-ce pas?

Opérateur logique sur des futures

Les développeurs ont l’habitude de combiner des valeurs booléennes. En Scala (comme dans de nombreux autres langages), l’opérateur && indique un ET logique: le résultat est vrai si chaque terme de l’opération est vrai.

Lorsque les valeurs sont emballées dans des Future (par exemple, parce que le code repose sur un traitement asynchrone des données), la combinaison des valeurs booléennes devient plus lourde. Par exemple, un ET logique s’exprime comme:

val a: Future[Boolean] = ???
val b: Future[Boolean] = ???
val c: Future[Boolean] = (a zip b) map { case(a,b) => a && b }

Rien d’insurmontable programmatiquement parlant, mais c’est clairement plus moche qu’un simple &&.

Pimpons un peu notre code pour enrichir un Future[Boolean] d’un opérateur && (en Scala, && est un nom de méthode tout à fait valide):

import scala.concurrent.{ExecutionContext, Future}

object FutureOperator {
  implicit class FutureOps(val a: Future[Boolean]) extends AnyVal {
    def &&(b: Future[Boolean])(implicit ec: ExecutionContext): Future[Boolean] = {
      (a zip b) map { case (a,b) => a && b }
    }
  }
}

Il nous reste à l’utiliser dans notre code:

import FutureOperator.FutureOps

val a: Future[Boolean] = ???
val b: Future[Boolean] = ???
val c: Future[Boolean] = a && b

Voilà qui est plus joli et qui rend le code réellement plus compréhensible à mon humble avis.

Conversion d’objets

Nous sommes parfois amenés à travailler avec un objet d’un certain type et de tomber sur une fonction s’attendant à recevoir des objets d’un autre type. C’est souvent le cas en Scala lorsqu’on utilise des bibliothèques faites initialement pour Java et qui manipulent des collections, telles que des listes ou des ensembles: Java dispose de ses propres collections et Scala propose les siennes. Il s’agit de classes différentes bien que semblables.

Pour l’exemple, considérons des classes représentant des montants en euros et en dollars:

case class Dollar(amount: Double)
case class Euro(amount: Double)

Petit conseil: n’utilisez jamais sérieusement des nombres en virgule flottante pour représenter des quantités d’argent, en raison des problèmes de précision et des erreurs d’arrondi qu’ils introduisent.

Par contre, le fait d’utiliser des classes aussi simples que Dollar et Euro (plutôt que de simplement travailler avec des double, par exemple) n’a rien de surprenant en Scala: l’avantage de ces classes ne vient pas de la richesse de leur contenu ni du comportement qu’elles encapsulent, mais du sens que le développeur donne en utilisant ces types particuliers: travailler avec des montants en euros ou en dollars est plus riche de sens que de travailler avec des nombres en virgule flottante.

Supposons que vous utilisiez une bibliothèque tierce fournissant une fonction send s’attendant à recevoir des dollars:

def send(amount: Dollar) = ???

Comment faire en sorte d’adapter nos euros afin de pouvoir utiliser la fonction sans la modifier?

En programmation orientée objet, le design pattern adaptateur serait probablement proposé. Il souffre malheureusement des mêmes limitations que le décorateur proposé plus haut: dans de nombreux cas, sa mise en œuvre est loin d’être triviale.

Une manière douce d’assurer l’usage de cette fonction est de permettre la conversion implicite du type Euro en type Dollar:

object CurrencyConverter {
  implicit def euroToDollar(euro: Euro): Dollar = Dollar(euro.amount * 1.1)
}

import CurrencyConverter._

val euroAmount: Euro = Euro(100.0)
send(euroAmount) // Conversion implicite

En pratique, le taux de conversion devrait sans doute être variable…

Mais le point important dans cet exemple est que notre objet de type Euro a pu être utilisé de manière transparente comme paramètre d’une méthode qui travaille avec des Dollar.

Il s’agit, d’une certaine manière, d’une généralisation du mécanisme de promotion de type proposé par Java (et aussi par Scala), par exemple pour convertir automatiquement des int en long. Alors que Java fait reposer ce mécanisme sur des règles claires, édictées d’avance et encodées directement dans le langage, Scala permet au développeur de créer ses propres règles de conversion tout en s’assurant à la compilation qu’une règle permettra la résolution des variables à utiliser.

Import d’implicites

En pratique, comment organiser ses implicites et comment les ajouter à une portée de résolution? Plusieurs approches sont possibles.

L’une d’elles consiste à déclarer des implicites (souvent des fonctions) dans un trait. Toute classe étendant le trait bénéficiera ainsi de ses implicites. C’est l’approche préconisée pour la conversion d’objets en JSON, et inversement, avec Spray: on crée un trait supportant les opérations de conversion pour un type donné, les classes peuvent alors supporter la conversion implicite d’un type en étendant le trait correspondant.

Une autre approche consiste à définir les implicites au sein d’un objet et d’importer les composants de cet objet (et non l’objet lui-même, ce n’est pas la même chose). Cette approche a été illustrée à plusieurs reprises dans cet article. Un cas courant consiste à placer les implicites dans des objets de package, de sorte que toutes les classes contenues dans un package puissent en bénéficier facilement.

Enfin, il est toujours possible de déclarer un implicite au sein même d’une classe ou d’une fonction. Cela limite grandement la portée de résolution qui en bénéficie, ce qui peut être un avantage ou un inconvénient, selon les circonstances.

Conclusion

En Scala, des variables, des paramètres, des fonctions et des classes peuvent être définis comme implicites afin d’éviter aux développeurs d’avoir à y faire explicitement référence.

Nous avons vu que, si cela permet d’abréger les appels de fonctions, cet usage des implicites n’est pas le seul possible, ni sans doute le plus intéressant.

En permettant l’ajout de méthodes dans des classes, les implicites favorisent leur extensibilité. La conversion d’un type à un autre de manière transparente peut s’avérer très utile, notamment pour l’intégration de nos composants avec des bases de code préexistantes.

Il faut cependant se méfier des effets pervers des implicites. En enfuissant des modifications du comportement dans des endroits difficilement imaginables, ceux-ci peuvent en effet complexifier la compréhension du code. Un usage raisonnable de cette fonctionnalité doit donc être trouvé afin d’en profiter pleinement.

2 réflexions sur “Les implicites en Scala

  1. Pingback: Capture de contexte avec le framework Play | Le compas de l'architecte

  2. Pingback: Vous reprendez bien un peu de curry avec votre programme? | Le compas de l'architecte

Répondre à Capture de contexte avec le framework Play | Le compas de l'architecte Annuler la réponse.