Tipos función y lambdas
Antes de escribir una lambda hay que saber leer su tipo. Esta lección estudia la notación de los tipos función y su aridad, la diferencia entre declarar el tipo y anotar los parámetros, el literal de lambda con su cuerpo y su última expresión como valor de retorno, el parámetro implícito y las tres condiciones exactas que deben cumplirse para que exista, la convención de sacar la lambda fuera de los paréntesis y por qué existe, y el manejo de lambdas con varios parámetros, con desestructuración y con descartes.
Hay un momento en el aprendizaje de Kotlin en el que una llave abierta deja de parecer un bloque de código y empieza a parecer un valor. Es el momento en que se entiende que { n -> n * 2 } no es una instrucción que el programa ejecuta al pasar por ahí, sino una expresión que produce algo: un objeto que se puede guardar en una variable, pasar como argumento, devolver desde una función y almacenar en una lista. Ese cambio de perspectiva es el que abre el nivel entero. Pero llegar a él por imitación es una trampa cómoda, porque la sintaxis de las lambdas en Kotlin está tan pulida que se puede copiar durante meses sin entenderla, hasta que un día el compilador se queja de que el parámetro implícito no existe o de que hay una ambigüedad entre dos sobrecargas, y no hay nada en la memoria muscular que sirva para responder. La única forma de no llegar ahí es empezar por donde empieza el compilador: por el tipo.
- Leer y escribir tipos función con precisión, incluida la aridad, el retorno
Unity los tipos función nulables. - Distinguir cuándo el tipo se declara en la variable y cuándo hay que anotar los parámetros dentro del literal.
- Enunciar las condiciones exactas bajo las que existe el parámetro implícito y por qué desaparece.
- Aplicar la convención de la lambda final fuera de los paréntesis y manejar lambdas de varios parámetros con desestructuración y descartes.
El tipo antes que el valor
Un tipo función describe una forma de llamada: qué recibe y qué devuelve. Se escribe con la lista de tipos de los parámetros entre paréntesis, una flecha y el tipo del resultado. Así, (Int) -> String es el tipo de cualquier cosa que acepte un entero y produzca una cadena, y (String, String) -> Boolean el de lo que acepte dos cadenas y produzca un booleano. El número de parámetros se llama aridad y forma parte del tipo: (Int) -> Int y (Int, Int) -> Int son tipos distintos e incompatibles, del mismo modo que lo son Int y String.
Dos casos merecen atención inmediata. El primero es el retorno vacío: en Kotlin no existe la ausencia de tipo de retorno, existe Unit, que es un tipo real con un único valor. Una función que solo produce efectos tiene el tipo (Int) -> Unit, y esa Unit debe escribirse siempre; no se puede omitir como se omite en la declaración de una función normal. El segundo es la nulabilidad, donde los paréntesis dejan de ser decorativos: ((Int) -> Int)? es un tipo función que puede ser nulo, mientras que (Int) -> Int? es un tipo función que nunca es nulo pero cuyo resultado sí puede serlo. La diferencia entre los dos es la diferencia entre no tener callback y tener un callback que a veces no encuentra respuesta.
val duplicar: (Int) -> Int = { n -> n * 2 }
val concatenar: (String, String) -> String = { a, b -> a + b }
val registrar: (String) -> Unit = { linea -> println(linea) }
val constante: () -> String = { "sin argumentos" }
val callbackOpcional: ((Int) -> Int)? = null // la funcion puede faltar
val resultadoOpcional: (Int) -> Int? = { null } // la funcion siempre esta
El cuerpo del literal es una secuencia de expresiones y su valor es el de la última. No hay ningún return implicado, y de hecho escribir return dentro de una lambda significa otra cosa muy distinta que se estudia en la cuarta lección de este nivel. La consecuencia práctica es que la última línea de una lambda es siempre significativa: si es una llamada que devuelve algo y la lambda debía devolver Unit, el compilador la aceptará por coerción, pero si la lambda debía devolver un valor y la última línea es una asignación, aparecerá un error que a primera vista parece inexplicable.
De aquí sale la primera regla operativa del nivel, y es una regla sobre dónde vive la información de tipos. Si el tipo está declarado a la izquierda, el compilador puede propagarlo hacia dentro del literal y los parámetros no necesitan anotación. Si no lo está, el literal no tiene de dónde deducirlo y hay que anotarlo dentro. Esta simetría explica casi todos los errores de inferencia que produce un principiante.
val a: (Int) -> Int = { n -> n + 1 } // el tipo viene de la izquierda
val b = { n: Int -> n + 1 } // el tipo se deduce del literal
// val c = { n -> n + 1 } // error: no hay informacion suficiente
El parámetro implícito y sus tres condiciones
Cuando una lambda recibe exactamente un parámetro y el compilador conoce su tipo por el contexto, Kotlin permite omitir tanto el nombre como la flecha y referirse al valor con el identificador it. Es la abreviatura más usada del lenguaje y también la peor entendida, porque casi nadie sabe enunciar cuándo deja de estar disponible. Las condiciones son tres y se cumplen todas o no hay it: la aridad debe ser exactamente uno, el tipo esperado debe conocerse desde fuera del literal, y no se debe haber declarado ningún parámetro explícito. En el momento en que se escribe la flecha con un nombre delante, it desaparece.
flowchart TD
A[Literal de lambda] --> B{Aridad exactamente uno}
B -- No --> N[No hay it: nombra los parametros]
B -- Si --> C{Tipo conocido desde el contexto}
C -- No --> N
C -- Si --> D{Hay flecha con nombre explicito}
D -- Si --> N
D -- No --> E[Existe it]La segunda condición es la que sorprende. Una lambda asignada a una variable sin tipo declarado nunca tiene it, porque el contexto no aporta nada; una lambda pasada como argumento a map sí lo tiene, porque la firma del parámetro dice cuál es el tipo. Por eso listaDeNombres.map { it.length } compila y val f = { it.length } no compila jamás, por mucho que ambas líneas se parezcan.
nombres.map { it.length } // hay it: el tipo viene de map
val f: (String) -> Int = { it.length } // hay it: el tipo viene de la izquierda
// val g = { it.length } // no hay it: nada aporta el tipo
nombres.map { nombre -> nombre.length } // no hay it: se declaro un nombre
nombres.zip(edades) { n, e -> n to e } // no hay it: la aridad es dos
La tercera condición, la de no haber declarado parámetros, tiene una utilidad que suele pasar desapercibida: permite reintroducir un nombre en cuanto el cuerpo crece, sin cambiar nada más de la llamada. Es decir, it y el parámetro nombrado no son dos estilos rivales entre los que haya que elegir de una vez por todas, sino dos puntos de un mismo eje continuo por el que se va avanzando conforme el bloque gana complejidad.
La disponibilidad no es una obligación. Nombra el parámetro cuando el cuerpo tenga más de dos o tres líneas, cuando el valor no sea evidente por el nombre de la función que recibe la lambda, y sobre todo cuando haya lambdas anidadas: en ese caso el it interior oculta al exterior sin ningún aviso del compilador, y el lector no tiene forma de saber a cuál de los dos se refiere cada aparición. Un anidamiento con dos it es correcto para el compilador y engañoso para el humano, que es la peor combinación posible.
La lambda final, fuera de los paréntesis
Kotlin permite escribir la lambda fuera de la lista de argumentos cuando es el último parámetro de la llamada. Si además es el único, los paréntesis vacíos también se omiten. La regla es puramente sintáctica y no cambia nada en la semántica de la llamada, pero sostiene la estética entera del lenguaje: sin ella no existirían ni las funciones de ámbito ni los DSL con receptor, porque nada se parecería a una estructura de control.
// Las tres formas son la misma llamada
val pares1 = numeros.filter({ n -> n % 2 == 0 })
val pares2 = numeros.filter() { n -> n % 2 == 0 }
val pares3 = numeros.filter { n -> n % 2 == 0 }
// Con otros argumentos, la lambda sale fuera y el resto se queda dentro
val texto = numeros.joinToString(separator = " y ") { n -> "el $n" }
// Con dos lambdas, solo la ultima puede salir
val etiqueta = valor.fold(inicial = 0, operacion = { a, b -> a + b })
La razón de que esta convención exista se ve mejor mirando una función propia. Cuando el último parámetro de una función es una lambda, la llamada adopta la apariencia de un bloque del lenguaje, y el diseñador de la API puede elegir deliberadamente ese aspecto colocando la lambda al final de la lista de parámetros. Esa decisión, que parece cosmética, es en realidad una decisión de diseño de API que la quinta lección de este nivel eleva a principio.
fun medir(etiqueta: String, bloque: () -> Unit) {
val inicio = System.nanoTime()
bloque()
println("$etiqueta: ${System.nanoTime() - inicio} ns")
}
medir("ordenacion") {
lista.sorted()
}
Hay una trampa concreta que conviene conocer antes de tropezar con ella. Si una función está sobrecargada y una de las versiones acepta una lambda como último parámetro mientras otra acepta algo distinto, escribir la lambda fuera de los paréntesis puede volver ambigua la resolución. El compilador lo señala, pero el mensaje no siempre es transparente, y la solución es siempre la misma: devolver la lambda al interior de los paréntesis y, si hace falta, nombrar el argumento.
Varios parámetros, desestructuración y descartes
Con dos o más parámetros hay que nombrarlos todos y separarlos por comas antes de la flecha. La anotación de tipo es opcional siempre que el contexto la aporte, que es el caso normal al pasar la lambda como argumento.
val mapa = mapOf("a" to 1, "b" to 2)
mapa.forEach { clave, valor -> println("$clave vale $valor") } // no compila
mapa.forEach { entrada -> println("${entrada.key} vale ${entrada.value}") }
mapa.forEach { (clave, valor) -> println("$clave vale $valor") } // desestructurado
La primera línea falla porque forEach sobre un mapa entrega un único parámetro, la entrada completa, y no dos. Los paréntesis de la tercera línea son desestructuración, no una lista de parámetros: el compilador toma ese único parámetro y le aplica las funciones component1 y component2. Confundir ambas cosas es el error más común del nivel, y la señal para distinguirlas es exactamente ese par de paréntesis.
usuarios.forEachIndexed { indice, usuario -> println("$indice: $usuario") }
usuarios.forEachIndexed { _, usuario -> println(usuario) } // descarte
puntos.forEach { (x, _) -> println(x) } // descarte al desestructurar
mapa.entries.associate { (clave, valor) -> valor to clave }
Sobre la última línea conviene una advertencia de tipos que aparece con frecuencia en el nivel siguiente. Cuando la lambda debe devolver un valor, la última expresión del cuerpo es ese valor, y las expresiones que no producen nada útil dejan al bloque devolviendo Unit. Si el compilador se queja de que esperaba un tipo y encontró Unit, lo primero que hay que mirar no es la firma de la función receptora sino la última línea de la lambda, porque casi siempre allí hay una asignación, un println o un if sin rama alternativa.
El descarte con guion bajo
Cuando un parámetro no se usa, sustituir su nombre por _ evita el aviso del compilador y comunica al lector que la omisión es deliberada. Funciona igual en los parámetros normales y en los componentes de una desestructuración.
La desestructuración es convencional
Solo funciona sobre tipos que declaran operadores componentN, es decir sobre data class, sobre Map.Entry, sobre Pair y sobre cualquier clase que los defina a mano. No es un patrón sobre tuplas porque en Kotlin no hay tuplas.
Conviene detenerse en algo que la comodidad de esta sintaxis oculta muy bien: cada una de las abreviaturas que acabamos de estudiar existe porque el compilador tiene información que el programador no necesita repetir, y cada vez que una de ellas deja de funcionar es porque esa información se ha perdido. El parámetro implícito no es un atajo caprichoso: es la manifestación visible de que el tipo esperado del argumento viaja desde la firma de la función receptora hacia dentro del literal, y por eso desaparece en el instante en que la lambda se escribe sin contexto. La omisión de los tipos de los parámetros no es tolerancia del lenguaje: es propagación de tipos hacia abajo, la misma que permite que map sobre una lista de cadenas sepa que it es una cadena sin que nadie lo escriba. La posibilidad de sacar la lambda fuera de los paréntesis no es azúcar cosmético: es la decisión de hacer que las funciones de orden superior de la librería estándar se lean como construcciones del lenguaje, de modo que repeat, forEach, run o measureTime sean indistinguibles de un bucle o de un bloque, aunque sean llamadas ordinarias a funciones ordinarias. Kotlin no tiene un for mágico y una lambda vulgar; tiene una gramática diseñada para que la frontera entre lo que es palabra clave y lo que es librería resulte invisible en el punto de uso, y esa invisibilidad es intencionada porque desplaza poder del diseñador del lenguaje al diseñador de la librería. La consecuencia para quien escribe código es que la sintaxis de las lambdas no debe memorizarse como una lista de formas permitidas, sino leerse como un informe sobre el estado del conocimiento del compilador: si it está disponible, hay un tipo esperado; si no lo está, no lo hay; si los paréntesis pueden desaparecer, la lambda es el último parámetro. Cada abreviatura disponible es un hecho sobre los tipos, no una preferencia de estilo.
- Escribe cinco valores con los tipos
() -> Unit,(Int) -> Int,(String, Int) -> Boolean,((Int) -> Int)?y(Int) -> Int?, y explica en una frase la diferencia entre los dos últimos. - Coge una lambda tuya que use
ity comprueba cuál de las tres condiciones dejaría de cumplirse si la asignaras a una variable sin tipo declarado. - Localiza un anidamiento con dos
iten tu código o escríbelo, y nombra ambos parámetros. Compara la legibilidad de las dos versiones sin recurrir a la palabra costumbre. - Declara una función propia cuyo último parámetro sea una lambda y llámala en las tres formas sintácticas equivalentes. Justifica cuál elegirías en una API pública.
- Escribe un
forEachsobre un mapa en las tres variantes de la lección y explica por qué una de ellas no compila apelando a la aridad y no a la sintaxis.