Dividiré este apartado en 2 artículos. En esta primera parte mostraré las características de Kotlin que se deben conocer para poder construir un DSL. Estas características son las siguientes:

En la 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:

Si no conoces alguna de las características de Kotlin anteriores, considera echarle un vistazo y leerla con detenimiento antes de continuar.

Lambdas

Lambda es la undécima letra del alfabeto griego. El símbolo utilizado para representar esta letra es el siguiente: Λ o λ. Esta es la pieza más importante en lo que a programación funcional se refiere pues es una representación de los procesos del cálculo funcional en matemática del cual nace este paradigma.

Un lambda en términos generales define un algoritmo, un comportamiento, una acción, un procedimiento, etc. Puedes llamarlo como quieras, sin embargo, si lo observas muy bien, un lambda no es otra cosa más que el cuerpo de una función, por lo tanto, sigue la misma estructura de una función común. En síntesis, un lambda es la definición de un proceso que a partir de entradas específicas devuelve un resultado.

Un lambda en Kotlin se construye con un bloque de código encerrado entre llaves y que además podemos almacenar en una variable:

En el ejemplo anterior se define una variable llamada myLambda que almacena un bloque de código encerrado entre llaves, es decir, un lambda.

Como mencioné anteriormente, un lambda tiene la misma estructura de una función: los datos de entrada y el dato de salida. El ejemplo anterior corresponde a un lambda cuyo tipo de función es () -> Unit o lo que es lo mismo, una función que no recibe parámetros de entrada y que tampoco retorna ningún valor. Debes tomar en cuenta que el valor de retorno de un lambda siempre corresponderá a la última línea o instrucción del bloque de código.

Gracias a que Kotlin infiere el tipo de función al declarar una variable, lo pudimos omitir en el ejemplo anterior, sin embargo, si quisiéramos ser explícitos debemos hacerlo de la siguiente forma:

Observa la primera línea, específicamente la parte que corresponde al tipo de función () -> Unit. Los paréntesis contienen los tipos de datos de los parámetros de entrada y si están vacíos significa que el lambda no recibe ninguno. Luego de la flecha se declara el tipo de dato de retorno y como ya debes saber, un dato de tipo Unit corresponde al tipo de dato por defecto que devuelven todas las funciones que no declaran un valor de retorno.

Si quisiéramos definir un lambda que reciba un número entero y que devuelva su valor al cuadrado, tendríamos que declarar el tipo de función así (Int) -> Int lo cual implica que recibe un parámetro de tipo Int y que devuelve un valor de tipo Int. El código del lambda sería el siguiente:

El bloque de código encerrado entre llaves tiene la misma estructura que el tipo de función que definimos al declarar la variable. Es decir, lo que aparece a la izquierda de la flecha corresponde a los parámetros de entrada (x sería el nombre de la variable). Lo que aparece del lado derecho de la flecha corresponde al algoritmo o procedimiento a realizar utilizando los parámetros de entrada. Recuerda que la última línea o instrucción del bloque de código corresponderá al valor de retorno; en este caso sería la instrucción x * x.

En Kotlin, si un lambda recibe solamente un parámetro, la notación con la flecha puede ser omitida haciendo que el parámetro tome el nombre it por defecto:

Para ejecutar el lambda que almacenamos en una variable, lo hacemos simplemente escribiendo el nombre de la variable agregándole paréntesis para realizar la invocación. Si el lambda recibe parámetros de entrada, debemos ponerlos dentro del paréntesis:

Para finalizar, te mostraré un ejemplo un poco más elaborado para que observes la declaración de un lambda que recibe más de un parámetro de entrada y que requiere de un algoritmo un poco más complejo para obtener el valor de retorno.

El lambda declarado en este caso contará el número de veces que aparece un número específico dentro de una lista de números y para ello recibe dos parámetros de entrada. El primer parámetro corresponde a la lista donde buscará el número y el segundo parámetro corresponde al número a buscar para finalizar retornando el número de veces que encontró dicho número. Cuando se recibe más de un parámetro de entrada, los tipos de datos deben separarse por comas. En este caso el tipo de función quedaría así (List<Int>, Int) -> Int.

Solamente me queda decirte que uses este “poder” con responsabilidad ya que podrías llegar a definir lambdas tan complejos como se te ocurra, por ejemplo lambdas que reciben lambdas que retornan lambdas. Mantén tu código limpio y fácil de leer, recuerda que algún otro desarrollador o tú mismo en el futuro deberá darle mantenimiento y después de un tiempo no recordarás qué hace exactamente el código que escribiste en el pasado.

🔗 Si deseas conocer más acerca de lambdas puedes echarle un vistazo al apartado High-order functions and lambdas de la documentación oficial de Kotlin en el siguiente enlace: https://kotlinlang.org/docs/lambdas.html

