Scala 2.12 : Lambda expression

Lambda expression

Les lambda expressions en Java 8 représentaient jusqu’à présent un net avantage sur Scala : elles peuvent en effet s’appliquer à toutes sortes d’interfaces fonctionnelles. Les interfaces fonctionnelles sont des interfaces n’ayant qu’une seule méthode à implémenter (aussi appelées SAM pour Single Abstract Method). Du côté de Scala jusqu’à la version 2.11, les expressions de ce type étaient limitées à être transcrites uniquement sous la forme de Function0, Function1, Function2, etc., en fonction du nombre de paramètres en entrée de la fonction.

Ce point a été revu dans la version 2.12 de Scala. Si par défaut, le comportement ne change pas pour ce qui concerne la transcription en Function0, Function1, Function2, etc., il en va différemment à partir du moment où le contexte est précisé.

Prenons l’exemple de la création d’un thread pour des versions de Scala antérieures.

val thread =
  new Thread(new Runnable {
    override def run(): Unit = println("hello world!")
  })

À partir de Scala 2.12, nous pouvons l’écrire de la façon suivante.

val thread =
  new Thread(() => println("hello world!"))

Rentrons dans le détail de ce qui se passe.

Voyage au coeur des lambda expressions

Observons un exemple montrant le comportement par défaut, autrement dit si on ne précise pas l’interface à implémenter. Si on ouvre le REPL, nous avons le résultat suivant :

val f1 = (x: Int) => x + 1
f1: Int => Int = $Lambda$1610/845028109@3a3b07ee

Int => Int est l’équivalent de Function1[Int, Int]. Ainsi est transcrite par défaut une lambda expression.

f1: Int => Int = <function1>

Nous voyons que la fonction est à présent transcrite sous la forme Lambda de Java. Mais en dehors de cet affichage, il semble n’y avoir aucun véritable changement.

Regardons au niveau bytecode. Pour cela, mettons et utilisons f1 dans une classe appelée UseLambda.

class UseLambda {
  val f1 = (x: Int) => x + 1

  def useFunction() = println(f1(2))
}

En jouant avec javap et les options -c (désassembler le code) et -p (afficher tous les membres de la classe), nous voyons cette signature :

private final scala.Function1<java.lang.Object, java.lang.Object> f1;

À partir de là se posent deux questions. 1/ Comment accédons-nous à f1, puisque le champ est privé ? 2/ Comment est-il initialisé ?

Pour l’accès à f1, il suffit de regarder un peu plus loin :

public scala.Function1<java.lang.Object, java.lang.Object> f1();
    // ...
    getfield #19 // Field f1:Lscala/Function1;
    // ...

getfield fait bien référence à la variable d’instance f1 de type scala.Function1[_, _]. Donc, l’accès au champ f1 se fait par la méthode f1().

public void useFunction();
    // ...
    4: invokevirtual #34       // Method f1:()Lscala/Function1;
    7: iconst_2
    8: invokeinterface #40, 2  // InterfaceMethod
         // scala/Function1.apply$mcII$sp:(I)I
   13: invokestatic #46        // Method
         // scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;
    // ...

En 4, nous avons l’appel à la méthode de f1() qui nous retourne le contenu de du champ f1. En 8, c’est la méthode scala.Function1.apply$mcII$sp() qui est appelée sur ce qui est retourné par f1. Cette méthode n’existe pas dans scala.Function1. Elle est ajoutée au runtime. Nous allons voir comment.

Le champ f1 est initialisé dans le constructeur de UseLambda, au travers de l’appel à l’instruction invokedynamic :

public UseLambda();
    // ...
    invokedynamic #71, 0 // InvokeDynamic
        // #0:apply$mcII$sp:()Lscala/runtime/java8/JFunction1$mcII$sp;
    putfield #19 // Field f1:Lscala/Function1;
    // ...

C’est de là que vient la méthode mystère apply$mcII$sp(). Mais regardons de plus près cette instruction invokedynamic et ce qui se passe dans f1.

Invokedynamic

invokedynamic (aussi appelée indy) est la nouvelle instruction de la JVM apparue sous Java 7 avec la JSR-292. Elle permet de lier un appel de méthode avec n’importe quelle autre méthode ayant une signature identique au runtime (ça vous rappelle le duck typing ?), alors que les autres types d’invocation (invokestatic, invokevirtual, invokespecial) sont résolus au compile-time.

