Kotlin DSL | Conocimiento base para construir DSLs con Kotlin — Parte 2
Características de Kotlin que permiten la construcción de DSLs
Puedes ir a cualquier artículo de esta serie haciendo clic en alguno de los siguientes enlaces:
Kotlin DSL
- Introducción
- Conocimiento base para construir DSLs con Kotlin — Parte 1
- Conocimiento base para construir DSLs con Kotlin — Parte 2
- Código Base: Proyecto Shapes-DSL
- Construcción del DSL: 1 — Organización de paquetes y el objeto ‘Panel’
- Construcción del DSL: 2 — El objeto ‘Cuadrado’
- Construcción del DSL: 3 — Los objetos ‘Triángulo’ y ‘Rombo’
- Construcción del DSL: 4 — Espacios en blanco y el objeto ‘Figura Compuesta’
- Construcción del DSL: 5 — Los operadores ‘plus’ y ‘minus’ y funciones ‘inline’
- Construcción del DSL: 6 — La anotación @DslMarker
- Experimentación y conclusiones
Ésta es la segunda parte del apartado “Conocimiento base para construir DSLs con Kotlin”. En la primera parte abarqué las características de Kotlin que necesariamente se deben conocer para la creación de DSLs:
- Lambdas
- Funciones de orden superior
- Funciones de extensión
- Lambdas con receptor
- Funciones de ámbito
- El patrón de diseño ‘Constructor’
Si desconoces alguno de estos puntos sería ideal que le eches un vistazo antes de continuar con este artículo y con la serie en general. En esta segunda parte explicaré aspectos adicionales de Kotlin que, si bien no son esenciales, son muy útiles para generar un DSL más idiomático. Estos aspectos son los siguientes:
Notación infix
Si tienes experiencia en Kotlin probablemente alguna vez hayas definido una colección de tipo Map
de la forma key to value
o tal vez hayas trabajado con rangos y hayas definido un ciclo for
de la forma for (i in 1 until 100 step 2)
. Las palabras to
en el caso del Map
, until
en el caso del rango y step
en el caso del ciclo for
son funciones definidas que siguen la notación infix.
La notación infix es una forma de llamar a un método omitiendo el punto y los paréntesis que encierran los argumentos de la llamada. Es decir, lo que sería una llamada común a un método, como por ejemplo a.until(b)
, se puede expresar en notación infix como a until b
simplemente marcando la función con el modificador infix
:
En el ejemplo anterior, la función de extensión de String remove
está marcada con el modificador infix
. Esto permite que a través de un String se pueda invocar al método utilizando tanto la notación común — Línea 7 — como la notación infix — Línea 8 — . Básicamente lo que hace la función remove
es remover el caracter enviado como argumento entre paréntesis en la notación común, o bien, el caracter establecido al lado derecho del nombre de la función en la notación infix.
La salida que arroja el ejemplo de código anterior es la siguiente:
Idealmente la notación infix se utiliza para generar un código más idiomático. Su intención es permitir que la escritura de código se acerque al lenguaje natural siendo entonces responsabilidad del desarrollador nombrar los métodos de tal manera que la línea de código que los invoca parezcan estar escritas en prosa. Para ilustrar esto observa el siguiente ejemplo:
En este ejemplo de código defino la clase Car
con su variable miembro speed
que corresponderá la velocidad a la que se mueva el objeto. Defino también la clase enum Direction
con los 4 puntos cardinales. La idea es hacer que el objeto de tipo Car
se mueva en el eje x (Este y Oeste) y el eje y (Norte y Sur). La variable miembro coordinates
indicará el punto en el plano en el que el objeto Car
se encuentra. Además defino una función marcada con el modificador infix
llamada driveTo
que recibe como argumento la dirección a la que se moverá el objeto. Las líneas 25, 27, 29 y 31 corresponden a la notación infix que por medio de instrucciones como car driveTo Direction.North
se invoca al método que realizará la acción de mover el objeto.
El resultado de la ejecución del código anterior es el siguiente:
Por último, para definir funciones infix debes tomar en cuenta las siguientes restricciones:
- El método debe ser una función de extensión o función miembro.
- La función debe tener un solo parámetro.
- El parámetro no debe estar marcado con el modificador
vararg
ni debe tener un valor por defecto.
🔗 Si deseas conocer más sobre la notación infix puedes echarle un vistazo al apartado Infix notation de la documentación oficial de Kotlin en el siguiente enlace: https://kotlinlang.org/docs/functions.html#infix-notation
Sobrecarga de operadores
En programación la sobrecarga de operadores no es otra cosa más que definir un método con un nombre especial o reservado que se corresponde a un operador. Algunos ejemplos de estos operadores pueden ser +
, -
, *
, /
, %
, +=
, <
, >
, etc. Es decir, al usar alguno de estos operadores se invocará al método que definimos para ese operador.
En Kotlin para sobrecargar un operador debemos crear un método cuyo nombre será su palabra reservada y debe ser marcado con el modificador operator
. Además este método debe ser una función miembro o una función de extensión.
Veamos un ejemplo con el operador llamado módulo, remainder o residuo %
:
En el ejemplo anterior sobrecargamos el operador %
que corresponde al método rem
. En este caso el método es una función de extensión de String
con lo cual será posible aplicarle dicho operador a una cadena de texto — Línea 4 — . También se puede usar la notación tradicional llamando al método por su nombre con paréntesis y argumentos — Línea 6 — .
El método rem
anterior aplicado sobre un String lo que hace es devolver true
si el residuo de la división de la longitud de la cadena de texto entre el número entero recibido como argumento, es igual a cero. Aunque pude haber hecho cualquier cosa y devolver lo que se me ocurriera, la idea de sobrecargar un operador es tener operaciones que apelen al sentido común según el operador sobrecargado. Es decir, si por ejemplo sobrecargamos el operador +
aplicado a un objeto X, esperaríamos que el resultado de la operación sea otro objeto de tipo X en el que alguna de sus variables miembro sea equivalente a la suma de las mismas variables miembro de los dos objetos implicados en la operación.
Para ilustrar lo anterior de una mejor manera, observa el siguiente ejemplo en el que tenemos una clase LivingBeing
con atributos developmentCycle
, y sex
. La idea es sobrecargar el operador +
para realizar una combinación de 2 objetos de tipo LivingBeing
y cuyo resultado sea un objeto nuevo de tipo LivingBeing
:
La operación correspondiente al operador sobrecargado +
intenta emular la reproducción de seres vivos. Es decir, la aplicación del operador +
sobre 2 objetos de tipo LivingBeing
retorna como resultado un nuevo objeto de tipo LivingBeing
. Además dentro del método se requiere que el atributo sex
de los 2 objetos implicados sea distinto y que el ciclo de desarrollo de ambos objetos sea igual o mayor que la constante MIN_DEV_CYCLE_TO_REPRODUCE
para poder realizar la operación. Al objeto de tipo LivingBeing
resultante se le asignará aleatoriamente su atributo sex
. Finalmente su atributo developmentCycle
se establecerá en 0.
Como puedes ver sobrecargué el operador +
para realizar una operación análoga a una suma aritmética, vale decir, al “sumar” ambos objetos de tipo LivingBeing
el resultado es un nuevo objeto de tipo LivingBeing
. Sin embargo, la sobrecarga del operador +
no necesariamente debe implicar la creación de un nuevo objeto. Por ejemplo, si hablamos de colores en notación hexadecimal, el operador +
podría sobrecargarse para que el resultado sea la suma de ambos colores implicados: Rojo + Azul = Magenta (#FF0000 + #0000FF = #FF00FF
).
Asegúrate siempre de que las sobrecargas de operadores que hagas tengan un sentido lógico y que así como les das nombres descriptivos a tus variables y métodos según su función, a los operadores les asignes una función acorde a su significado.
Visto lo anterior ya puedes poner en práctica la sobrecarga de estos y todos los demás operadores. A continuación te presento una imagen con los operadores que se pueden sobrecargar en Kotlin y sus correspondientes nombres especiales o reservados:
🔗 Si deseas conocer más sobre la sobrecarga de operadores puedes echarle un vistazo al apartado Operator overloading de la documentación oficial de Kotlin en el siguiente enlace: https://kotlinlang.org/docs/operator-overloading.html
Funciones inline
Al trabajar con funciones de orden superior, el compilador convierte los lambdas en instancias de la interfaz Function#
. Si haces muchas llamadas a la función, se provocarán gastos innecesarios de recursos por la constante creación de objetos e invocación de sus métodos. Para solucionar este problema Kotlin cuenta con el modificador inline
.
El modificador inline
permite una ejecución más eficiente ya que el compilador se encargará de localizar todos los lugares donde se invoca a la función para substituir esa invocación por el código de la función marcada como inline
. Además los lambdas recibidos como parámetros de entrada ya no serán convertidos en objetos, sino que también adquieren la característica inline
.
Ilustraré lo anterior con un ejemplo sencillo:
Dentro de la función main
se llama a la función executeBetweenPrints
la cual imprime la frase "First print"
, luego ejecuta el bloque de código del lambda task: () -> Unit
y finaliza imprimiendo la frase "Last print"
. El bloque de código correspondiente al lambda imprime la frase "My task is executing..."
.
La salida del código anterior es la siguiente:
Si revisas el bytecode que genera el ejemplo anterior y lo descompilas a código en Java, obtendrás un código similar al siguiente (aunque con mucho más “ruido”):
La función executeBetweenPrints
recibe una instancia de la interfaz Function0
y ésta es usada para llamar a la función invoke
la cual contiene lo que contenía el bloque de código enviado como lambda.
Si la función executeBetweenPrints
es llamada unas cuantas veces durante la ejecución del programa, entonces no habría mucho problema en dejarlo así ya que la ganancia sería imperceptible. Sin embargo, si la invocación se diera dentro de un ciclo de muchísimas iteraciones o dentro de algún operador de un Stream de muchísimos elementos, por cada repetición o elemento se crearía un objeto y esto provocaría muchísimo gasto innecesario de recursos en memoria.
Ahora haz la comparación de exactamente el mismo código, pero esta vez marcando la función executeBetweenPrints
como inline
.
Si hacemos exactamente lo mismo que con el ejemplo anterior y descompilamos el bytecode generado a código en Java, obtendríamos un código similar al siguiente:
Al ser substituida la invocación por el código de la función, no se crearán instancias de la interfaz Function0
, por lo tanto, no se da el gasto de recursos que requeriría crear objetos e invocar sus métodos.
En este punto imagino que estarás pensando que sería buena idea marcar absolutamente todas las funciones con el modificador inline
. Bueno, no es del todo así ya que, como mencioné anteriormente, si la función no es llamada de manera frecuente o muy pocas veces, no habría una ganancia significativa en el rendimiento.
Otra cosa que debes tomar en cuenta es el tamaño de la función marcada como inline
ya que si ésta es muy grande entonces el código generado será muy extenso. En términos generales se recomienda que las funciones marcadas como inline
no superen las 4 líneas de código.
Para que tengas una idea general de cuando es beneficioso marcar una función con el modificador inline
puedes tomar en cuenta los siguientes indicadores:
— La función es llamada muchas veces y de manera frecuente.
— La función recibe uno o más lambdas como parámetros de entrada.
— La función no supera las 4 líneas de código.
Si una función cumple con todos los puntos anteriores, considera marcarla como inline
.
Por último, debes saber que algunas veces no es posible marcar una función con el modificador inline
debido a algunas restricciones que se dan, más que todo con el código generado, pero que se pueden solventar con los modificadores noinline
, crossinline
e incluso el modificador reified
; y que dependen mucho del caso de uso en cuestión. Para efectos de esta serie de artículos no será necesario profundizar en ellos ya que no los utilizaremos.
🔗 Si deseas conocer más sobre estos modificadores puedes echarle un vistazo al apartado Inline functions de la documentación oficial de Kotlin en el siguiente enlace: https://kotlinlang.org/docs/inline-functions.html
🔗 También puedes mirar este video en el que Florina Muntenescu explica el modificador
inline
en el siguiente enlace: https://www.youtube.com/watch?v=wAQCs8-a6mg
El patrón/antipatrón de diseño ‘Singleton’
Dentro de los patrones de diseño creacionales existe el patrón de diseño Singleton que genera mucha controversia entre los desarrolladores porque para algunos éste es un patrón de diseño del que se obtiene más daño que beneficio y por ello es también considerado como un antipatrón.
Un objeto Singleton no es más que una clase que se puede instanciar solamente 1 vez, es decir, solamente existirá 1 instancia de dicho objeto durante el ciclo de vida de la aplicación. Este es posiblemente el patrón de diseño más simple y aunque puede resultar muy potente, sin duda también puede resultar muy dañino. Para evitar un mal uso por lo general este patrón de diseño se suele aplicar de manera correcta bajo circunstancias como las siguientes:
— Cuando se desea tener un único punto de acceso hacia alguna fuente de información, como por ejemplo una base de datos o un servicio web.
— Cuando la creación de un objeto resulta muy cara y el sistema requiere de un funcionamiento más dinámico. Esto suele ser muy común en el desarrollo de videojuegos.
— Cuando se necesita de un objeto que nunca cambia y cuyo objetivo es servir como “mensaje” especial o como un objeto de tipo definido dentro de un conjunto de objetos de la misma clase/categoría. Este caso suele ser común cuando se aplica el patrón de diseño ‘Observador’ y se requieren “mensajes” diferentes de una misma clase, comúnmente definidos bajo una clase sellada.
Podrían existir más casos y habría que ver cada uno por separado, pero por lo general la aplicación de este patrón de diseño suele coincidir de proyecto a proyecto, después de todo por algo se hace llamar ‘patrón’.
La creación de un Singleton en Kotlin es muy similar a la creación de una clase, con la diferencia de que en lugar de declararlo con la palabra clave class
, se utiliza la palabra clave object
:
El modificador object
permite crear solamente una instancia de un objeto y éste será creado hasta que sea accedido por primera vez. Para acceder a dicho objeto se debe utilizar su nombre directamente.
Cabe mencionar que un objeto Singleton tiene la posibilidad de heredar de otra clase o implementar interfaces:
🔗 Si deseas conocer más sobre el modificador
object
puedes echarle un vistazo al apartado Object expressions and declarations de la documentación oficial de Kotlin en el siguiente enlace: https://kotlinlang.org/docs/object-declarations.html
Hemos finalizado con las características de Kotlin necesarias para la construcción de DSLs. En el siguiente artículo te presentaré el código inicial sobre el que crearemos un DSL paso a paso.
Continúa en el siguiente artículo con Código Base: Proyecto Shapes-DSL
- Puedes acceder a la documentación oficial sobre notación infix en el siguiente enlace: https://kotlinlang.org/docs/functions.html#infix-notation
- Puedes acceder a la documentación oficial sobre sobrecarga de operadores en el siguiente enlace: https://kotlinlang.org/docs/operator-overloading.html
- Puedes acceder a la documentación oficial sobre funciones inline en el siguiente enlace: https://kotlinlang.org/docs/inline-functions.html
- Puedes acceder a la documentación oficial sobre el modificador
object
en el siguiente enlace: https://kotlinlang.org/docs/object-declarations.html