Funciones de orden superior

En Kotlin las funciones o métodos son ciudadanos de primera clase. Esto quiere decir que las funciones pueden ser declaradas sin la necesidad de crear una clase que las contenga. Esta cualidad le permite a Kotlin ser considerado como un lenguaje híbrido ya que permite tanto la programación orientada a objetos como la programación funcional.

Una función de orden superior no es otra cosa más que una función que puede recibir otras funciones como parámetros de entrada y que incluso podría retornar otra función.

En el apartado anterior te mostré qué es un lambda y su posibilidad de ser almacenado en una variable. Si lo piensas bien, al asignarle un lambda a una variable podremos hacer con ella lo mismo que hacemos con cualquier otra variable: sobrescribirla si es var, enviarla como parámetro al invocar a una función, devolverla como valor de retorno dentro de una función, almacenarla dentro de alguna colección, etc.

Una función de orden superior luce de la siguiente manera:

Observa el segundo parámetro que recibe la función printAndExecuteTask. Dicho parámetro corresponde a un lambda cuyo tipo de función es () -> Unit . Todo lo que hace la función es imprimir el texto del parámetro text y seguidamente ejecuta el lambda del parámetro task.

Toma en cuenta que así como debe respetarse el tipo de dato y el orden de los parámetros al invocar un método, al tener un lambda como parámetro se debe respetar su tipo de función. Por ejemplo, si el tipo de función del lambda corresponde a () -> Unit entonces no será posible pasar como parámetro un lambda cuyo tipo de función sea (Int) -> Unit ni cualquier otro que no sea idéntico al declarado dentro de la función que queremos invocar.

La invocación de la función anterior la haríamos de la siguiente manera:

Desde la línea 1 hasta la línea 3 se crea un lambda cuyo tipo de función es () -> Unit , idéntico al que recibe la función printAndExecuteTask. En la línea 4 se invoca a la función enviando como primer parámetro, el String "This is my text!" y como segundo parámetro, el lambda anteriormente declarado.

El resultado de la ejecución del código anterior es el siguiente:

Veamos un ejemplo un poco más elaborado. Esta vez tendremos una función de orden superior encargada de ejecutar operaciones matemáticas. La operación la deberá recibir como un lambda cuyo tipo de función será (Int, Int) -> Int. Además del lambda, también recibirá un número base que será el número que le pasará al lambda durante su invocación:

Al ser invocada la función de orden superior calculate primero imprimirá la frase "Starting calculation..." y luego ejecutará el lambda correspondiente al parámetro operation. El valor de retorno de la función será el mismo que retorne el lambda.

La invocación a la función calculate la podemos hacer de la siguiente manera:

El primer lambda — Líneas 1 a 4 — corresponde a la operación multiplicación. El segundo lambda — Líneas 5 a 8— corresponde a la operación suma. De esta manera al hacer la invocación a la función — Líneas 10 y 11 — podemos pasarle cualquiera de estas dos operaciones dado que los tipos de función de ambos lambdas son compatibles con el parámetro operation.

El resultado de la ejecución del código del ejemplo anterior es el siguiente:

En Kotlin, una función de orden superior puede recibir como parámetros de entrada, más de un lambda incluso en combinación con otros tipos de datos, sin embargo, siempre que el último parámetro de entrada sea un lambda, éste se puede sacar afuera de los paréntesis al hacer la invocación. A este tipo de sintaxis se le conoce como trailing lambda:

De esta manera puedes ahorrarte la declaración y asignación del lambda en una variable, aunque también podrías hacer lo mismo sin sacar el lambda de los paréntesis.

Si has trabajado con colecciones entonces conocerás los operadores map , filter , foreach , etc. Todos estos operadores son funciones de orden superior que reciben un lambda como último argumento. Esto es lo que nos permite especificar las acciones a realizar entre llaves:

🔗 Si deseas conocer más acerca de la funciones de orden superior puedes echarle un vistazo al apartado High-order functions and lambdas de la documentación oficial de Kotlin en el siguiente enlace: https://kotlinlang.org/docs/lambdas.html

Funciones de extensión

Por medio de las funciones de extensión, Kotlin nos permite añadirle funcionalidad a cualquier clase, incluso si dicha clase no se puede modificar. Una función de extensión es una función común cuyo nombre lleva como prefijo el nombre de la clase que se quiere extender, conectando ambos con un punto.

Para ilustrar las funciones de extensión utilicemos el siguiente ejemplo práctico:

Imagina que estás desarrollando un juego en el que le presentas al jugador cadenas de texto con los caracteres desordenados aleatoriamente. El objetivo del jugador es ordenar los caracteres para formar las palabras o frases correctas. Con un String podemos hacer muchas cosas como dividirlo con la función split, buscar un caracter con la función find, extraer una subcadena de texto con la función substring, etc., pero no existe una función que desordene o “baraje” los caracteres de la cadena de texto.

