Articles

Publié par

Il y a 4 mois -

Temps de lecture 10 minutes

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 !

 

Publié par

Commentaire

7 réponses pour " La magie des implicites, Scala "

  1. Publié par , Il y a 4 mois

    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

  2. Publié par , Il y a 3 mois

    Hello @cluelessjoe

    concernant les conversions implicites, c’est plutôt déconseillé justement car l’usage masque le traitement et le besoin de l’implicit. Ce qui est recommandé est plutôt de faire du « ad hoc polymorphism » en appelant explicitement la méthode, qu’elle fasse une conversion de type ou non.
    L’important est surtout de bien comprendre le fonctionnement des implicits et surtout les avantages actroyés dans leurs différents cas d’utilisation. C’est loin d’être trivial au premier abord mais une fois la logique maîtrisée, je trouve qu’on voit bien la séparation explicit/implicit et que le côté magique disparaît.

    https://www.scala-exercises.org/cats/semigroup propose un bon exercice pour se familiariser avec les implicits, mais c’est effectivement pas super simple d’attaquer lorsqu’on débute en scala…

  3. Publié par , Il y a 3 mois

    Bonjour @cluelessjoe,
    Concernant la preuve de type : une preuve de type est une contrainte qu’on souhaite imposer à un type argument. Exemple : vous avez une fonction de conversion implicite de

    T => IntegralOps

    , qui va fournir à T les fonctions applicables aux entiers. Vous aimeriez restreindre la portée de cette conversion implicite. Vous aimerier qu’on vous prouve que T est un type particulier pour lequel la conversion est possible? Malheureusement, les types génériques T attendu ne sont pas issus de la même hiérarchie. Il va donc falloir ruser. Vous avez un type Integral[T] qui lui, n’est applicable qu’aux types que vous souhaitez. Si vous avez une instance d’Integral[T], vous avez la preuve que votre conversion de T en IntegralOpss sera possible.

    Vous écrivez donc:

    implicit def infixIntegralOps[T](x: T)(implicit num: Integral[T]): Integral[T]#IntegralOps = new num.IntegralOps(x)

    Exemple : Vous avez défini

    implicit object LongIsIntegral extends Integral[Long] { /*implémentation des opérateurs*/ }

    Donc Scala est capable d’appeler

    infixIntegralOps[Long](x: Long)

    Comme cette dernière fonction est implicite, le type Long se verra augmenté de toutes les opérations définies dans IntegralOps.
    En revanche, il n’existe aucune implémentation de Integral[Double]. Un double ne sera jamais implicitement converti en IntegrapOps.

    Notez que vous verrez souvent du code défini ainsi

    def mafonction[T](arg: T)(implicit ev: UnType[T])

    « ev » est mis ici pour « evidence » -> « preuve ». On a ici explicité le rôle de l’implicite ;)
    Dans les versions récentes de scala, cette notation est remplaçable par

    def mafonction[T: UnType](arg: T)

    Cette dernière notation rend la preuve de type homogène aux autres contraintes de type, mais attention, un débutant ira souvent chercher une première fois sur Internet pourquoi son compilateur lui demande un implicite.

  4. Publié par , Il y a 3 mois

    Concernant la conversion implicite de String en type de votre domaine, il s’agit là d’une utilisation déconseillée. En effet, lorsque vous allez appeler une méthode qui prend deux objets du domaine, et qui est surchargée de cette manière :

    def methode(a: A, b:B)
    def methode(a: A, b: C)
    

    lorsque vous allez appeler mamethode avec 2 chaînes, le compilateur va demander s’il doit convertir la seconde en B ou en C…
    Cela introduit d’autres ambigüité du même genre. Préférez les « Value Types », qui permettent d’avoir des vrais types vérifié, non ambigüs. Quitte à leur faire étendre AnyVal pour qu’ils soient vus comme en type de base au après compilation.
    C’est même une bonne pratique de remplacer explicitement les String par des types qui peuvent avoir des contraintes.

  5. Publié par , Il y a 2 mois

    Bonjour

    Merci Raph et Joachim pour ces retours :)

    Concernant les conversions implicites, je vois souvent des choses comme suit dans la base de code sur laquelle je suis:
    implicit class NotionDuDomaine(val value: Double) extends AnyVal

    Pour vous cela est il recommandé ou pas en Scala ?

    Aucun de vous deux n’a répondu sur la problématique des imports à réaliser manuellement, j’en conclus que vous avez le même souci, correct ?

    Et merci pour l’explication de la preuve de type : je connaissais la technique mais pas son nommage :)

    A+

  6. Publié par , Il y a 1 mois

    Concernant les implicit * extends AnyVal, c’est à éviter en général, mais on peut l’utiliser avec parcimonie dans certains cas.
    Pourquoi l’éviter en général ?
    – à cause des conflits de signature : Si une méthode prends en paramètre un double et une de ses surcharges un NotionDuDomaine, lorsque vous allez appeler avec un double, le compilateur va râler car 2 méthodes conviennent (ambigüité)
    – à cause du contournement du système de type. Vous pouvez appeler avec un double là où est attendu un NotionDuDomaine, sans autre vérification. Ça peut sembler pratique lorsque NotionDuDomaine accepte tous les doubles, mais si jamais les valeurs possibles sont un sous-ensembe (exemple : réels strictement positifs), vous avé cassé votre domaine.
    Du coup, où peut-on l’utiliser ?
    J’ai déjà à moitié répondu : dans les cas où la notion est assimilable au type de base ET où vous voulez garder les performance des types « natifs » (auto-unboxing des AnyVal). Mais franchement, c’est un compromis entre clareté+robustesse d’un côté et productivité+performances de l’autre.

    cf ce cas particulier parmis tous les cas de value classes mais gardez à l’esprit le cas où le développeur vous passe un Double en étant persuadé que la méthode prend des MilliMeters…

  7. Publié par , Il y a 1 mois

    Bonjour

    Encore merci pour ces réponses :)

    Concernant la visualisation des implicits, intellij propose désormais des fonctionnalités vraiment intéressantes, cf https://www.youtube.com/watch?v=dRiQIo9moSw . Vraiment top AMHA et de quoi expliciter plein de choses… Ca fait un peu inférence de type sous stéroïdes je trouve :)

    Pour en revenir au sujet des implicits et Value Class (cad les extends AnyVal), j’ai clarifié avec mon collègue et en substance:
    – usage des implicits uniquement pour les Value Classes
    – usage des Value Class uniquement pour wrapper des String (j’ai vérifié ^^), et ainsi avoir des String « fortement typées » (à la compilation) comme notion du domaine

    Et au final, pour mon collègue, utiliser des implicit Value Class pour des String fortement typées est comme écrire 1 pour une valeur typée Double ou Long, cad ne pas mettre explicitement 1.0 ou 1L. Et franchement, dans le cas d’implicit Value Class que pour des String, dur de contre argumenter je trouve.

    Au demeurant avoir des chaînes de caractères fortement typées (sans surcoût de perf en plus) est vraiment top et nous a permis déjà plusieurs fois de « distiller notre domaine » comme dirait Eric Evans.

    C’est à dire que deux strings qu’on aurait pu aisément affecter l’une à l’autre représentaient en fait des notions bien différentes que le typage fort via les Value Class a mis en avant, explicitant fortement le domaine. Des alias de type n’auraient pas suffit car ils n’empêchent pas ces affectations intempestives.

    Avec ces précisions je trouve cela plutôt pertinent au final :) Qu’en dites vous?

    In fine, le seul hic, c’est de toujours savoir quel package importer manuellement… Je me demande si je ne devrai pas ouvrir un ticket sur le repo du plugin Scala intellij ^^

    A+

Laisser un commentaire

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

Nous recrutons

Être un Xebian, 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.