La magia de Groovy: haciendo dinámico lo estático

El otro día, en el seminario de Creación de DSLs con Groovy, un asistente me hizo una interesante pregunta. Me encontraba explicando cómo funciona el ExpandoMetaClass, el sistema que tiene Groovy para “sobreescribir” métodos de cualquier clase, incluso del JDK.En el siguiente código de la presentación se ve claramente lo que se quiere hacer:

Integer.metaClass.toString = {
    return "OPS!"
}
println 5.toString()

La explicación que estaba dando en ese momento decía algo así como “con este código, sobreescribimos el método toString de la clase Integer, de manera que cuando lo llamemos nos devuelva la cadena “OPS!”. Pero ojo, porque modificar el comportamiento de una clase del JDK no es una buena práctica porque bla bla bla”. Y en ese momento me interrumpieron (cosa que me gusta, porque significa que la gente está atenta y es curiosa) con esta pregunta: “pero ¿la sobreescritura de un método del JDK tiene efecto desde todas las clases, o solo durante el código donde se ejecuta?”. Y es una pregunta interesante porque para dar una respuesta con la máxima precisión posible es necesario adentrarse en cómo funciona Groovy por dentro. La respuesta corta es: si, tiene efecto en toda la máquina virtual, pero solo dentro del código de las clases de Groovy, nunca dentro de una clase Java. Pero para la respuesta larga no había tiempo, así que aprovecharé para responderla ahora con este post.

Groovy es un lenguaje empotrado (o embebido) en la JVM, por lo que cuando programamos en Groovy sabemos que el código resultante es 100% Java, ya que Groovy crea bytecode y genera clases que podemos usar desde Java con total libertad. Podemos usar interfaces Java desde Groovy (y viceversa), o hacer que dos clases, una en Java y otra en Groovy, hereden la una de la otra (o al revés). Hasta aquí todo correcto, sigamos.

Como la sintaxis de Groovy es casi idéntica a la de Java, podemos tener la sensación de que el código generado es el mismo que si hubiésemos programado la clase en Java (sin tener en cuenta las cosas nuevas que añade Groovy, como closures, etc). Pero sabemos que hay un poco de “magia”, la que hace que Groovy pueda acceder a variables y métodos que todavía no han sido creados en tiempo de compilación, pero que sabemos que posteriormente existirán en tiempo de ejecución. Cuando decimos que Groovy es dinámico o hablamos de Duck-typing, estamos hablando sin querer de esta “magia”, sin pararnos realmente a pensar como funciona, pues son las tripas internas de Groovy y, simplemente, nos fiamos.

¿Y cómo funciona realmente esta “magia”? ¿cómo hace Groovy para que, generando código 100% Java (que sabemos que tiene tipado estático) se convierta en tipado dinámico, cuando Java no lo es? Todas las clases compiladas y generadas por Groovy, y todas las que se usan desde Groovy, implementan siempre groovy.lang.GroovyObject, por lo que poseen los siguientes métodos:

    Object invokeMethod(String name, Object args);
    Object getProperty(String propertyName);
    void setProperty(String propertyName, Object newValue);
    MetaClass getMetaClass();
    void setMetaClass(MetaClass metaClass);

Los tres primeros métodos se encargan de interceptar todos los accesos de escritura y lectura de atributos de la clase, y de interceptar todas las llamadas a cualquier método que pueda poseer la clase, sin verificar que exista o no.

Así que, para que nos hagamos una idea, el código generado podría ser algo así (no es exactamente así, se ha simplificado con un código equivalente creado exclusivamente con fines didácticos para que lo entendamos. Si descompilamos una clase Groovy, veremos algo totalemente distinto)

Prueba.groovy

def objeto = new UnaClaseCualquiera()
objeto.variable = "saludo"
objeto.llamada()

Prueba.java (pseudo-clase resultante de compilar Prueba.groovy)

Object objeto = new UnaClaseNuestra();
objeto.setProperty("variable", "saludo");
objeto.invokeMethod("llamada", new Object[]{});

Como vemos, el código resultante es código Java 100%, tipado estático y que compila perfectamente. No importa si durante el código Groovy hemos usado un nombre de variable o método que exista o no, ya que en el código resultante Java no aparece explícitamente su acceso, sino que está encapsulado a través de estos métodos. La implementación de invokeMethod, por ejemplo, dependerá de muchos factores, pero podría ser la siguiente: usará reflexión para averiguar si la clase actual tiene un método con ese nombre que acepte los parámetros enviados (y con aceptar me refiero a número y tipo) y ejecutarlo; y si no lo encuentra, buscará un atributo de tipo Closure con ese nombre e intentará ejecutarlo, etc. Todas estas búsquedas sobre que método ejecutar (o qué atributo leer o escribir) se producen en tiempo de ejecución, produciendo el efecto de “código dinámico”, es decir, código cuyo comportamiento puede ser modificado en tiempo de ejecución.