A falta de una función que nos sería muy conveniente tener, podemos aprovechar las funciones de extensión e implementar la funcionalidad nosotros mismos sin necesidad de modificar la clase String. De hecho si le echas un vistazo a todas las funciones de String que acabo de mencionar te darás cuenta de que dichas funciones son funciones de extensión.

Continuando con el ejemplo práctico, podemos crear una función de extensión de String que desordene los caracteres de manera aleatoria de la siguiente manera:

Observa que al nombre de la función shuffled le antecede el prefijo String conectados por un punto String.shuffled. Al definir una función de esta manera el resultado es el mismo a que si dentro de la clase String estuviera definida una función llamada shuffled. Hemos aumentado/extendido la funcionalidad de la clase String sin haber modificado la clase y lo mejor de todo es que ya podemos “barajar” nuestros Strings con una sola línea de código que resulta más clara de entender y que podemos aplicar sobre cualquier String en cualquier parte del proyecto:

Con las funciones de extensión podemos agregarle funcionalidad a la clase que deseemos e incluso puedes crear funciones de extensión de interfaces y, aunque parezca innecesario, hasta de tus propias clases.

Ahora analicemos un poco más una función de extensión. En el ejemplo de la función String.shuffled anterior, en la línea 5 puedes ver lo siguiente: sb.append(this[it]) . Aquí lo importante a notar es la referencia this la cual corresponde al receptor — receiver en Inglés — y es la referencia que usamos para acceder a la instancia que invoca a la función tal y como lo haríamos si la función no fuera de extensión y estuviera definida dentro de la clase String.

Supongo que te preguntarás qué pasaría si defines una función de extensión con un nombre que ya se encuentre definido dentro de la clase que quieres extender y con el mismo signature. Si haces eso el compilador no te lanzará un error, ni tampoco se generará un error durante la ejecución, sino que la función que tendrá prioridad al ser invocada será la que está definida dentro de la clase.

Las funciones de extensión, como cualquier otra función, pueden recibir parámetros y retornar cualquier tipo de objeto o no retornar nada. Ahora que conoces esta potente herramienta úsala para crear código más intuitivo, más práctico y menos verboso. Ya no harán falta todas esas clases con funciones auxiliares o de ayuda que solíamos llamar “Utils” y que nunca sabíamos donde definir o poner.

🔗 Si deseas conocer más acerca de las funciones de extensión puedes echarle un vistazo al apartado Extensions de la documentación oficial de Kotlin en el siguiente enlace: https://kotlinlang.org/docs/extensions.html#extension-functions

Lambdas con receptor

Ahora que sabes cómo crear lambdas y funciones de extensión puedes combinar ambas herramientas, el resultado de dicha combinación se conoce como lambda con receptor o tipo de función de extensión.

Tomemos el mismo ejercicio del apartado anterior, pero esta vez en lugar de una función de extensión extraigamos la funcionalidad dentro de un lambda. El lambda deberá recibir un String y retornará otro String que tendrá los mismos caracteres desordenados aleatoriamente. El tipo de función sería el siguiente: (String) -> String.

Como ya vimos en el apartado Lambdas, al omitir la notación de los parámetros y la flecha dentro de los paréntesis cuando se recibe solamente un parámetro de entrada, éste toma el nombre it por defecto. La invocación al lambda se haría de la siguiente manera: val shuffledText = shuffled("This is my text!").

Si queremos crear un lambda con receptor y que ese receptor sea el String recibido como parámetro de entrada, entonces lo sacamos de los paréntesis de la siguiente manera: String.() -> String. Dentro de las llaves la referencia this correspondería al String que invoca al lambda.

En este caso la invocación al lambda se haría de la siguiente manera: val shuffledText = "This is my text!".shuffled().

Un lambda con receptor puede tener solamente un receptor, pero si necesitas pasarle más parámetros lo puedes hacer incluyéndolos entre paréntesis y declarándolos como parámetros de entrada en el tipo de función.

🔗 Si deseas conocer más acerca de lambdas con receptor puedes echarle un vistazo al apartado Function literals with receiver de la documentación oficial de Kotlin en el siguiente enlace: https://kotlinlang.org/docs/lambdas.html#function-literals-with-receiver

Funciones de ámbito

Si observas bien las funciones de extensión o los lambdas con receptor te darás cuenta de que el bloque de código se ejecuta dentro del ámbito correspondiente al objeto que invoca a la función, tal y como sucedería si la función o el lambda fuese una función miembro de una clase. La referencia this se usa para acceder a los métodos y atributos miembros de dicho objeto.

