Publié par
Il y a 3 semaines · 10 minutes · Back

La magie des implicites, Scala

Les implicites… ce concept mystérieux, ésotérique, est ni plus ni moins abscons pour la majorité d’entre nous. Brisons la glace.

Chapitre 1- Introduction à la puissance sombre des implicites – destiné aux apprentis développeurs

Avant de vous enseigner les secrets des implicites, je tenais à vous mettre en garde contre leur coté obscur. Vous pourrez vous procurer beaucoup de plaisir en maitrisant cette force, néanmoins, quelles que soient vos intentions, vous risquez d’engendrer beaucoup de souffrances

Pour résumer le monde des implicites, nous parlons de deux familles :

Le Polynectar La cape d’invisibilité
Le polynectar est une potion qui permet à un développeur de changer secrètement un objet en un autre. La Cape d’invisiblité permet à un développeur de cacher un objet de tous. Pour autant, les propriétés de ce dernier restent actives.
En Scala, on parle de conversions implicites.

Le comportement peut être atteint de deux manières

  • Classe implicite
  • Méthode implicite
En Scala, on appelle cela les paramètres implicites

Je vous propose de nous concentrer dans un premier temps sur les ingrédients des paramètres invisibles (implicites). Dans un deuxième temps nous appréhenderons les conversions implicites. Nous finirons sur la séquence de compilation et les bonnes pratiques.

Présentation de l’exemple

Tout au long de cet article, je vais tenter de conserver le même exemple.

La classe MagicWand encapsule sa composition sous forme de chaine de caractères. Elle possède une méthode decompose qui renvoie une Option de Tuple contenant le type de bois et son cœur.

