Compilación de expresiones Lambda: Scala vs. Java 8

 ● 27th Feb 2014

8 min read

En los últimos años, las expresiones Lambda han arrasado con el mundo de la programación. La mayoría de los lenguajes modernos las han adoptado como una parte fundamental de la programación funcional. Asimismo, se han convertido en una pieza clave en aquellos lenguajes basados en la JVM, como Scala, Groovy y Clojure. Y ahora, por fin, Java 8 se suma a la fiesta.

Algo interesante de las expresiones Lambda es que, desde el punto de vista de la JVM, son absolutamente invisibles. La máquina virtual no tiene ni idea de qué es una función anónima o una expresión Lambda. Lo único que reconoce es bytecode, una especificación estricta de la Orientación a Objetos. Queda en manos de los creadores del lenguaje y de su compilador trabajar dentro de esos marcos para crear elementos nuevos y más avanzados del lenguaje.
La primera vez que nos topamos con esto fue cuando estábamos agregando el soporte para Scala a OverOps y tuvimos que bucear en las aguas profundas del compilador de Scala. Con Java 8 a la vuelta de la esquina, pensé que sería muy interesante ver cómo implementaban las expresiones Lambda los compiladores de Java y Scala. Los resultados fueron bastante inesperados.
Para comenzar, tomé una expresión Lambda simple que convierte una lista de Strings en una lista de sus longitudes
En Java –
[java]
List names = Arrays.asList(“1”, “2”, “3”);
Stream lengths = names.stream().map(name -> name.length());
[/java]
En Scala –
[scala]
val names = List(“1”, “2”, “3”)
val lengths = names.map(name => name.length)
[/scala]
No te creas que es tan simple: detrás de bambalinas están ocurriendo asuntos más complejos.

Comencemos con Scala

SCalaLam (1)
El código
Usé javap para visualizar los contenidos –en bytecode- del .class generado por el compilador de Scala. Veamos el bytecode resultante (que es lo que la JVM, al fin y al cabo, ejecutará).
[scala]
// esto carga la var names dentro de la pila (la JVM la toma
// como variable #2).
// Se va a quedar allí por un rato, hasta que la usemos
// con la función .map.
aload_2
[/scala]
A continuación, las cosas se ponen más interesantes: una nueva instancia de la clase sintética generada por el compilador es creada e inicializada. Este es el objeto que, desde el punto de vista de la JVM, retiene al método Lambda. Resulta gracioso que, mientras que Lambda está definida como parte integral de nuestro método, en realidad vive totalmente por fuera de nuestra clase.
[scala]
new myLambdas/Lambda1$$anonfun$1 //instanciación del objeto Lambda
dup //lo ponemos en la pila de nuevo
// finalmente, invocamos al creador. Recuerda: es un simple objeto
// desde el punto de vista de la JVM.
invokespecial myLambdas/Lambda1$$anonfun$1/()V
// estas dos (extensas) líneas de código cargan la factoría immutable.List CanBuildFrom
// la cual creará la nueva lista. Este patrón de factoría es parte de
// la arquitectura de las colecciones de Scala
getstatic scala/collection/immutable/List$/MODULE$
Lscala/collection/immutable/List$;
invokevirtual scala/collection/immutable/List$/canBuildFrom()
Lscala/collection/generic/CanBuildFrom;
// Ahora ya tenemos en la pila al objeto Lambda y a la factoría.
// La siguiente fase consiste en llamar a la función .map().
// Recuerda que cargamos la var names en
// la pila al comienzo. Ahora, será usada como la
// this para llamar a .map(), la cual también
// aceptará al objeto Lambda y a la factoría para producir
// la nueva lista de longitudes.
invokevirtual scala/collection/immutable/List/map(Lscala/Function1;
Lscala/collection/generic/CanBuildFrom;)Ljava/lang/Object;
[/scala]
Pero, ¡un momento! ¿Qué ocurre dentro del objeto Lambda?
El objeto Lambda
La clase Lambda deriva de scala.runtime.AbstractFunction1. A través de esto, la función map() puede hacer una invocación polimórfica al método sobreescrito apply(), cuyo código aparece más abajo.
[scala]
// este código carga this y el objeto destino en el que actuar,
// verifica que sea un String, y entonces llama a otro apply sobrecargado
// para que haga el verdadero trabajo y arroje su resultado.
aload_0 //carga this
aload_1 //carga el string arg
checkcast java/lang/String // tenemos un objeto, nos aseguramos de que sea un String
// llama a otro método apply() en la clase sintética
invokevirtual myLambdas/Lambda1$$anonfun$1/apply(Ljava/lang/String;)I
//arroja el resultado
invokestatic scala/runtime/BoxesRunTime/boxToInteger(I)Ljava/lang/Integer
areturn
[/scala]
El verdadero código para realizar la operación .length() está anidado en ese método apply adicional, que simplemente devuelve la longitud del String, según lo esperado.
¡Uff!… ¡un largo camino para llegar hasta aquí! 🙂
[scala]
aload_1
invokevirtual java/lang/String/length()I
ireturn
[/scala]
Para una línea tan simple como la que originalmente escribimos, más arriba, se genera mucho bytecode: una clase adicional y una cantidad de métodos. Por supuesto, no es que esto nos aleje de la mente la idea de emplear Lambdas (estamos escribiendo en Scala, no en C). Pero sí es válido para mostrar la complejidad existente detrás de esos constructos. ¡Piensa por un momento en la montaña de código encerrada en la compilación de complejas cadenas de expresiones Lambda!
Yo esperaba que Java 8 implementara esto de la misma forma, pero me llevé una gran sorpresa al ver que ha tomado un rumbo completamente distinto.

