Metaprogramación en compile-time con Groovy

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

En el anterior artículo sobre Metaprogramación en runtime con Groovy explicamos qué es la metaprogramación y vimos las distintas técnicas que ofrece Groovy de metaprogramación en runtime.

En esta ocasión lo que vamos a aprender son las distintas posibilidades de metaprogramación en tiempo de compilación. Este tipo de técnicas lo que nos van a permitir es intervenir durante las distintas fases de compilación y, de esta forma, generar código en tiempo de compilación.

Empezamos con la teoría

El compilador de Groovy tiene nueve fases o etapas diferentes que van desde leer los archivos con el código, parsearlos o realizar las comprobaciones de gramática, hasta la escritura final del bytecode. Para realizar la compilación lo que se crea es un _Abstract Syntax Tree_ (AST) en memoria que no es más que un árbol con nodos y hojas que representa nuestro código. Durante las distintas etapas de la compilación, el compilador de Groovy crea nuevos nodos y hojas, los reordena e incluso los elimina. Finalmente el árbol es convertido al bytecode que podremos ejecutar. Estas transformaciones alteran el _AST_ de un programa, es por ello que en Groovy se llaman Transformaciones AST. Las transformaciones AST nos permiten intervenir en la generación de este AST para, finalmente, afectar al bytecode que se genera.

La metaprogramación en compile time permite escribir código que genera bytecode o, al menos, está involucrado durante la generación del mismo.

Espera, ¿voy a escribir bytecode?

No, no vamos a escribir bytecode, vamos a seguir dejando al compilador que se encargue de ello. Lo que vamos a hacer es modificar el AST para añadir nuevos nodos que representen el código que queremos añadir a nuestra clase.

Crear una transformación AST no es una tarea sencilla, requiere bastantes conocimientos de los _internals_ de Groovy y de cómo se representa nuestro código en el AST así que hablaremos de _ConstantExpression_, _AnnotatedNode_, _ClassExpression_,... y todas las clases que utiliza internamente el compilador para representar el código que está compilando.
Por ejemplo, el atributo: public static final String VERSION = '2.0' que veremos en un ejemplo se representa como un _FieldNode_ con un _ConstantExpression_ que se pertenece a un _ClassNode_:

Arbol Ast

Ventajas de las transformaciones AST

Comparada con la metaprogramación en runtime la principal ventaja de esta técnica es que estamos haciendo que nuestros cambios sean _visibles_ a nivel de bytecode. Esto implica que:

  • Si nuestra transformación añade un método a la clase, esos cambios serán visibles si llamamos al método desde Java u otro lenguaje de la JVM. Si utilizásemos metaprogramación en runtime los cambios solamente serían visibles desde Groovy.
  • Al estar la modificación en el bytecode no tendremos el overhead de utilizar Groovy-dinámico por lo que el rendimiento serán mejor.
  • Podemos hacer que la clase implemente un interfaz, extienda de una clase abstracta,... como si realmente escribiéramos ese código.

Tipos de Transformaciones AST

Groovy permite crear dos tipos de transformaciones AST en función de nuestras necesidades.

  • Transformaciones Globales: Las aplica el compilador a todo el código según lo está compilando sin necesidad de marcar o anotar el código que queremos afectar por la transformación. Son por tanto, transparentes al desarrollador puesto que no deberá modificar su código fuente para que la tranformación sea aplicada. Es importante recordar que este tipo de tranformaciones se aplican a todo el código por lo que pueden tener gran impacto en el rendimiento del compilador.

  • Transformaciones Locales: Se aplican de manera local anotando el código al que queremos aplicar la transformación. Probablemente son las más comunes y en nuestro código estamos explicitando nuestras intenciones por el hecho de añadir la anotación. Tienen ciertas restricciones como que no se pueden aplicar en todas las fases del compilador, algo que con las globales sí es posible.

Veamos un ejemplo

Después de tanta teoría vamos a ver un poco de código para intentar entenderlo mejor. Vamos a crear una transformación AST Local @Versionar que va a añadir un atributo estático de tipo String y cuyo valor será el que añadamos en la anotación.
Voy a mostrar fragmentos de código y los voy a explicar. Por no hacer los ejemplos muy largos he quitado todos los imports pero todo el código está disponible en Github.

Con esta anotación lo que queremos hacer es _convertir_ esto:

@demo.Versionar('2.0')
class Foo {
}

en esto (a nivel de bytecode):

class Foo {
    public static final String VERSION = '2.0'
}

