Metaprogramación en runtime con Groovy

Metaprogramación en runtime con Groovy
Sin comentarios Facebook Twitter Flipboard E-mail

Una de las características que hacen de Groovy un lenguaje tan potente es su capacidad de Metaprogramación. El hecho de que sea un lenguaje dinámico (opcionalmente como ya vimos) hace que podamos postponer hasta tiempo de ejecución ciertas comprobaciones y decisiones que normalmente se harían en tiempo de compilación. Así podremos interceptar, inyectar e incluso sintetizar nuevas clases y métodos bajo demanda en tiempo de ejecución.

La metaprogramación es una técnica avanzada que básicamente nos permite _escribir código que escribe código_. Este tipo de técnicas, que a priori pueden no significar nada, hacen que podamos resolver nuestros problemas con un enfoque distinto y en ocasiones la solución puede ser mucho más elegante, eficiente y adecuada que si usamos un enfoque más tradicional.

Si eres desarrollador Java probablemente te vengan a la cabeza _reflection_, programación orientada a aspectos o incluso manipulación de bytecode o generación de código y pienses que esto es algo muy complicado, ¡nada más lejos de la realidad!
En este artículo nos vamos a centrar en la Metaprogramación en Runtime de Groovy, veremos qué es, cómo y por qué funciona en Groovy y explicaremos las distintas técnicas con ejemplos de código para que todo se entienda mejor.

Un poco de teoría

Hemos comentado que cuando usamos las diferentes técnicas de metaprogramación lo que estamos haciendo es _escribir código que escribe código_. En la wikipedia tenemos una definición algo más formal:

La metaprogramación consiste en escribir programas que escriben o manipulan otros programas (o a sí mismos) como datos.

Groovy proporciona estas capacidades de metaprogramación a través del _Meta-Object Protocol_ (MOP).

Groovy Mop

La clave está en que desde Groovy cuando ejecutamos otro código (bien sea Groovy o Java) las llamadas a ese código se envían a través del MOP. Es en esta capa en la que podremos ejecutar o añadir nuestro código para influenciar o modificar la ejecución original.
Otro concepto importante que hay que explicar es el de _MetaClass_ que podemos ver como un registro con los métodos y propiedades de cada clase. Así, cuando Groovy va a ejecutar un método, lo primero que hace es buscarlo en este registro y si existe en él, lo ejecutará y no llamará al existente en la clase. Esto nos permite añadir y modificar el comportamiento de una clase aunque no podamos modificar su código fuente.

Técnicas de Metaprogramación

Vamos a enumerar y explicar con ejemplos las distintas técnicas que nos ofrece Groovy de metaprogramación en runtime.

Modificar el MetaClass

Como hemos comentado si modificamos el MetaClass asociado a una clase, lo que estamos consiguiendo de manera indirecta es modificar el comportamiento de esa clase. Veámoslo con un ejemplo.

String.metaClass.saludar = {
    return "Hola ${delegate}!"
}

def nombre = "Iván"
println nombre.saludar() // Hola Iván!

Estamos modificando la clase java.lang.String que como sabemos es de Java y es final añadiendo el método saludar. En realidad lo que hacemos es añadir al MetaClass ese nuevo método y Groovy, al buscar ahí primero, lo encuentra y lo ejecuta.

Además de añadir un método algo que podemos hacer es sobreescribir uno ya existente. Imaginemos el siguiente caso:

def rnd = new Random()

3.times {
    println rnd.nextInt() // Obtendremos 3 valores aleatorios
}

Random.metaClass.nextInt = {
    return 42
}
3.times {
    println rnd.nextInt() // Obtendremos 42 siempre
}

La ejecución de este fragmento de código sería:

38272508
374716016
1033483625
42
42
42

Como veis se pueden hacer cosas muy potentes y también muy malas con esto, sed buenos ;)

Categories

El principal problema de modificar el MetaClass es que los cambios son persistentes y difíciles de deshacer. Además son cambios globales que afectan a todo nuestro código. Una forma mejor de aplicar cambios al MetaClass es usar _Categories_. La idea es exactamente igual pero los cambios sólo se aplican en el scope de una closure. Una vez ejecutada la closure los cambios al MetaClass se revierten automáticamente.

class Utils {
    static String saludar(String nombre) {
        return "Hola ${nombre}"
    }
}

def nombre = "Iván"
use(Utils) {
    println nombre.saludar()
}