Java 8 – Un nuevo abordaje

JavaLam (1)
El bytecode aquí es más pequeño pero realiza algo más bien sorpresivo. Comienza de forma muy simple, cargando la var names e invocando su método .stream(), pero a esa altura, realiza algo muy elegante. En vez de crear un nuevo objeto que envuelva a la función Lambda, usa la instrucción invokeDynamic, agregada en Java 7, para enlazar dinámicamente el sitio de esta llamada con la función Lambda.
[java]
aload_1 //carga la var names
// llama a su función stream()
invokeinterface java/util/List.stream:()Ljava/util/stream/Stream;
// ¡la magia de invokeDynamic!
invokedynamic #0:apply:()Ljava/util/function/Function;
//llama a la función map()
invokeinterface java/util/stream/Stream.map:
(Ljava/util/function/Function;)Ljava/util/stream/Stream;
[/java]
La magia de InvokeDynamic. Esta instrucción de la JVM fue añadida en Java 7 para hacer menos estricta a la JVM, y permitir a los lenguajes dinámicos la asociación de símbolos en el tiempo de ejecución, en vez de hacer todas las vinculaciones de forma estática cuando el código ya fue compilado por la JVM.
Vinculación dinámica. Si miras la instrucción invokedynamic, notarás que no hay ninguna referencia de la función Lambda (llamada lambda$0). La razón recae en la forma en que invokedynamic está diseñada (lo cual merece todo un artículo aparte), pero en resumen, la razón está en el nombre y signatura de la Lambda, que en este caso es:
[java]
// una función denominada lambda$0 que recibe un String y devuelve un Integer
lambdas/Lambda1.lambda$0:(Ljava/lang/String;)Ljava/lang/Integer;
[/java]
Estas están almacenadas en una entrada en una tabla separada en el .class en que el parámetro #0 pasó los puntos de instrucción. Esta nueva tabla verdaderamente cambió la estructura de especificación del bytecode por primera vez en unos cuantos (y buenos) años, forzándonos a adaptar también el motor de análisis de errores de OverOps a ello.
El código Lambda
Este es el verdadero código de la expresión Lambda. Es muy similar: simplemente carga el parámetro de String, llama a length() y arroja el resultado. Verás que fue compilada como una función static para no tener que pasar un objeto this adicional a ella, como vimos en Scala.
[java]
aload_0
invokevirtual java/lang/String.length:()
invokestatic java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
areturn
[/java]
Esta es otra ventaja del abordaje con invokedynamic, ya que el mismo permite invocar al método de una manera que es polimórfica desde la perspectiva de la función .map(), pero sin necesidad de ubicar un objeto wrapper o un método virtual de sobreescritura. ¡Qué interesante!
Resumen. Es fascinante ver cómo ahora Java, el más “estricto” de los lenguajes modernos está utilizando vinculación dinámica para manejar sus nuevas expresiones Lambda. Este es, además, un abordaje sumamente eficiente, ya que no son necesarias la carga ni la compilación de ninguna clase adicional: el método Lambda es simplemente un método privado en nuestra clase.
Java 8 ha realizado un trabajo verdaderamente elegante al utilizar una tecnología introducida en Java 7 para implementar las expresiones Lambda de una forma que resulta sumamente simple y directa. Resulta un placer ver cómo hasta una niña “venerable” como Java todavía puede enseñarnos algunos nuevos trucos a todos 🙂

As a co-founder and CTO, Tal is responsible for overseeing OverOps' product and engineering strategy. Previously, Tal was co-founder and CEO at VisualTao, acquired by Autodesk Inc. (ADSK). Following that, Tal was the Director for the AutoCAD global Cloud and Mobile product line. Plays Jazz drums and Skypes, sometimes simultaneously.

The OverOps Trial Challenge Are you up for the challenge?
Win a “log files suck” t-shirt

Next Article

The Fastest Way to Why.

Eliminate the detective work of searching logs for the Cause of critical issues. Resolve issues in minutes.
Learn More