Publié le 23/08/2017 Par Clément Carreau

Les implicits sont des concepts qui peuvent sembler “magiques” et assez déroutants quand on tombe sur eux pour la première fois. Néanmoins, pour peu que l’on en fasse bon usage, ils peuvent simplifier la vie des devs. Mais qu’entend-on par “implicits” ? À quoi servent-ils ? Comment fonctionnent-ils ? C’est ce que nous allons tenter de comprendre.

L’utilisation des implicits permet au développeur de se faciliter la tâche en laissant au compilateur le soin d’aller chercher de lui-même ce qui lui manque, que ce soit la façon de convertir un type A en type B, une méthode en elle-même ou l’un de ses paramètres. Concentrons-nous ici sur l’utilisation des paramètres et des conversions implicites.

Il manquerait pas un paramètre là ? Non.

Premier type d’implicite et surement le plus courant, le paramètre implicit. Prenons l’exemple d’une API permettant de sérialiser/désérialiser du texte, que ce soit en JSON, XML, ou autre. On pourrait imaginer un objet fournissant deux méthodes ser / deser telles que :

def deser(value:String, format: Format): Entity = ???
def ser(value:Entity, format: Format): String = ???

La méthode deser pourrait s’appeler de différentes façons, parmi lesquelles :

//import des méthodes et des objets

deser("mon JSON", JSONFormat)
deser("modn XML", XMLFormat)

À l’utilisation, on peut très vite remarquer une chose : si l’on souhaite utiliser cette API pour traiter du JSON, il est très peu probable de devoir à un moment passer à ces méthodes autre chose qu’un JSONFormat.

La question que pose Scala est la suivante : pourquoi devrait-on le passer à chaque fois ? Scala permet donc de réécrire l’API présentée ci-dessus de la façon suivante :

def deser(value:String)(implicit format:Format): Entity = ???
def ser(value:Entity)(implicit format:Format): String = ???

De prime abord, il n’y a pas de grandes différences, si ce n’est une plus grande complexité du code. Néanmoins, déclarer les méthodes de l’API de cette manière nous permet la flexibilité suivante :

//on déclare le format qui sera utilisé
implicit val format = JSONFormat

deser("mon JSON")
deser("mon autre JSON")

deser("mon XML")(XMLFormat) // ici on souhaite passer explicitement le format

Comme on peut le voir, Scala nous permet d’omettre le paramètre lors de l’appel de la méthode. À noter qu’il est possible de le passer explicitement, comme avec le XMLFormat.

À noter aussi que, dans la signature d’une méthode, le mot clé implicit ne sert pas à déclarer un paramètre mais une liste de paramètres. Tous les paramètres suivant le mot clé seront donc des implicites :

implicit val n = "John"
implicit val a = 20

def greet(greeting:String)(implicit name:String, age:Int) = {
  print(s"$greeting $name, you are $age")
}

greet("Hello") //compile et renvoie Hello John, you are 20

Besoin d’un Format, who cares ?

Second type d’implicite, la conversion implicite. Reprenons le cas de notre API de sérialisation/désérialisation. Nous avons vu qu’il était désormais possible de faire cela :

deser("mon XML")(XMLFormat)

Imaginons désormais que, rebelle dans l’âme, vous ayez envie de passer “JSON” ou “XML” au lieu de l’objet Format correspondant. Il serait alors très simple de faire ceci :

def getFormat(stringFormat:String): Format = {
  stringFormat match {
    case "XML" => XMLFormat
    case "JSON" | _ => JSONFormat
  }
}

deser("mon text")(getFormat(stringFormat))

Néanmoins, il existe une autre solution prévue par Scala, vous permettant, encore une fois, d’omettre l’appel à la méthode getFormat :

implicit def StringToFormat(stringFormat: String): Format = {
  stringFormat match {
    case "XML" => XMLFormat
    case "JSON" | _ => JSONFormat
  }
}

deser("mon text")(stringFormat)

Ici, nous avons tout simplement déclaré une méthode implicite (implicit def) permettant de convertir une String en Format, ce qui nous permet d’appeler la méthode deser directement avec le format sous forme de String. Attention, même si le code suivant semble logique, il ne compilera pas :

class A(val n: Int)
class B(val m: Int, val n: Int)
class C(val m: Int, val n: Int, val o: Int) {
  def total = m + n + o
}

implicit def AtoB(a: A) = new B(a.n, a.n)
implicit def BtoC(b: B) = new C(b.n, b.m, b.n+b.m)

new A(0).total //On aimerait pouvoir faire cela mais ça ne compile pas