Después hay otros dos métodos: getMetaClass() y setMetaClass() que nos permiten acceder (y modificar) el MetaClass relacionado con el objeto actual. El MetaClass contiene todos los atributos y métodos dinámicos que hemos añadido a la clase. De hecho, la implementación del método invokeMethod de la clase GroovyObjectSupport (de la que heredan todos los objetos del GDK de Groovy) es la siguiente:

public Object invokeMethod(String name, Object args) {
    return getMetaClass().invokeMethod(this, name, args);
}

Es decir, cualquier llamada a un método es delegada a su correspondiente objeto MetaClass, de manera que éste será el responsable de buscar si primero alguien ha añadido para esa clase un método dinámico (como el toString() para la clase Integer) y ejecutarlo antes. El efecto de esto es que estamos “sobrescribiendo” un método en tiempo de ejecución (el uso de las comillas es a propósito, ya que no existe una sobreescritura real).

Volviendo al ejemplo inicial, cuando hacemos esto:

Integer.metaClass.toString = { return "OPS!" }

Lo único que hacemos es añadir un closure en el objeto MetaClass de la clase Integer, de manera que cuando llamamos a nuestro método toString(), lo intercepte su MetaClass, encuentre el que acabamos de añadir y lo ejecute en vez de invocar al método toString() original del JDK. Por esta razón, esto solo funciona si la clase que ejecuta el toString() es una clase Groovy, no una Java. Una prueba de ello es el siguiente código:

Test.groovy

Integer.metaClass.toString = { return "OPS" }
println 5.toString()
println(new JavaTest().metodo(5))

JavaTest.java

public class JavaTest {
    public String metodo(Integer p) {
        return p.toString();
    }
}

El resultado será “OPS” y después “5”. Esto es así porque desde JavaTest, al estar creada directamente en Java, nos saltamos la llamada al invokeMethod y al MetaClass, que son los encargados de interceptar las llamadas, buscar y ejecutar los métodos nuevos añadidos en tiempo de ejecución y que sobreescriben a los originales del JDK.

De hecho, podemos corroborar esta afirmación viendo lo que hacemos de otra manera. Pensemos que en vez intentar “sobreescribir” el método toString() de Integer, lo que hacemos es añadir un método nuevo. Por ejemplo, añadamos el método toMelon() a la clase Integer desde Groovy y probémoslo:

Integer.metaClass.toMelon = {
    return "Melón"
}
println 5.toMelon()

Desde Java no solo no podemos llamarlo, sino que directamente no compilaría, pues el método toMelon no existe en la clase Integer del JDK.

Puede que todavía haya alguien que diga: “ok, todas las clases generadas por Groovy heredan de GroovyObjectSupport o implementan de alguna manera GroovyObject. Pero, ¿y las clases del JDK? ¿Cómo es posible acceder al atributo metaClass de la clase Integer, si este no existe?”. La respuesta es que desde Groovy nunca usamos clases del JDK, sino objetos que las encapsulan. Durante el proceso de compilación, Groovy se encarga de envolver en wrappers todos los tipos primitivos en sus propias clases. Por ejemplo, todos los ints o Integers que haya en nuestro código Groovy serán sustituidos por instancias de la clase IntWrapper, la cual si implementa GroovyObject. Incluso si usamos desde Groovy una clase Java nuestra, no la estamos usando directamente, sino que estamos accediendo a ella a través de un PojoWraper, el cual también implementa GroovyObject.

El encargado de hacer toda esta magia de verdad es el compilador de Groovy y la clase ScriptBytecodeAdapter, la cual os invito a que leáis y estudiéis, pues el verdadero código generado por Groovy no son más que llamadas a métodos de esta clase (ver un ejemplo aquí, o compruébalo tu mismo descompilando una clase Groovy)

Con esto espero haber dado un poco de luz a como funciona Groovy por dentro, como se resuelven los accesos cuando llamamos a métodos y como se “sobreescriben” en tiempo de ejecución. Espero poder más adelante explicar con detalle la generación de bytecode por el compilador de Groovy y las transformaciones AST. Mientras tanto, solo tenemos que recordar que la magia no existe, que todo son trucos. Y que Groovy usa un truco fantástico para engañarnos, y parecer dinámico cuando en realidad no lo es (desde el punto de vista de la JVM). Y que ese truco solo funciona dentro de las clases Java generadas a partir de clases Groovy, nunca desde clases Java escritas en Java.