En Kotlin existen 5 funciones de ámbito, construidas sobre lambdas con receptor y funciones de extensión, que nos permiten trabajar dentro del ámbito del objeto que las invoque y cuyo objetivo es que escribamos código menos verboso, más idiomático e intuitivo. Estas 5 funciones son let, run, with, apply, y also.

Para acceder a los métodos y atributos miembros, las funciones with, run y apply lo hacen con la referencia this, mientras que las funciones let y also lo hacen con la referencia it. El valor de retorno de cada función también varía. Las funciones let , run y with retornan el valor que retorna su bloque de código — trailing lambda — . Las funciones apply y also retornan el objeto que invocó a la función.

Veamos un ejemplo con cada función:

  • apply:

Con un objeto de tipo StringBuilder recién creado invoco a la función apply y dentro de su bloque de código — trailing lambda — le añado 3 Strings — Líneas 2, 3, y 4 — . La función apply es perfecta para inicializar las variables miembro de objetos recién creados ya que modifica su estado y retorna el mismo objeto sobre el que se está trabajando. Además, dado que se puede acceder al objeto de tipo StringBuilder con la referencia this , se puede invocar a sus métodos y atributos miembros omitiendo dicha referencia, es decir, a través del receptor implícito — Líneas 3 y 4 — . Esta característica es muy común y muy importante al construir un DSL.

  • run:

En este caso utilizamos el mismo ejemplo anterior, pero esta vez con la función run. La única diferencia respecto a la función apply es que ahora el valor de retorno corresponde a la última línea del bloque de código — Línea 5 — .

  • let:

La función let se comporta exactamente igual que la función run, con la diferencia de que se accede al objeto con la referencia it en lugar de la referencia this.

  • also:

La función also funciona como la función apply devolviendo el mismo objeto que invoca a la función, sin embargo, para acceder a dicho objeto se usa la referencia it en lugar de la referencia this.

  • with:

La función with recibe el objeto como parámetro de entrada y para acceder a él se usa la referencia this. El valor de retorno corresponde a la última línea del bloque de código — Línea 7 — .

🔗 Si deseas conocer más acerca de las funciones de ámbito puedes echarle un vistazo al apartado Scope functions de la documentación oficial de Kotlin en el siguiente enlace: https://kotlinlang.org/docs/scope-functions.html

El patrón de diseño ‘Constructor’

Generalmente cuando tenemos POJOs o data classes con muchas propiedades y además inmutables, la creación de estos objetos se puede volver un poco confusa, especialmente cuando varias de estas propiedades son del mismo tipo haciéndolos propensos a errores ya que es muy fácil que el cambio de lugar de algún parámetro pase totalmente desapercibido. Para evitar esto se suele acudir al patrón de diseño ‘Constructor’ — Builder en Inglés — que actúa como un intermediario que nos permite crear un objeto por partes.

Para ilustrar el funcionamiento de este patrón de diseño, vamos a simular la construcción de platillos, específicamente el desayuno omitiendo las otras comidas del día por simplicidad:

En este caso decidí usar una clase sellada, sin embargo, se podría usar una interfaz o una clase abstracta de la cual hereden todas las comidas del día.

Para crear un objeto de tipo Breakfast es necesario pasarle todos los parámetros al constructor:

El objeto Breakfast necesita de 4 parámetros para ser creado, aunque es muy común encontrarse con objetos muy complejos que incluso necesitan la creación de otros objetos. En este caso los dos primeros parámetros son del mismo tipo y existe la posibilidad de intercambiarlos por error. Cuando un objeto requiere de más parámetros la probabilidad de equivocarse aumenta. Para reducir la probabilidad de ingresar código propenso a errores se suele aplicar el patrón de diseño ‘Constructor’ creando clases intermediarias encargadas de construir los objetos:

Dos aspectos muy importantes a los que les debes poner atención son los siguientes:

  • Contiene los mismos atributos con valores por defecto pero mutables.
  • Los setters retornan el objeto que los invoca para permitirles ser encadenados.

La creación de un objeto utilizando esta clase intermediaria se haría de la siguiente manera:

La creación de un objeto de tipo Breakfast ahora se hace más intuitiva, sin embargo, Kotlin nos permite hacerlo incluso mejor y con menos código:

Hacemos públicos los atributos de tal manera que ya no hace falta crear setters explícitos para establecerles valores distintos a los valores por defecto.

Ahora con ayuda de la función de ámbito apply podemos crear un objeto de tipo Breakfast de la siguiente manera:

No es casualidad que el resultado se asemeje mucho a la creación de objetos dentro de un DSL ya que durante la construcción de un DSL se aplica intensivamente este patrón de diseño.

Continúa en el siguiente artículo con Conocimiento base para construir DSLs con Kotlin — Parte 2

--

--

Glenn Sandoval
Kotlin en Android

I’m a software developer who loves learning and making new things all the time. I especially like mobile technology.