1. Crear la anotación

package demo

@Retention(RetentionPolicy.SOURCE)
@Target([ElementType.TYPE])
@GroovyASTTransformationClass("demo.VersionarASTTransformation")
@interface Versionar {
    String value()
}

Se trata de una anotación normal de Java, la única diferencia es que con @GroovyASTTransformationClass indicamos la clase en la que vamos a implementar la transformación AST.

2. Implementar la anotación

@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
class VersionarASTTransformation extends AbstractASTTransformation {

    private static final String VERSION = 'VERSION'

    @Override
    public void visit(final ASTNode[] nodes, final SourceUnit source) {

        // 1. Verificar tipo de los argumentos
        if (!(nodes[0] instanceof AnnotationNode) || !(nodes[1] instanceof ClassNode)) {
            throw new RuntimeException('Sólo podemos usar @Versionar para anotar clases')
        }

        // 2. Verificar que el trabajo no está hecho
        ClassNode classNode = (ClassNode) nodes[1]
        if (classNode.getField(VERSION)) {
            println 'Campo VERSION ya existe'
            return
        }

        // 3. Añadir el campo
        AnnotationNode annotation = (AnnotationNode)nodes[0]
        def version = annotation.getMember('value')
        if (version instanceof ConstantExpression) {
            classNode.addField(VERSION, ACC_PUBLIC | ACC_STATIC | ACC_FINAL, ClassHelper.STRING_TYPE, version)
        } else {
            source.addError(new SyntaxException('Valor no válido para la anotación', annotation.lineNumber, annotation.columnNumber))
        }
    }
}

Como veis ocupa apenas 30 líneas de código que explicaremos con detalle:

  • Tenemos que anotar la implementación con @GroovyASTTransformation indicando en qué fase del compilador queremos aplicarla.
  • Nuestra clase tiene que heredar de AbstractASTTransformation y tenemos que implementar el método visit. El compilador se encargará de ejecutar dicho método durante la compilación del código.
  • Primero (punto 1) debemos comprobar el tipo de lo que estamos anotando es correcto. En nuestro caso estamos comparando que el nodo anotado (nodes[1]) es una clase porque sólo queremos poder usar la anotación a nivel de clase y no de método o atributo.
  • Posteriormente (punto 2) es importante verificar que el código que queremos añadir no existe en la clase. Si no realizamos esta comprobación e intentamos añadir un atributo que ya existe y tendremos un error de compilación.
  • Finalmente (punto 3) obtenemos el valor de la anotación y añadimos el campo VERSION que será _public_, _static_, _final_ y de tipo String.
    En caso de que el valor no sea una constante daremos un error de compilación indicándolo.

Para comprobar que realmente se ha añadido el campo podemos compilar la clase con la AST en el classpath y posteriormente decompilar el .class. Como podeis ver, nuestro campo public static final String VERSION ha sido añadido a nivel de bytecode.

Clase Decompilada

¿Pero esto tiene utilidad?

Probablemente después de haber visto el ejemplo anterior os estareis preguntando si realmente esto tiene utilidad o no, o si compensa todo el esfuerzo. En este caso se trataba de un ejemplo muy sencillo (¿o no tanto?) que sirve de punto de partida para ver el tipo de problemas que se pueden resolver con esta técnica.
Respecto a dónde se usa, por ejemplo en Grails o en Spock. En el primero para _mejorar_ y añadir comportamiento a los controllers, servicios,... y en Spock es la base de su potente DSL.

Resumen

Hemos visto una tecnica avanzada con la que podemos implementar soluciones de una manera totalmente distinta a las más tradicionales. Podemos crear tranformaciones AST que nos permitirán inspeccionar clases para comprobar que son _thread safety_, realizar comprobaciones antes o despues de ejecutar cierto código o incluso no ejecutarlo, sin tener que modificarlo. Es cierto que estas técnicas, al igual que las mostradas sobre metaprogramación en tiempo de ejecución son sobre todo usadas por frameworks y bibliotecas y no se suelen utilizar en el día a día. Aún siendo eso cierto en mi equipo hemos resuelto problemas en algunas ocasiones aplicando estas técnicas de una manera muy eficiente, elegante y transparente.

Si quereis analizar el código en detalle y comprobar cómo se puede testear una AST está todo disponible en este repo de Github.

Para más información recomiendo echar un vistazo a la documentación oficial sobre transformaciones AST de Groovy.

Comentarios cerrados
Inicio