La lambda con receptor
Todo el poder expresivo de los DSLs de Kotlin sale de una sola construcción del sistema de tipos. Esta lección estudia el tipo función con receptor y su notación, la equivalencia exacta con el tipo función ordinario en el runtime y la diferencia irreductible en el punto de llamada, la reescritura de this dentro del bloque y la resolución de miembros y extensiones sin cualificador, la pila de receptores implícitos que aparece al anidar bloques y sus etiquetas, y la razón por la que esta única característica convierte una llamada a función en algo indistinguible de una construcción del lenguaje.
Hay una pregunta que casi nadie se hace al escribir su primer apply, y es de dónde sale el this que aparece dentro del bloque. La respuesta es que no sale de ninguna parte del bloque: viene impuesta desde fuera por la firma del parámetro que lo recibe, es decir, por una decisión que tomó otra persona en otro archivo y que el lector del código no ve. Esa asimetría es incómoda al principio y es exactamente la fuente del poder: quien diseña una función puede decidir cuál es el sujeto de las frases que se escribirán dentro de ella. Kotlin no tiene un mecanismo especial para DSLs, no tiene macros ni sintaxis extensible ni un preprocesador; tiene una sola pieza en el sistema de tipos, el tipo función con receptor, y absolutamente todo lo que parece magia en Gradle, en Compose, en Ktor o en buildString es esa pieza usada con más o menos gusto. Aprenderla bien es aprender el nivel entero de antemano.
- Leer y escribir tipos función con receptor, distinguiendo
A.() -> Bde(A) -> Ben la notación y en el punto de llamada. - Explicar por qué ambos tipos coinciden en el runtime y qué implica esa coincidencia para la interoperabilidad.
- Determinar a qué objeto se refiere
thisdentro de un bloque anidado y desambiguar con etiquetas cuando hay varios receptores implícitos. - Justificar por qué esta característica, y no otra, es la que habilita todos los DSLs del lenguaje.
El punto antes de los paréntesis
Kotlin tiene dos familias de tipos función. La primera es la ordinaria, (A) -> B, que describe algo que acepta un argumento de tipo A y produce un B. La segunda añade un cualificador delante: A.() -> B se lee como el tipo de un bloque que se invoca sobre un A y produce un B. Ese punto minúsculo antes de los paréntesis cambia dos cosas y solo dos: dónde se escribe el valor de tipo A en la llamada, y qué nombre tiene ese valor dentro del cuerpo.
val medirNormal: (StringBuilder) -> Int = { sb -> sb.length }
val medirReceptor: StringBuilder.() -> Int = { length }
val sb = StringBuilder("hola")
medirNormal(sb) // el valor viaja como argumento
sb.medirReceptor() // el valor viaja como receptor
medirReceptor(sb) // tambien valido: la forma de llamada no es exclusiva
Dentro del primer literal hace falta nombrar el parámetro para poder usarlo; dentro del segundo no hay parámetro que nombrar, porque el StringBuilder es el this implícito y length se resuelve contra él sin cualificador alguno. La aridad declarada tampoco es la misma cosa: A.(B) -> C tiene un receptor y un parámetro, de modo que dentro del bloque conviven un this y un it.
En el runtime, sin embargo, la distinción se evapora. Ambos tipos se representan con la misma interfaz de la biblioteca, Function1, y el receptor se compila como el primer parámetro del método invoke. De ahí se sigue una propiedad que sorprende y que la documentación del lenguaje declara explícitamente: los tipos función con receptor y sin receptor son intercambiables, y un valor de uno puede pasarse donde se espera el otro siempre que las listas de tipos coincidan al alinear el receptor con el primer parámetro.
val conReceptor: Int.(Int) -> Int = { otro -> this + otro }
val sinReceptor: (Int, Int) -> Int = conReceptor // conversion aceptada
println(3.conReceptor(4)) // 7
println(sinReceptor(3, 4)) // 7
La conclusión es importante y suele malinterpretarse: el receptor no es una capacidad nueva del runtime ni una entidad distinta de un parámetro. Es una convención de resolución de nombres que existe únicamente durante la compilación. Todo el nivel se apoya en algo que, mirado desde abajo, es un simple reordenamiento de argumentos.
La reescritura de this dentro del bloque
Lo que el receptor sí cambia de verdad es el ámbito léxico del cuerpo. Dentro del bloque, el objeto receptor pasa a ser un receptor implícito: sus miembros públicos, y también las funciones de extensión declaradas sobre su tipo, quedan disponibles como si el código estuviera escrito dentro de la clase. Esa es la razón por la que un DSL se lee como una lista de declaraciones sueltas en lugar de como una cadena de llamadas sobre una variable.
La diferencia entre las funciones de ámbito de la biblioteca estándar, que tanto cuesta memorizar, se disuelve en cuanto se miran sus firmas: la única variación real es si el parámetro es un tipo función con receptor o sin él.
inline fun <T, R> T.let(bloque: (T) -> R): R = bloque(this)
inline fun <T, R> T.run(bloque: T.() -> R): R = bloque()
inline fun <T> T.apply(bloque: T.() -> Unit): T { bloque(); return this }
inline fun <T, R> with(receptor: T, bloque: T.() -> R): R = receptor.bloque()
let entrega el objeto como argumento, y por eso dentro se usa it; run lo entrega como receptor, y por eso dentro se usa this o directamente nada. No hay ninguna otra distinción conceptual entre las dos, y quien haya intentado aprenderlas como cuatro reglas independientes estaba memorizando consecuencias en lugar del principio.
La resolución dentro del bloque no se limita a los miembros declarados en la clase. Las funciones de extensión visibles en el punto de llamada también participan, lo cual significa que el vocabulario disponible dentro de un bloque con receptor puede ampliarse desde un módulo que el autor del tipo nunca conoció. Esa apertura es la que permite que un DSL crezca sin tocar sus clases y la que explica que el mismo bloque ofrezca palabras distintas según qué importaciones haya en el archivo.
fun StringBuilder.linea(texto: String) { append(texto).append('\n') }
val informe = buildString {
append("Cabecera") // miembro de StringBuilder
linea(": ok") // extension declarada fuera de la clase
}
Usa el receptor cuando el bloque va a hablar sobre todo del objeto: configurarlo, rellenarlo, construirlo. Usa el argumento cuando el bloque va a usar el objeto como un dato más entre otros, cuando su nombre aporta información al lector, o cuando ya existe otro receptor implícito en el ámbito y añadir uno segundo volvería ambigua la lectura. La pregunta operativa no es cuál es más corto, sino si dentro del bloque el objeto es el sujeto de la frase o su complemento.
La pila de receptores implícitos
Cuando un bloque con receptor contiene otro bloque con receptor, no se sustituye un this por otro: se apilan. En el punto más interno hay varios receptores implícitos vigentes a la vez, y el compilador resuelve cada nombre buscando primero en el más cercano y ascendiendo hasta encontrar un candidato adecuado. El this desnudo se refiere siempre al más interno; para alcanzar a los demás hay que cualificarlo con una etiqueta, que por defecto es el nombre de la función que introdujo ese receptor.
flowchart TD
A[Ambito de la funcion externa] --> B[Receptor introducido por el bloque exterior]
B --> C[Receptor introducido por el bloque interior]
C --> D{Resolucion de un nombre}
D -- Existe en el receptor interior --> E[Se usa el interior]
D -- No existe en el interior --> F[Se busca en el exterior]
F -- Tampoco existe --> G[Se busca en el ambito lexico envolvente]class Externo {
val nombre = "externo"
fun construir(bloque: Interno.() -> Unit) = Interno().apply(bloque)
}
class Interno {
val nombre = "interno"
fun describir() {
println(this.nombre) // interno: el receptor mas cercano
}
}
fun uso(e: Externo) = with(e) {
construir {
println(nombre) // interno: gana el mas cercano
println(this@with.nombre) // externo: cualificado con etiqueta
}
}
Aquí conviven dos hechos que conviene separar. El primero, que el ocultamiento por cercanía es intencionado y necesario: sin él, cada bloque anidado tendría que cualificar todo. El segundo, que ese mismo ocultamiento es silencioso, y que cuando el receptor exterior tiene miembros que el interior no tiene, esos miembros siguen siendo visibles desde dentro sin ninguna marca. La tercera lección de este nivel está dedicada por completo a esa segunda mitad, porque es donde los DSLs anidados se vuelven peligrosos.
De una firma a un lenguaje
Reunidos todos los ingredientes, se ve por qué basta con esta característica. La convención de la lambda final fuera de los paréntesis hace que la llamada pierda su apariencia de llamada. El receptor hace que dentro del bloque el vocabulario cambie y solo estén cerca los nombres pertinentes. El modificador inline elimina la asignación de objetos y permite retornos no locales, de modo que no hay penalización por escribir así. Y la resolución de extensiones sobre el receptor permite ampliar ese vocabulario desde fuera de la clase.
class Documento {
private val partes = mutableListOf<String>()
fun titulo(texto: String) { partes += "# $texto" }
fun parrafo(texto: String) { partes += texto }
override fun toString() = partes.joinToString("\n")
}
fun documento(bloque: Documento.() -> Unit): Documento = Documento().apply(bloque)
val d = documento {
titulo("Informe")
parrafo("Primera observacion")
}
Ninguna línea de ese bloque es sintaxis nueva. titulo y parrafo son métodos ordinarios de una clase ordinaria, invocados sobre un receptor que nadie escribió porque lo puso la firma del parámetro. Lo único que hizo el diseñador fue elegir el tipo Documento.() -> Unit en lugar de (Documento) -> Unit, y esa elección de un solo carácter es la diferencia entre una API y un lenguaje.
El receptor es resolución, no representación
En el bytecode el receptor es el primer parámetro. Toda la diferencia vive en el compilador: dónde se escribe el valor y qué nombres quedan al alcance dentro del cuerpo.
La firma decide el vocabulario
Quien declara el parámetro decide qué puede escribirse sin cualificar dentro del bloque. Diseñar un DSL es, literalmente, diseñar el conjunto de nombres visibles en cada nivel de anidamiento.
Merece la pena detenerse en la naturaleza de lo que acaba de ocurrir, porque es más profundo que una comodidad sintáctica. En la mayoría de los lenguajes, el conjunto de nombres que un programador puede escribir sin cualificar en un punto dado del archivo está fijado por reglas del lenguaje: los locales, los miembros de la clase que contiene el método, los importados. Ese conjunto es global respecto al archivo y estable a lo largo de él, y por eso todo código de un mismo fichero se lee con el mismo diccionario. El tipo función con receptor rompe esa estabilidad de forma deliberada y controlada: convierte el diccionario disponible en una función de la posición sintáctica, de modo que dentro de un bloque las palabras pertinentes están cerca y las impertinentes no existen. Eso es exactamente lo que hace un lenguaje de dominio específico, y la diferencia con los enfoques clásicos es que aquí no hay un intérprete que lea cadenas, ni una gramática aparte, ni una fase de generación de código: hay tipado estático completo, autocompletado real, navegación al origen, refactorización segura y errores en tiempo de compilación, porque cada palabra de ese idioma es una función de verdad que existe en un tipo de verdad. La consecuencia estratégica es que en Kotlin la decisión de si algo es sintaxis o es biblioteca deja de pertenecer al equipo del lenguaje y pasa al autor de la biblioteca; y la consecuencia ética, que es la que interesa a partir de aquí, es que ese poder trae la responsabilidad de decidir qué nombres se ponen al alcance en cada nivel. Un DSL bien diseñado es un ámbito donde solo es escribible lo que tiene sentido escribir. Un DSL mal diseñado es un ámbito donde el usuario alcanza cosas que no debería y el compilador no protesta, y ese fallo, como veremos, no se corrige con documentación sino con tipos.
- Escribe la misma operación con
let,run,applyywith, y explica cada diferencia apelando exclusivamente a la firma del parámetro, sin mencionar la costumbre ni el estilo. - Declara un valor de tipo
Int.(Int) -> Int, invócalo en las dos formas posibles y asígnalo después a una variable de tipo(Int, Int) -> Int. Justifica por qué el compilador lo acepta. - Anida dos bloques con receptor cuyos tipos tengan una propiedad con el mismo nombre y predice qué imprime cada acceso antes de ejecutarlo. Corrige tu predicción si falla y enuncia la regla.
- Toma una clase tuya con tres o cuatro llamadas de configuración encadenadas y añade una función de nivel superior que reciba un
T.() -> Unit. Compara ambas versiones desde el punto de vista de quien las lee sin conocer la clase. - Argumenta, con la firma en la mano, por qué
buildStringpuede aceptar un bloque donde se escribeappendsin sujeto, y qué tendría que cambiar en su declaración para que hiciera falta escribirlo.