En effet, il faudrait que le compilateur puisse enchaîner deux conversions implicites, ce qu’il n’est pas capable de faire :

BtoC(AtoB(new A(0))).total //ce qu’on voudrait que le compilateur fasse

Afin de résoudre ce problème, il faut changer la méthode BtoC() comme suit :

implicit def BtoC[T](b: T)(implicit conv: T => B) = new C(b.n, b.m, b.n+b.m)

Ici nous avons rajouté un paramètre implicite dont la résolution n’est pas limitée au niveau de sa recherche. Le paramètre conv sera donc cherché jusqu’à ce qu’il soit trouvé.

Le compilateur trouvera donc AtoB() et s’en servira en tant que paramètre implicite, limitant le nombre de conversions nécessaires à une :

BtoC(new A(0))(x => AtoB(x)).total //une seule conversion

Method undefined ? Attends j’arrange ça

Dernier type d’implicite et probablement mon préféré, les classes implicites, se rapprochant des extension method du C#. L’exemple précédent montrait comment convertir implicitement une String en Format en passant par une conversion. Mais pourquoi n’avoir pas tout simplement appelé la méthode toFormat de la classe String ?

val format = "JSON".toFormat //le tour est joué, ou presque.

Le seul problème, c’est que cette méthode n’existe pas. No problem, Scala est là. Il est tout à fait possible d’enrichir la classe String d’une telle méthode, comme ceci :

implicit class FormatString(val string:String){
  def toFormat = {
    string match {
      case "XML" => XMLFormat
      case "JSON" | _ => JSONFormat
    }
  }
}

"JSON".toFormat //compile ! (et fonctionne)

À savoir que les classes implicites ne sont, en vrai, que du “sucre” syntaxique se basant sur la conversion implicite vue précédemment ! En effet, le code ci dessus, “desugared” par le compilateur (c’est à dire, sous sa vraie forme) donnera :

class FormatString(val string:String){
  def toFormat = {
    string match {
      case "XML" => XMLFormat
      case "JSON" | _ => JSONFormat
    }
  }
}

implicit final def FormatString(stringFormat: String): FormatString = new FormatString(stringFormat)

"JSON".toFormat //compile ! (et fonctionne)

Et le cas de l’ambiguïté ?

Dans les exemples donnés jusqu’à présent, je n’ai défini qu’un seul paramètre implicite pour type d’argument attendu. Mais que se passerait-il si l’on en définissait un second ? Prenons un exemple simple :

def sayHello(implicit name: String) = print(s"Hello $name")

implicit val name1 = "John"
implicit val name2 = "Beth"

sayHello

Ici, sayHello nécessite un implicite de type String, mais comment choisir entre name1 et name2 ? Scala ne permet pas de résoudre cette ambiguïté. La compilation aurait donc échoué avec le message : “Error: ambiguous implicit values”. En effet, entre un String et un autre String, rien ne permet de les différencier.

C’est la raison pour laquelle il est rarement pertinent d’utiliser des types primitifs en tant qu’implicite compte tenu des potentiels conflits avec les autres implicits présents dans le scope. Dans le cas particulier où le contexte nécessite vraiment un tel implicite, il faut privilégier une classe « wrapper » englobant la valeur cible, comme par exemple :

case class Name(name:String) extends AnyVal
def sayHello(implicit name: Name) = print(s"Hello ${name.name}")

Il existe néanmoins des cas où deux implicites, valides, peuvent être comparés et l’un des deux être préféré. C’est la règle du static overloading. Par exemple, nous pourrions avoir cette méthode :

def convert[A](a:A)(implicit func_A=>String) = func(a)

Et ces deux conversions implicites (qui sont ici utilisées en tant que paramètre implicites !) :

implicit def IntToString(i:Int) = s"[Int]${i.toString}"
implicit def AnyValToString(a:AnyVal) = s"[AnyVal]${a.toString}"

Que se passerait-il si l’on appelait convert(1) alors que ces deux conversions implicites sont dans le scope ? Dans ce cas là, le résultat serait “[Int]1” car l’implicite le plus spécifique serait choisi (la règle entière peut être trouvée p. 97 de la documentation). En effet, ici 1 est un AnyVal, mais c’est plus spécifiquement un Int. La première conversion sera donc choisie.

En définitive, les implicites permettant de disposer d’un code plus condensé, plus lisible, plus “beau”. Mais comment fonctionnent-ils ? Que se passe-t-il à la compilation ? Je vous propose de l’évoquer lors d’un prochain article sur la résolution des implicites.

———

Si le sujet vous intéresse, nous vous invitons à lire sa suite :
La résolution des implicits en Scala

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.