La résolution des implicites scala prennent place lors de la compilation. Mais comment en être sûr ? Prenons un exemple simple…
Nous avons vu lors d’un précédent article (Les “implicits” en Scala, ou comment sous-traiter au compilateur) qu’il existait deux types d’implicits : les paramètres implicites et les conversions implicites. Mais que se passe-t-il “under the hood”, comment cela fonctionne-t-il ? C’est ce que nous allons voir dans ce deuxième article sur les implicits scala.
Remarque : Dans ce deuxième article sur les implicits scala, je ne parlerai pas ici des classes implicites qui sont du sucre syntaxique pour les conversions implicites.
Les paramètres implicites, une résolution (presque) simple
Il a déjà été dit dans l’article précédent du même dossier que la résolution des implicites prenait place lors de la compilation. Mais comment en être sûr ? Prenons un exemple simple, utilisant un paramètre implicite et compilons-le :
class FooImplicit { def bar(implicit message: String) = print(message) implicit val message = "hello world" bar }
Exécutons maintenant la commande javap -c FooImplicit.class :
public class FooImplicit { public void bar(java.lang.String); Code: 0: getstatic #18 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: aload_1 4: invokevirtual #22 // Method scala/Predef$.print:(Ljava/lang/Object;)V 7: return public java.lang.String message(); Code: 0: aload_0 1: getfield #27 // Field message:Ljava/lang/String; 4: areturn public FooImplicit(); Code: 0: aload_0 1: invokespecial #31 // Method java/lang/Object."":()V 4: aload_0 5: ldc #33 // String hello world 7: putfield #27 // Field message:Ljava/lang/String; 10: aload_0 11: aload_0 12: invokevirtual #35 // Method message:()Ljava/lang/String; 15: invokevirtual #37 // Method bar:(Ljava/lang/String;)V 18: return }
Prenons maintenant un exemple similaire, mais sans paramètre implicite :
class FooNoImplicit { def bar(message: String) = print(message) val message = "hello world" bar(message) }
Exécutons cette fois la commande javap -c FooNoImplicit.class :
public class FooNoImplicit { public void bar(java.lang.String); Code: 0: getstatic #18 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: aload_1 4: invokevirtual #22 // Method scala/Predef$.print:(Ljava/lang/Object;)V 7: return public java.lang.String message(); Code: 0: aload_0 1: getfield #27 // Field message:Ljava/lang/String; 4: areturn public FooNoImplicit(); Code: 0: aload_0 1: invokespecial #31 // Method java/lang/Object."":()V 4: aload_0 5: ldc #33 // String hello world 7: putfield #27 // Field message:Ljava/lang/String; 10: aload_0 11: aload_0 12: invokevirtual #35 // Method message:()Ljava/lang/String; 15: invokevirtual #37 // Method bar:(Ljava/lang/String;)V 18: return }
On peut s’apercevoir qu’il n’y a aucune différence à la compilation (à part le nom des classes, évidemment) ! Le paramètre implicite a donc bien été résolu durant la compilation et n’a pas été injecté au runtime. Il n’y a plus de concept d’implicit après la compilation !
La question qui se pose désormais est : Comment le paramètre a-t-il été résolu ?
Eh bien Jamy, c’est très simple (ou presque).
Ce qu’il faut garder en tête c’est que le compilateur cherchera des implicits à deux endroits, respectivement dans l’ordre :
- Dans le scope relatif à l’endroit où la méthode est appelée
- Dans le « scope implicite » du type T attendu
Le premier point est assez simple à comprendre, voici néanmoins un exemple :
object Speed{ implicit val speed = 88.0 } trait HasPrice{ implicit val price = 10000 } object Car extends HasPrice{ import Speed.speed implicit val label = "car" def describe = describeWithImplicit def describeWithImplicit(implicit l:String, p:Int, s:Double) = { println(s"Label :$l, Price: $p, Speed: $s") } } Car.describe
Il est à noter que le scope de la méthode appelante est le scope de Car car c’est l’appel à describe qui appellera la méthode describeWithImplicit nécessitant les paramètres implicites.
Le premier implicite, label, est résolu car c’est un attribut de l’objet Car, donc accessible depuis ses méthodes.
Le second implicite, price, est résolu car Car hérite de HasPrice qui offre un implicite du type attendu.
Enfin, speed est résolu grâce à l’import de Speed.
À noter que si l’on avait appelé Car.describeWithImplicit au lieu de Car.describe, aucun des paramètres implicites n’auraient été résolus car le scope de la méthode appelante aurait été l’objet courant et non l’objet Car.
La seconde stratégie de résolution de paramètre implicite est moins triviale. Pour simplifier on pourrait dire que, pour tout type T, le scope implicite de T correspond aux objets compagnon de toutes les classes associées au type T. Par exemple, les objets compagnon de A et de B si T est de type A[B] ou A with B ou A#B, etc.
Prenons un exemple pour illustrer :
class Foo[A] class Bar object Foo{ implicit val fb = new Foo[Bar] } def foobar(implicit foobar: Foo[Bar]) = print("foo bar !") foobar
Dans l’exemple donné ci-dessus le code compilera car un implicite est trouvé dans l’objet compagnon de Foo. Il aurait très bien pu être déclaré dans l’objet compagnon de Bar.
Remarque : La seconde règle de résolution est ici volontairement simplifiée, vous pouvez la trouver en intégralité à la page 106 de la documentation, dans la langue de Shakespeare.
Les conversions implicites, une résolution (un peu plus) simple
Prenons cette fois encore deux exemples de code utilisant, d’un côté, une conversion implicite, et de l’autre, un appel de méthode classique afin de comparer les instructions de la JVM.
class FooImplicit { def bar(message: String) = print(message) implicit def IntToString(x:Int) = x.toString bar(125) }
Résultat de la commande javap -c FooImplicit.class :
public class FooImplicit { public void bar(java.lang.String); Code: 0: getstatic #16 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: aload_1 4: invokevirtual #20 // Method scala/Predef$.print:(Ljava/lang/Object;)V 7: return public java.lang.String IntToString(int); Code: 0: iload_1 1: invokestatic #32 // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer; 4: invokevirtual #36 // Method java/lang/Object.toString:()Ljava/lang/String; 7: areturn public FooImplicit(); Code: 0: aload_0 1: invokespecial #42 // Method java/lang/Object."":()V 4: aload_0 5: aload_0 6: bipush 125 8: invokevirtual #44 // Method IntToString:(I)Ljava/lang/String; 11: invokevirtual #46 // Method bar:(Ljava/lang/String;)V 14: return }
Prenons maintenant un exemple similaire, mais sans conversion implicite, avec appel de méthode classique :
class FooNoImplicit { def bar(message: String) = print(message) def IntToString(x:Int) = x.toString bar(IntToString(125)) }
Résultat de la commande javap -c FooNoImplicit.class :
public class FooNoImplicit { public void bar(java.lang.String); Code: 0: getstatic #16 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: aload_1 4: invokevirtual #20 // Method scala/Predef$.print:(Ljava/lang/Object;)V 7: return public java.lang.String IntToString(int); Code: 0: iload_1 1: invokestatic #32 // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer; 4: invokevirtual #36 // Method java/lang/Object.toString:()Ljava/lang/String; 7: areturn public FooNoImplicit(); Code: 0: aload_0 1: invokespecial #42 // Method java/lang/Object."":()V 4: aload_0 5: aload_0 6: bipush 125 8: invokevirtual #44 // Method IntToString:(I)Ljava/lang/String; 11: invokevirtual #46 // Method bar:(Ljava/lang/String;)V 14: return }
Encore une fois, pas de différence au niveau des instructions JVM, ce qui montre bien que les conversions implicites sont elles aussi résolues au moment de la compilation.
Pour ce qui est de la résolution des conversions implicites (appelées Views), elle est similaire à la résolution des paramètres implicites. Seule la définition du scope implicite change.
Il existe trois cas particuliers, respectivement :
- L’expression e passée à la méthode est de type A alors qu’un type B est attendu
- L’expression e.m est passée à la méthode alors que m n’existe pas dans e
- L’expression e.m(args) est passée à la méthode alors que args n’est pas le type attendu par e.m
On part du principe qu’il existe une méthode comme définie ci-dessous, que nous allons appeler avec des e différents.
def foo(message:String) = print(message)
Premier cas d’appel de la méthode
object Foo{ implicit def FooToString(foo:this.type) = "Hello world" } foo(Foo)
Ici, nous passons à la méthode un objet de type Foo$ alors qu’elle attend un objet de type String. Le compilateur définit le scope implicite de recherche comme étant Foo$ => String. La conversion implicite sera donc recherchée dans Foo$ et l’objet compagnon de String.
Second cas d’appel de la méthode
object Bar{ val message = "Hello world" } object Foo{ implicit def FooToBar(foo:this.type) = Bar } foo(Foo.message)
Ici Foo$ ne définit pas d’attribut message, le compilateur définira donc le scope implicite comme étant Foo$ et cherchera une conversion implicite Foo$ => T, avec T possédant message.
Troisième cas d’appel de la méthode
object Bar{ def doSomething(x:String) = x } object Foo { def doSomething(x:Int) = x.toString implicit def FooToBar(foo:this.type) = Bar } foo(Foo.doSomething("Hello world"))
Ici, il existe une méthode doSomething dans Foo$. Néanmoins, cette méthode n’attend pas un String, le compilateur cherchera donc une conversion Foo$ => T avec T possédant une méthode doSomething attendant un argument de type String.
Remarque : Dans les trois exemples présentés ci-dessus j’ai pris le parti d’utiliser des objets et non un couple classe/compagnon objet pour raccourcir le code. Cela ne change en rien la façon dont le compilateur définira le scope implicite. Si, dans le deuxième exemple, j’avais utilisé le couple class Foo / objet compagnon Foo$, le scope implicite aurait été le même.
Un grand pouvoir implique de grandes responsabilités
Nous avons vu que selon que l’on utilise des paramètres ou des conversions implicites, les règles de résolution étaient nombreuses et spécifiques au cas d’utilisation. Cela pourrait sembler à l’avantage du développeur, lui permettant une plus grande liberté de design de son code. Néanmoins, il est nécessaire de se rendre compte des dérives pouvant découler d’une utilisation excessive des implicites.
Une (re)lecture du code complexe
Il faut bien avoir à l’esprit que si vous décidez de vous simplifier la vie avec une conversion implicite, les définitions des méthodes que vous allez appeler ne vont pas changer pour autant. Une méthode attendant un type T attendra toujours un type T.
Comment risque donc de réagir un développeur, n’ayant pas connaissance du code, à la vue de ces méthodes appelées avec potentiellement n’importe quel type ?
S’il ne connaît pas le concept des conversions implicites il risque d’avoir du mal à comprendre pourquoi l’IDE lui indique une méthode attendant un Int alors qu’on l’appelle avec un String. Et pourquoi diantre est-il impossible d’appeler la même méthode avec la même String à un autre endroit (sacrées règles de résolution !).
Mais les conversions implicites ne sont pas les seules à rendre la lecture du code confuse !
La plupart des API (ou librairies externes) se reposant sur des paramètres implicites pour leur fonctionnement vous fournissent ces mêmes paramètres sans que vous le sachiez.
C’est la raison pour laquelle, dans leur code de démonstration, il est la plupart du temps demandé d’ajouter une ligne de ce genre :
import x.y.z._ //on importe les méthodes, paramètres et conversions implicites, ...
Dès lors, comment relire efficacement le code si l’appel suivant…
doSomething("foobar")
… cache en réalité plusieurs paramètres implicites, déclarés à divers endroits et affectant directement l’exécution de la méthode ?
Le développeur se voit amputé de certaines informations qu’il va devoir chercher… ailleurs, mais où ? Dans les imports, dans la hiérarchie, dans les objets compagnons, … ?
Le code review n’aura jamais été aussi fun !
Les effets de bords masqués
Comme dit précédemment, beaucoup de librairies Scala se basant sur l’usage (plus ou moins intensif) des implicits vous demanderont d’inclure une ligne telle que celle-ci dans votre code :
import x.y.z._ //allez hop, on importe les méthodes, les implicites, etc ...
Le problème c’est que dans certains cas le wildcard import n’est pas ce que vous désirez et il est possible que vous soyez en train de modifier le comportement de votre programme sans le savoir.
Par exemple, vous pourriez importer une conversion implicite qui prendrait le pas sur l’une de vos propres conversions (la conversion la plus spécifique étant choisie). Ce problème pourrait même survenir durant une mise à jour des librairies externes. Une nouvelle conversion implicite est définie dans une des librairies externes, votre code compile toujours, mais ne se comporte plus comme avant. Difficile de localiser un problème comme celui-là.
Vous pourriez aussi importer tout un tas de conversions ne vous étant pas utiles et qui en plus vous empêche de remarquer certaines erreurs dans votre code, par exemple :
import x.y.z._ // importe des conversions utiles, et d’autres qui le sont moins class Person(val name: String, var wage: Double) { def increaseWage(bonus: Int) = wage += bonus } val p = new Person("John", 1000) p.increaseWage(100) p.increaseWage(100.0) // ne devrait pas compiler print(p.wage) // 1200.0 ? Rien ne nous l’assure
Ici, il n’aurait pas dû être possible d’appeler la méthode incraseWage avec un flottant et pourtant ce code compile.
La conversion implicite doit forcément provenir de l’import de la librairie externe, et rien ne nous assure que la conversion soit logique selon notre contexte.
Dans le cas ci-dessus, il est très facile de passer à côté du problème, alors imaginez avec un projet de plusieurs milliers de lignes, avec des classes dans tous les sens, etc.. Cela devient vite très compliqué.
Il est donc préférable d’importer spécifiquement les conversions implicites dont vous avez besoin plutôt que d’importer potentiellement du code sur lequel vous n’avez pas le contrôle.
Une compilation à la ramasse
La compilation Scala est connue pour être plus lente que la compilation Java (pour diverses raisons), et l’utilisation des implicites n’est pas là pour arranger les choses.
En effet, à chaque fois que vous déclarez un implicite (que ce soit un paramètre ou une conversion), le compilateur devra aller chercher dans tous les scopes possible afin de la résoudre. Et plus il sera déclaré loin (dans un trait parent d’une classe parente d’une autre, etc.) plus la compilation prendra de temps.
Vous savez donc ce qu’il vous reste à faire pour justifier les pauses café.
Conclusion
L’article précédent avait pour but d’introduire le concept des implicits scala et de montrer en quoi ils pouvaient être utiles pour les développeurs. J’espère que cet article aura permis de lever le voile sur le “comment” des implicits et surtout de faire comprendre au lecteur que la complexité des règles de résolution n’amène pas seulement à une plus grande flexibilité, mais aussi à potentiellement plus de difficulté à résoudre les problèmes liés à ces concepts.
Entre le shadowing potentiel, les implicits plus ou moins spécifiques, la définition de paramètres ou de méthodes de conversion dans des scopes divers et variés, il est parfois difficile de s’y retrouver.
À consommer donc avec modération.
——–
Si le sujet des implicits scala vous intéresse, un article précède celui-ci :
– Les “implicits” en Scala, ou comment sous-traiter au compilateur
Pas encore de commentaires