[Número 003] DSLs en Java

Resumen: El diseño e implementación de un Lenguage de Dominio Específico usando el lenguaje Java puede resultar en una tarea ardua y tediosa dadas las limitaciones del lenguaje, sin embargo existen alguna técnicas y trucos que permiten obtener mejores resultados.

Hola de nuevo, después de un período de silencio desde el último número debido a las vacaciones de verano. La 7ma edición de JCrete tomó lugar una vez más a mediados de Julio en la Academia Ortodoxa de Creta (OAC para abreviar en Inglés) en Kolimbari, Creta. Como otras conferencias, JCrete ofrece la oportunidad a cada participante de compartir su pasión y conocimiento, sin embargo JCrete es bastante peculiar y única, ya que permite gozar de la famosa hospitalidad cretense y asi como el ambiente de la isla (las playas!) Es increíble. Este año decidí aplicar un enfoque diferente al evento al realizar entrevistas con los asistentes, así como a dos de los tres fundadores: Dr. Heinz Kabutz y Kirk Pepperdine. Puedes ver todas las entrevistas en el siguiente enlace.

Hubo más de 90 sesiones diferentes durante el tiempo regular, una en particular llamó mi atención que es el tema de ésta edición. La conversación comenzó compartiendo la motivación de la reunión y para imediatamente adentrarnos en la mecánica de escribir un LDS (Lenguaje de Dominio Específico o DSL por sus siglas en Inglés). Como es probable que suceda cuando se toca éste tema, la distinción entre DSL internos y externos surgió temprananamente. Un DSL interno está directamente vinculado a la plataforma/lenguaje anfitrión, en otras palabras, está muy cercano del anfitrión y sigue la mayoría de las reglas del mismo. Un ejemplo de este tipo de DSLs es Bean Shell Framework (BSF), el cual proporciona capacidades de scripting para Java. Por otro lado un DSL externo no está vinculado al lenguaje anfitrión, puede ser un tipo de lenguaje completamente diferente; es mas, los usuarios de este tipo de DSL no se percaten de los detalles de implementación relacionados con el lenguaje anfitrión. Limitamos las opciones sólo a la JVM y a los DSL internos dado que los DSL externos se pueden implementar con cualquier plataforma/lenguaje existente.

Probablemente el siguiente pensamiento te habrá llegado a la cabeza: ¿cómo es posible escribir un DSL intern que en la JVM si el lenguaje Java es tan elaborado en verbosidad y a su vez también limitado en comparación con otros lenguajes alternativos de la JVM? Lenguajes como Scala, Kotlin, y Groovy (mi favorito) fueron mencionados, de nueva cuenta el grupo decidió seguir adelante con el fin de encontrar cómo escribir un DSL sólamente con el lenguaje Java. Y así fué como llegamos a los siguientes puntos:

Dado que el DSL estará vinculado a la semántica de Java, las expresiones del DSL deben seguir el patrón receptor.mensaje () o mensaje(receptor). Un momento, el primer patrón es fácil de entender, después de todo eso es sólo la invocación de un método en una instancia, así que ¿cómo podemos invocar el segundo patrón? La respuesta es a través de métodos estáticos; este es un truco utilizado por Mockito y Awaitility con gran efecto, por ejemplo, el siguiente fragmento de código muestra un stub simple para un servicio que está destinado a ser invocado por un componente de tipo controlador

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.only;
import static org.mockito.Mockito.verify;

HelloService service = mock(HelloService);
when(service.sayHello(input)).thenAnswer(output);
Controller controller = new Controller(service);
controller.doTheStuff(input);
verify(service, only()).sayHello(input);

Los métodos mock(), when() y verify() se utilizan como puntos de entrada para la expresión DSL, con instrucciones adicionales añadidas al resultado de la invocación anterior. Este enfoque es similar (pero no idéntico) al uso de funciones de orden superior en otros idiomas, es decir, la expresión es la composición de valores/receptores (el servicio y el valor de entrada) y las funciones (when y verify, por ejemplo) con otras funciones (only). La combinación de llamadas de función es posible siempre que el siguiente valor de la cadena pueda acceder a la función deseada, lo que no es frecuente cuando se trata de tipos en una jerarquía. Veamos los siguientes tipos como ejemplo

public class Parent {
    public Parent doParentStuff() {
        // ...
        return this;
    }
}

public class Child extends Parent {
    public Child doChildStuff()  {
        // ...
        return this;
    }
}

Child child = new Child();
child.doParentStuff().doChildStuff(); // oops!

El compilador se queja de que doChildMethod() no está disponible en el tipo Parent y con justa razón, sólo está disponible en el tipo Child. Dadas las limitaciones del lenguaje Java, no podemos sobrecargar el método doParentStuff() en el tipo Child y cambiar el valor de retorno a Child en lugar de Parent. En lugar de ello, se podría actualizar el código de Parent para devolver a Child en lugar de Parent, pero eso no funcionaría en todas las situaciones donde existan otros tipos en la jerarquía. Una mejor solución sería que la jerarquía padre/hijo utilice el concepto de self types, el cual lamentablemente no está disponible en el lenguaje, pero puede ser emulado usando tipos genéricos de la siguiente manera

public class Parent<SELF extends Parent<SELF>> {
    protected final SELF self() { return (SELF) this; }

    public SELF doParentStuff() {
        // ...
        return self();
    }
}

public class Child extends Parent<Child> {
    public SELF doChildStuff() {
        // ...
        return self();
    }
}

Child child = new Child();
child.doParentStuff().doChildStuff(); // yay!

Este truco es utilizado por AssertJ/Truth (discutidos en el número 002) para proporcionar un diseño de interfaz fluída a las jerarquías de aserciones que possen. Los próximos enfoques requieren Java 8, ya que se basan en características añadidas a Java en esa versión, es decir, métodos predeterminados (default methods), expresiones lambda y referencias de métodos. Los métodos predeterminados permiten definir detalles de implementación en una interfaz. Si bien esta característica no afecta directamente a la sintaxis de un DSL, si permite una mayor reutilización de código, ya que puede empujar detalles de implementación comunes a una interfaz en lugar de una clase base, otorgando la libertad de extender cualquier otra clase si es necesario. La sintaxis de las expresiones lambda puede parecer extraña para algunos DSLs, pero su poder es innegable, ya que pueden ser utilizadas por los usuarios finales del DSL para definir funciones en línea; la alternativa sería que definieran métodos estáticos por lo que se verían forzados a tener más conocimiento del lenguaje Java. Finalmente, las referencias de métodos pueden ser vistas como atajos para expresiones lambda, y si es cierto que la sintaxis de receptor::mensaje también puede ser vista como extraña en algunos casos, existen casos en los que su uso produce mejores resultados.

Un último punto para redondear las opciones discutidas fue el uso del patrón constructor (Builder). Típicamente los tipods de dominio se expresan en términos de objetos inmutables lo que significa un requerimientopara construir dichas instancias inmutables de manera segura. Los builders proporcionan un mecanismo mutable para construir dichas instancias inmutables. Implementar un builder es bastante sencillo, pero escribir dicho código resulta una tarea árdua; como alternativa tenemos macros en el IDE o la anotación @Builder del Proyecto Lombok para obtener el mismo efecto.

Gracias por tu tiempo. Cualquier comentario es apreciado.

Nos vemos la próxima vez.

Andrés

ˆ Back To Top