class MagicWand (s: String) { 
	def decompose(separator: String) : Option[(String,String)] = {
	 	val subparts = s.split(separator) 
		if (subparts.length == 2 ) 
			Some (subparts(0),subparts(1))
		else None 
	} 
}
val harryPotterMagiWand = new MagicWand("Holly#PhoenixFeather")
println(harryPotterMagiWand.decompose(“#”))
// affiche "Some (Holly,PhoenixFeather)"

Paramètres implicites

Explication

En dehors de l’aspect mystérieux de son comportement, il faut comprendre que le terme implicit permet d’invoquer l’esprit du compilateur Scala. Le but étant de demander au compilateur de réaliser à notre place des opérations évidentes et fastidieuses.

Nous déclarons le paramètre separator comme implicite.

class MagicWand (s: String) {
	def decompose(implicit separator:String): Option[(String,String)] = {
		val sub = s.split(separator)
		if (parties.length == 2 )
			Some (parties(0),parties(1))
		else None
	}
}

implicit val implicit_separator : String = "#"
println(new MagicWand("Vine#DragonVentricle").decompose)
// affiche "Some (Vine,DragonVentricle)"

La notion de paramètre implicite est assez simple à comprendre. Dès lors qu’un paramètre d’une méthode est défini comme implicite, il n’est plus nécessaire de le spécifier lors de l’appel : le compilateur se charge de le déduire à partir du contexte.

Il est très important de noter :

  • la correspondance entre le paramètre implicite de la fonction et la variable est indépendante de leurs noms. Le compilateur utilise le type commun String pour lier les deux éléments
  • tous les paramètres implicites doivent être définis dans « la même paire de parenthèse ». A défaut, tous les paramètres seront implicites
// a et b sont des paramètres implicites
def functionWithImplicit(implicit a: String, b :Int): Unit = println(a + b) 

// b est un paramètre implicite
def functionWithImplicit(a: String)(implicit b :Int): Unit = println(a + b) 

//Ne compile pas
def functionWithImplicit(a: String, implicit b :Int): Unit = println(a + b) 

Limitations

  • L’association des éléments par le compilateur impose une gestion fine des implicites selon la portée/le type
  • Une mauvaise gestion peut être source d’effets difficilement compréhensibles
  • Deux variables implicites de même type dans la même portée provoquent une erreur de compilation, à la première utilisation
class ClassTest (s: String) {
	def needImplicit1(implicit string: String): Unit = println(string)
    def needImplicit2 (implicit string: String): Unit = println(string)
}

implicit val implicit1 = "zef"
implicit val implicit2 = "fzef"
println(new classTest().needImplicit1)// Ne compile pas
println(new classTest().needImplicit1(implicit1)) //s'éxecute correctement

Comme le montre l’exemple, il est possible de forcer le paramètre en cas de conflit.

Conversions implicites – Classe Implicite

Explication

Restons sur le même exemple de notre classe MagicWand :

class MagicWand (s: String) { 
	def decompose: Option[(String,String)] = {
	 	val subparts = s.split("#") 
		if (subparts.length == 2 ) 
			Some (subparts(0),subparts(1))
		else None 
	} 
}

println(new MagicWand("Holly#PhoenixFeather").decompose)
// affiche "Some (Holly,PhoenixFeather)"

La classe MagicWand est fonctionnellement pauvre et n’apporte pas d’intérêt pour la connaissance métier. Nous voulons seulement être capable de parser la composition d’une baguette magique à partir d’une chaine de caractères.

Nous allons donc définir cette classe comme implicite. Le compilateur sera alors capable de convertir automatiquement une chaine de caractères en MagicWand et d’appliquer la méthode decompose. Nous définissons la classe MagicWand comme implicite.

implicit class MagicWand (s: String) {
	def decompose: Option[(String,String)] = { // même implémentation }
}

println("Holly#PhoenixFeather".decompose)
// affiche "Some (Holly,PhoenixFeather)"

Nous n’avons plus besoin de construire une instance de MagicWand. Le compilateur s’en charge pour nous. Syntaxiquement, tout se passe comme si nous avions rajouté une nouvelle méthode à String, ce qui n’est évidemment pas le cas.

Le constructeur de la classe MagicWand est utilisé par le compilateur pour la conversion d’un type vers un autre. La métamorphose est réservée aux classes Java/Scala. Les traits, interfaces ou objets ne peuvent pas être transformés.

Limitations

  • La déclaration d’une classe implicite n’est possible qu’à partir de la version 2.10 de Scala
  • Cette manière de faire impose une restriction sur le constructeur principal de la classe implicite. Sa signature doit correspondre à la conversion implicite

Dans notre cas, il prend en paramètre un type String et retourne un object MagicWand (String => MagicWand). Néanmoins, si nous enrichissons la classe MagicWand avec de nouveaux attributs, nous ajouterons probablement des paramètres à son constructeur. La signature du constructeur sera différente et ne pourra plus être utilisée par la conversion implicite. Une simple méthode implicite peut alors prendre le relai et exécuter la conversion.

Conversions implicites – Méthode implicite

Explication

On comprend que le compilateur Scala exploite les constructeurs en convertisseur selon les domaines d’entrée et de sortie. En résumé, le compilateur ne prête pas attention à la fonctionnalité du constructeur de classe mais analyse seulement sa signature. Il voit en lui un moyen de passer d’un type vers un autre. Il est possible d’utiliser une méthode comme convertisseur.

Pour que la méthode soit accessible de manière statique, il convient de la placer dans le companion object de la classe.

class MagicWand(s:String) {
	def decompose: Option[(String,String)] = { // même implémentation }
}
object MagicWand {
	implicit def string2MagicWand(s:String):MagicWand = new MagicWand(s)
}

println("Holly#PhoenixFeather".decompose)
// affiche "Some (Holly,PhoenixFeather)"

Il est d’usage de nommer ces méthodes en suivant la forme typeA2typeB.

Exemple d’utilisation

Les conversions implicites sont très présentes dans l’API Scala. L’une des conversions les plus utilisée concerne les chaines de caractères. L’objet scala.Predef est importé par defaut à tous les fichiers Scala.

Il définit String comme le type java.lang.String, c’est à dire le type Java.

//Extract form scala.Predef
type String = java.lang.String

implicit def wrapString(s: String): WrappedString = if (s ne null) new WrappedString(s) else null
implicit def unwrapString(ws: WrappedString): String = if (ws ne null) ws.self else null

implicit def augmentString(x: String): StringOps = new StringOps(x)
implicit def unaugmentString(x: StringOps): String = x.repr

scala.Predef déclare aussi deux conversions implicites pour String; à partir et vers WrappedString et StringOps. Ces deux classes apportent de nombreuses méthodes au type String, tel que map, foreach

Les developpeurs Scala nomment cette technique « extension de méthodes ». Le but est d’ajouter des nouvelles méthodes à des classes Java et Scala existantes.

Limitations

  • Ces deux techniques sont extrêmement pratiques, mais complètement opaques. ( Certains IDE apportent des outils d’analyse et de détection des implicites tel que IntelliJ, Cmd+Shift+P sous MacOS)
  • L’intelligence que nous attendons du compilateur lui apporte aussi une liberté que nous ne maîtrisons pas. Comment savoir ce que fait le compilateur et dans quels cas il réalise une conversion ?
  • Rien n’indique syntaxiquement que la classe MagicWand intervient, alors comment trouver la documentation de la méthode decompose ?

Séquence de compilation

Règles de compilation mises en œuvres par le compilateur

Les règles de mise en œuvre suivies par le compilateur dans les cas d’implicites sont les suivantes :

  • si le code peut être compilé tel quel, aucune recherche de conversion n’intervient.
  • si un problème se présente :
    • le compilateur recherche dans :
      • le contexte courant les définitions implicites de classe et de méthode,
      • les imports explicites
      • les imports globaux (import xxx._)
      • les companions objects du même type
    • il analyse les signatures jusqu’a trouver un convertisseur statisfaisant
    • il essaye d’appliquer la méthode demandée

S’il existe plusieurs implicites éligibles, il choisit le plus spécifque selon une liste de règles statiques (voir Scala Specification §6.26.4).

Les limitations de la séquence de compilation

  • Le choix de l’implicite sélectionné et invoqué est régi par un mécanisme complexe qu’il faut maîtriser.
  • Au maximum une seule
  • conversion est réalisée.
  • Le compilateur ne cherche pas une combinaison de conversion pour résoudre un problème.
  • Une mauvaise convertion implicite peut cacher une erreur de typage.

Les bonnes pratiques

Cette section de l’article est une traduction de Effective Scala publié par twitter.

extrait de Effective Scala

Les implicites sont des fonctionnalitées puissantes du système de type, mais il convient de les utiliser avec parcimonie. Les règles de résolution sont complexes et rendent difficile la lecture du code. Il est tout à fait correct d’utiliser les implicites dans les situations suivantes :

  • Extension ou ajout d’une collection de style Scala
  • Adaptation ou extension d’un objet (pattern « pimp my library »)
  • Utiliser pour améliorer la sécurité du type en fournissant des preuves de contraintes
  • Fournir une preuve de type (typeclassing)
  • Pour les Manifestes

Si vous utilisez des implicites, demandez-vous toujours s’il existe un moyen de réaliser la même chose sans leur aide.

Ne pas utiliser « implicit » pour effectuer des conversions automatiques entre des types de données similaires (par exemple, convertir une List en Stream) ; ceux-ci sont mieux faits explicitement parce que les types ont une sémantique différente.

Source : https://twitter.github.io/effectivescala/#Types%20and%20Generics-Implicits

Pour conclure

De cet article est né un grand débat chez Xebia, ou plutôt un dilemme. Pour résumer ce débat, voici quelques arguments.

Les adhérents trouvent que les implicites :

  • masquent la complexité et simplifient grandement l’utilisation des API ou des DSL ;
  • peuvent gérer les dépendances ;
  • sont meilleurs que les annotations ;
  • résolvent des problèmes techniques de manière fonctionnelle gracieuse (Type Class).

Les opposants pensent que les implicites :

  • seraient une des sources de lenteur lors de la compilation ;
  • créent de la complexité en déplaçant la complexité ;
  • doivent toujours être encadrés par des règles de craftsmanship ;
  • sont très compliqués à déboguer, en particulier lorsqu’ils sont couplés à des macros ;
  • seraient une des raisons de la non adoption du langage.

Comme beaucoup d’outils puissants en programmation, les implicites sont un outil à utiliser suivant le cas de figure et avec parcimonie. À présent, vous disposez des clés pour vous faire votre avis et prendre la bonne décision en fonction de votre situation !

 

One thought on “La magie des implicites, Scala”

  1. Publié par cluelessjoe, Il y a 2 semaines

    Sympa comme article, merci.

    Concernant les préconisations d’Effective Scala, « Fournir une preuve de type (typeclassing) » signifie quoi exactement ?

    Au demeurant le gros usage dans le projet sur lequel je suis est pour des conversions (de String en MonTypeDuDomaine par exemple). Est ce dans les clous d’après Effective Scala et/ou de façon générale ? Je pense que oui mais je préfère l’explicite… à l’implicite ^^

    Par contre à l’usage, personnellement, intellij ne me propose pas automatiquement les imports contenant les implicits désirés.

    Du coup le code ne compile pas quand je tape, toujours embêtant, puis je dois chercher & importer la classe contenant les implicits que je veux manuellement.

    Ca casse un peu le « coding flow » et à la longue c’est embêtant.

    Y a t il une meilleure façon de procéder?

    Enfin, pour le fameux débat pour ou contre, il est clair AMHA que cela a beaucoup coûté au langage Scala et encore maintenant certains me disent « mais avec les implicites on ne sait jamais ce qui se passe ». C’est plus du FUD qu’autre chose je trouve : on doit importer explicitement ce que l’on veut (pas de risque d’inclusion d’implicite « auto magiquement ») et de plus intellij fait un très bon boulot, AMHA, pour les mettre en avant. M’enfin, le mal est fait.

    A+
    cluelessjoe

Laisser un commentaire

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