// Fuera de la closure anterior el método saludar() no existe. El siguiente
// código lanza una groovy.lang.MissingMethodException
// println nombre.saludar()

Extension modules

A veces nos puede resultar útil añadir el comportamiento deseado al código sin tener que modificarlo explícitamente. Imaginemos que pudiéramos añadir un .jar al classpath y de esta forma modificasemos ciertas clases según nuestras necesidades. Esto lo podemos hacer con los _Extension modules_.
El truco está en crear un fichero con meta información declarando qué clases queremos modificar y cómo, empaquetarlo todo como un único jar y simplemente añadirlo al classpath y utilizarlo.

package demo
class Utils {
    static String saludar(String nombre) {
        return "Hola ${nombre}"
    }
}
# /src/main/resources/META-INF/services/org.codehaus.groovy.runtime.ExtensionModule
moduleName = string-utils-module
moduleVersion = 0.1
extensionClasses = demo.Utils

Uso:

// Para ejecutar:
// ./gradlew build
// groovy -cp build/libs/string-extensions-1.0.jar ExtensionModule.groovy

// extension_module.groovy
@groovy.transform.CompileStatic
class MiClase {
    void saluda() {
        println "Iván".saludar()
    }
}

def mc = new MiClase()
mc.saluda()

Como veis por el simple hecho añadir al classpath el jar con la clase Utils y el archivo de meta-infomación en el que declaramos el _extension module_, la clase String tiene el método saludar().
Las principales ventajas de esta técnica es que es compatible con @CompileStatic y que sólo hay que añadir una dependencia a nuestro código.

Method missing

Las técnicas que hemos visto hasta ahora pertenecen a la categoría de _method injection_. Esto significa que añadimos nuevos métodos y propiedades al código cuyo nombre conocemos cuando estamos desarrollando y los inyectamos en el código en tiempo de ejecución.
El uso de _method missing_ sin embargo lo que nos permite es capturar al vuelo durante tiempo de ejecución métodos cuyo nombre no conocemos cuando estamos desarrollando el código.

Veámoslo con un ejemplo para que se entienda mejor. Vamos a implementar una clase Coche que contendrá un pequeño DSL para mover y parar el coche. Nos vamos a centrar en la implementación de los distintos métodos del DSL y no en el propio DSL en sí.

class Coche {
    Integer velocidad = 0

    def methodMissing(String name, args) {
        switch(name) {
            case 'avanzar':
                velocidad = args[0]
                break
            case 'acelerar':
                velocidad += args[0]
                break
            case 'parar':
                velocidad = 0
                break
            default:
                throw new MissingMethodException(name, this.class, args)
        }
    }
}

def coche = new Coche()
println "El coche está parado. Velocidad = ${coche.velocidad}"

coche.avanzar(20)
println "El coche avanza a ${coche.velocidad} km/h"

coche.acelerar(50)
println "El coche acelera hasta los ${coche.velocidad} km/h"

coche.parar()
println "El coche está parado. Velocidad = ${coche.velocidad}"

//coche.turbo()
// Esto lanzará MissingMethodException

Si ejecutamos el código se mostrará lo siguiente:

El coche está parado. Velocidad = 0
El coche avanza a 20 km/h
El coche acelera hasta los 70 km/h
El coche está parado. Velocidad = 0

La clave está en implementar en nuestra clase el método methodMissing que será ejecutado cuando un método no exista en la clase y en su implementación podremos hacer lo que queramos. Como veis en este caso se trata de un ejemplo muy sencillo con una implementación muy básica pero se pueden llegar a hacer cosas muy complejas a priori de una manera relativamente sencilla.

Conclusiones

Hemos visto distintas técnicas de metaprogramación en runtime que, en función de nuestro caso de uso, podremos utilizar para resolver un problema en concreto de una manera distinta.
Como comenté al principio se trata de técnicas avanzadas que no se suelen utilizar en el día a día sino que se deben elegir teniendo en cuenta los pros y los contras de cada caso. Si damos con un buen caso de uso podemos hacer que nuestra solución sea más elegante, eficiente y fácil de mantener que con una aproximación más tradicional.

Todos los ejemplos de este post están disponibles en este repo de Github para que podais experimentar con ellos y entender mejor los conceptos. Además, para profundizar más en los detalles el apartado sobre metaprogramación en runtime de la web de Groovy es muy completo.

Comentarios cerrados
Inicio