Cette instruction est utilisée dans Java 8, pour lier dynamiquement les lambda expressions à une méthode locale et faire l’économie de la création d’une classe anonyme. Afin de rentrer dans les détails du fonctionnement d’invokedynamic, cette instruction à besoin de connaître la méthode qui est censée être appelée (le call site) et une méthode de bootstrap. La méthode de bootstrap est appelée au runtime pour lier le call site à une méthode cible ayant une signature équivalente et contenant l’implémentation. Cette méthode cible est représentée par un MethodHandle. Dans Java 8, la méthode de bootstrap est unique pour toutes les lambda expressions. Il s’agit de java.lang.invoke.LambdaMetaFactory#metafactory().

Non seulement, nous faisons l’économie d’une classe anonyme, mais l’utilisation d’invokedynamic permet aussi de choisir plus tard d’autres stratégies de liaison au dernier moment de façon transparente. Sachant que cette stratégie peut changer d’un JRE à un autre. Fabien Arrault avait déjà sur ce blog décrit le fonctionnement de l’instruction invokedynamic dans un cadre plus large que Java 8 (Devoxx France 2015 Jour 3 : Proxy 2.0). L’article de “Java 8 Lambdas – A Peek Under the Hood” donne plus de détail sur le fonctionnement de l’instruction invokedynamic dans le cadre de Java 8.

Invokedynamic dans Scala

Scala 2.12 se base globalement sur le même principe que Java 8 pour la conversion des lambda expressions. Une différence réside dans la méthode de bootstrap. C’est la méthode java.lang.invoke.LambdaMetaFactory#altMetafactory() qui est utilisée. altMetafactory() est une version généralisée de metafactory(), permettant plus de flexibilité et plus de contrôle sur l’appel à la méthode cible.

L’affichage des méthodes de bootstrap est un peu particulier, car elle n’apparaît pas dans l’interface de la classe, mais plutôt dans une section dédiée du bytecode. Pour l’afficher avec javap, il faut ajouter l’option -v (mode verbeux). Pour UseLambda, nous voyons apparaître :

BootstrapMethods:
  0: #60 invokestatic java/lang/invoke/LambdaMetafactory.altMetafactory:(
            Ljava/lang/invoke/MethodHandles$Lookup;
            Ljava/lang/String;
            Ljava/lang/invoke/MethodType;
            [Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
    Method arguments:
      // ...
      #64 invokestatic UseLambda.$anonfun$f1$1:(I)I
      // ...

La méthode de bootstrap #0 fait bien un appel static à la méthode java.lang.invoke.LambdaMetaFactory#altMetafactory(). Celle-ci fait référence à la méthode $anonfun$f1$1() définie dans la classe UseLambda et qui contient le code de f1. Sa signature est la suivante :

public static final int $anonfun$f1$1(int);

Donc, lors de la construction de l’instance de UseLambda, la méthode permettant d’exécuter la fonction f1 sera au final liée à $anonfun$f1$1().

Initialisation des lambda dans Scala

Reprenons les lignes du constructeur de UseLambda (vues plus haut) avec l’invokedynamic qui initialise f1 :

invokedynamic #71, 0 // InvokeDynamic
    // #0:apply$mcII$sp:()Lscala/runtime/java8/JFunction1$mcII$sp;
putfield #19 // Field f1:Lscala/Function1;

Ici, la méthode appelée pour la liaison, le call site est scala.runtime.java8.JFunction1$mcII$sp#apply$mcII$sp().

scala/runtime/java8 est un impressionnant répertoire contenant des interfaces fonctionnelles de la forme JFunction{{A}}$mc{{IO}}$sp et étendant chacune scala.Function0, scala.Function1 et scala.Function2. A correspond à l’arité de la fonction (nombre de paramètres en entrée, variant entre 0 et 2) et IO correspond au type des paramètres de sortie et d’entrée. Ces interfaces sont des fonctions permettant de gérer le boxing / unboxing de types primitifs. La classe JFunction1$mcII$sp est donc une interface fonctionnelle (au sens Java) représentant une fonction ayant une entrée de type int (I) et retournant un int (I). Elle présente la méthode abstraite apply$mcII$sp(). C’est cette méthode abstraite qui est la cible dans l’instruction invokedynamic vue plus haut. Sa signature est la suivante :

int apply$mcII$sp(int v1);

On peut remarquer l’équivalence en terme de signature avec $anonfun$f1$1(), que nous avons vu dans la section précédente.

En résumé

Le champ f1 est initialisé au runtime avec JFunction1$mcII$sp. Le call site JFunction1$mcII$sp#apply$mcII$sp() est greffé indirectement en local dans le bytecode à scala.Function1. Il est aussi associé au runtime à la méthode $anonfun$f1$1(). Avec ça, les appels à la fonction f1() sont convertis en appels vers le call site scala.Function1.apply$mcII$sp() et sont ainsi redirigés vers $anonfun$f1$1().

L’instruction invokedynamic permet donc de transformer un appel vers une fonction en appel local. Cette transcription est proche de celle réalisée en Java 8 pour les lambda expressions pour ce qui concerne la transformation d’un appel externe en appel local.

Pour plus de précisions sur l’utilisation d’invokedynamic en Scala, Jason Zaugg a écrit un Gist à ce sujet.

Autres exemples de lambda expressions

Il est possible d’associer des lambda expressions Scala à d’autres interfaces fonctionnelles, notamment avec celles définies en Java.

val f2: java.util.function.Function[Int, Int] = (x) => x + 1
f2: java.util.function.Function[Int,Int] = $Lambda$1611/1221520969@2762891e
val f3: java.util.function.IntUnaryOperator = (x) => x + 1
f3: java.util.function.IntUnaryOperator = $Lambda$1612/5779187@5ba4d579

Par contre, une fois l’interface déterminée, il n’est plus possible de réaliser de conversion.

val f4: Int => Int = f2
Error:(8, 24) type mismatch;
    found   : java.util.function.Function[Int,Int]
    required: Int => Int
    val f4: Int => Int = f2
                         ^

Le code précédent donne une erreur de compilation. La conversion de java.util.function.Function[Int, Int] vers Int => Int (c’est-à-dire scala.Function1[Int, Int]) ne peut être faite implicitement. L’inverse donne la même erreur.

val f5 = () => println("hello")
f5: () => Unit = $Lambda$1613/1388667714@47581aa2
val f6: Runnable = () => println("hello")
f6: Runnable = $Lambda$1614/95047245@15dea018

L’intérêt des lambda expressions en Scala ne se trouve pas tant dans les performances. Il se trouve surtout dans la capacité à appliquer les lambda expressions à n’importe quelle interface fonctionnelle.

Référence sur des méthodes

Java 8 permet d’appliquer des références sur des méthodes, qui seront converties intrinsèquement en lambda expressions . Scala permet dans certains cas de réaliser la même chose.

val printer: Consumer[String] = println
printer: java.util.function.Consumer[String] = $Lambda$1623/1450301379@2dd0526e

Mais ce n’est pas applicable à tous les appels de méthode.

Prenons une interface Message contenant une méthode message(). En Java, on ne peut faire que ça :

Stream<String> export(Stream<Message> messages) {
    return messages.map(Message::message);
}

En Scala, on est limité à cette écriture :

def export(messages: List[Message]) =
  messages.map(m => m.message())

Par contre, en utilisant la notation _, on peut transformer la méthode précédente de la manière suivante :

def export(messages: List[Message]) =
  messages.map(_.message())

Cette écriture n’est qu’un sucre syntaxique et est totalement équivalente à sa première version. Ceci dit, on bénéficie d’une optimisation équivalente à Java, qui consiste à utiliser invokedynamic pour déterminer la méthode cible. Scala ajoute en plus une indirection se basant sur une méthode anonyme.

Conclusion

Les principales nouveautés de Scala 2.12 permettent de rapprocher le langage de la direction prise par Java 8. En dehors de l’amélioration de l’interopérabilité Java / Scala, l’effet obtenu est la possibilité d’être en présence d’un code encore plus concis et d’avoir moins de fichiers .class générés. Ceci fait de la nouvelle version de Scala un cru intéressant.

Du côté des frameworks et API, il faudra attendre les mises à jour avant de profiter pleinement de l’écosystème Scala dans cette nouvelle version. En effet, si Akka, ScalaTest ou ScalaZ sont prêts pour la 2.12, ce n’est pas encore le cas de Spark ou Play.

Et après…

Scala 2.12 sorti, Scala 2.11 continuera à être maintenu pour ceux qui utilisent la version 6 ou 7 de Java. En parallèle, les développeurs du langage préparent déjà la version 2.13. Cette version devrait intégrer une rénovation de l’API Collection, avec l’intégration de la notion de vue et en particulier un rapprochement de l’usage réalisé avec Spark. On attend aussi l’arrivée du nouveau compilateur Dotty (prévue pour le 2e trimestre 2017) et CBT, le futur remplaçant de SBT.

Reportez-vous à la keynote de Martin Odersky de Scala.io 2016 pour plus de détails sur les autres nouveautés à venir dans Scala : https://www.youtube.com/watch?v=jHoIzOdUn4c&t=638s

Laisser un commentaire

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

*