Lambdas y funciones de extensión en Kotlin

Lección 6 de 8. En Kotlin las funciones son valores: se guardan en variables, se pasan a otras funciones y se devuelven. Con eso se escriben las lambdas, que hacen el trabajo de casi todos los bucles sobre colecciones (map, filter, groupBy…), y con las funciones de extensión puedes añadir métodos a clases que no son tuyas, como String. Juntas explican por qué el código Kotlin se ve tan distinto del de Java.

Funciones como valores: lambdas

Una lambda es una función sin nombre escrita entre llaves. Su tipo se escribe con flechas: (Int) -> Int es «recibe un Int y devuelve un Int». Si la lambda tiene un solo parámetro, no hace falta nombrarlo: se llama it.

fun main() {
    val doble: (Int) -> Int = { n -> n * 2 }
    val triple: (Int) -> Int = { it * 3 }
    val suma = { a: Int, b: Int -> a + b }
    val saludo: () -> String = { "Hola" }

    println(doble(5))
    println(triple(5))
    println(suma(2, 3))
    println(saludo())
}
salida10
15
5
Hola

La última expresión de la lambda es lo que devuelve; no se escribe return. Además de lambdas, puedes pasar una función que ya existe con una referencia, ::nombre:

fun esPar(n: Int) = n % 2 == 0

fun main() {
    val numeros = listOf(1, 2, 3, 4, 5, 6)
    println(numeros.filter(::esPar))
    println(listOf(5, 42, 1000).map(Int::toString).map(String::length))
    println(listOf("ana", "luis").map(String::uppercase))
}
salida[2, 4, 6]
[1, 2, 4]
[ANA, LUIS]

La lambda va fuera de los paréntesis

Si el último parámetro de una función es otra función, la lambda se escribe después de los paréntesis, y si es el único parámetro, los paréntesis desaparecen. Por eso verás lista.filter { it > 2 } y no lista.filter({ it > 2 }), aunque las dos formas compilan. Es la misma sintaxis que hace que repeat(3) { ... } parezca una instrucción del lenguaje, cuando es una función normal de la biblioteca estándar:

fun main() {
    repeat(3) { vuelta ->
        println("Vuelta $vuelta")
    }
    val largos = listOf("sol", "montaña", "río", "carretera").filter { it.length > 4 }
    println(largos)
}
salidaVuelta 0
Vuelta 1
Vuelta 2
[montaña, carretera]

Funciones de colección

Con lambdas, recorrer listas se vuelve una cadena de pasos con nombre. Para los ejemplos de esta sección usamos una lista de productos de una tienda; la data class y la lista están al final de cada programa y no las repetimos:

data class Producto(val nombre: String, val categoria: String, val precio: Double, val stock: Int)

val productos = listOf(
    Producto("Teclado", "Periféricos", 499.0, 12),
    Producto("Mouse", "Periféricos", 249.0, 0),
    Producto("Monitor", "Pantallas", 3299.0, 4),
    Producto("Laptop", "Equipos", 15999.0, 2),
    Producto("Webcam", "Periféricos", 899.0, 7),
    Producto("Tableta", "Equipos", 5499.0, 0),
)
fun main() {
    val agotados = productos.filter { it.stock == 0 }.map { it.nombre }
    println("Agotados: $agotados")

    val baratos = productos
        .filter { it.precio < 1000 }
        .sortedBy { it.precio }
        .map { "${it.nombre} ($${it.precio})" }
    println(baratos)

    println("¿Hay algo de más de 10,000? " + productos.any { it.precio > 10_000 })
    println("¿Todo tiene nombre? " + productos.all { it.nombre.isNotBlank() })
    println("Con existencias: " + productos.count { it.stock > 0 })
}
salidaAgotados: [Mouse, Tableta]
[Mouse ($249.0), Teclado ($499.0), Webcam ($899.0)]
¿Hay algo de más de 10,000? true
¿Todo tiene nombre? true
Con existencias: 4

Para sumar, contar y agrupar:

fun main() {
    val valorInventario = productos.sumOf { it.precio * it.stock }
    println("Valor del inventario: $valorInventario")

    val porCategoria = productos.groupBy { it.categoria }
    for ((categoria, lista) in porCategoria) {
        println("$categoria: ${lista.map { it.nombre }}")
    }

    val masCaro = productos.maxBy { it.precio }
    println("El más caro: ${masCaro.nombre}")

    val (conStock, sinStock) = productos.partition { it.stock > 0 }
    println("${conStock.size} con existencias, ${sinStock.size} sin existencias")

    val porNombre = productos.associateBy { it.nombre }
    println(porNombre["Monitor"]?.precio)
}
salidaValor del inventario: 57475.0
Periféricos: [Teclado, Mouse, Webcam]
Pantallas: [Monitor]
Equipos: [Laptop, Tableta]
El más caro: Laptop
4 con existencias, 2 sin existencias
3299.0

Y fold es la herramienta general: parte de un valor inicial y lo va combinando con cada elemento. joinToString arma un texto con separador:

fun main() {
    val unidades = productos.fold(0) { acumulado, p -> acumulado + p.stock }
    println("Unidades en bodega: $unidades")

    val texto = productos.take(3).joinToString(separator = ", ", prefix = "[", postfix = "]") { it.nombre }
    println(texto)

    val primeroCaro = productos.firstOrNull { it.precio > 50_000 }
    println(primeroCaro ?: "Nada cuesta más de 50,000")
}
salidaUnidades en bodega: 25
[Teclado, Mouse, Monitor]
Nada cuesta más de 50,000
  • ParaUsaDevuelve
  • Quedarte con algunosfilter { }, filterNotuna lista nueva
  • Transformar cada unomap { }, mapNotNulluna lista nueva
  • OrdenarsortedBy, sortedByDescending, sortedWithuna lista nueva (la original no cambia)
  • AgrupargroupBy { }un Map de clave a lista
  • Sumar o contarsumOf, countun número
  • Preguntarany, all, noneun Boolean
  • Buscar unofirst, firstOrNull, maxByun elemento (o null)
  • Combinar todofold, reduceun valor

Si vienes de Java: sin stream() ni collect()

En Java, filter y map viven en los Stream: hay que abrir el stream con .stream() y cerrarlo con .collect(...) o .toList(). En Kotlin son funciones de extensión de las propias colecciones y devuelven ya una List. La contrapartida es que cada paso crea una lista intermedia; cuando eso importa, se usa Sequence, que se comporta como un stream de Java. Lo comparamos en agrupar con streams en Java.

Sequence: cuando conviene ir uno a uno

Las funciones de las listas son ansiosas: cada paso recorre la lista completa y crea otra. Una Sequence es perezosa: cada elemento pasa por toda la cadena antes de leer el siguiente, y no se hace nada hasta que pides el resultado. Mira el orden en que se imprimen los pasos:

fun main() {
    val numeros = listOf(1, 2, 3, 4, 5, 6)

    println("Con List:")
    val a = numeros
        .filter { println("  filter $it"); it % 2 == 0 }
        .map { println("  map $it"); it * 10 }
        .take(2)
    println(a)

    println("Con Sequence:")
    val b = numeros.asSequence()
        .filter { println("  filter $it"); it % 2 == 0 }
        .map { println("  map $it"); it * 10 }
        .take(2)
        .toList()
    println(b)
}
salidaCon List:
  filter 1
  filter 2
  filter 3
  filter 4
  filter 5
  filter 6
  map 2
  map 4
  map 6
[20, 40]
Con Sequence:
  filter 1
  filter 2
  map 2
  filter 3
  filter 4
  map 4
[20, 40]

Arriba, una List aplica filter a los seis números, crea una lista nueva, luego aplica map a esa lista y al final take(2). Abajo, una Sequence lleva cada número por filter y map uno a uno: el 1 no pasa, el 2 pasa y se convierte en 20, el 3 no pasa, el 4 pasa y se convierte en 40; con dos resultados take(2) se detiene y el 5 y el 6 nunca se leenLa misma cadena: List termina cada paso, Sequence va elemento a elementoList (ansiosa)filtermaptake(2)filter recorre los 6 números y crea una lista nueva;map recorre esa lista y crea otra; take corta al final.Sequence (perezosa)1no pasa2pasamap: 203no pasa4pasamap: 40Con dos resultados, take(2) se detiene: 5 y 6 nunca se leen.

Con una List, cada paso termina antes del siguiente; con una Sequence, cada elemento recorre la cadena completa y take(2) puede parar antes. Esquema: decodigo.com.

Con la lista se hicieron 6 llamadas a filter y 3 a map; con la secuencia, 4 y 2. Para seis números da igual, pero con millones de elementos, o con una fuente que no termina, la diferencia es enorme. Una secuencia puede ser infinita:

fun main() {
    val potencias = generateSequence(1) { it * 2 }
    println(potencias.take(10).toList())
    println(potencias.first { it > 1000 })
}
salida[1, 2, 4, 8, 16, 32, 64, 128, 256, 512]
1024

Regla práctica: usa listas normales casi siempre (son más rápidas con pocos datos) y asSequence() cuando la cadena es larga, los datos son muchos o puedes cortar pronto con take o first.

Funciones que reciben o devuelven funciones

Una función que recibe otra función, o la devuelve, se llama de orden superior. Escribir las tuyas es igual de sencillo que usarlas:

fun medir(etiqueta: String, accion: () -> Int): Int {
    val resultado = accion()
    println("$etiqueta dio $resultado")
    return resultado
}

fun multiplicador(factor: Int): (Int) -> Int = { it * factor }

fun main() {
    medir("La suma") { (1..100).sum() }

    val porTres = multiplicador(3)
    val porDiez = multiplicador(10)
    println(porTres(7))
    println(listOf(1, 2, 3).map(porDiez))
}
salidaLa suma dio 5050
21
[10, 20, 30]

La lambda que devuelve multiplicador «recuerda» el factor con el que se creó: es un closure. A diferencia de Java, en Kotlin una lambda también puede modificar una variable de fuera:

fun main() {
    var contador = 0
    val palabras = listOf("uno", "dos", "tres", "cuatro")
    palabras.forEach { if (it.length > 3) contador++ }
    println("Palabras largas: $contador")
}
salidaPalabras largas: 2

Si vienes de Java: variables «effectively final»

En Java, una lambda solo puede leer variables locales que nunca cambian («effectively final»); el contador++ de arriba sería un error de compilación y habría que usar un AtomicInteger o un arreglo de un elemento. Kotlin lo permite porque envuelve la variable en un objeto por ti. Cómodo, pero cuidado si varias corrutinas o hilos la modifican a la vez: ahí sí hace falta sincronizar (lo verás en la lección 7).

inline y el return que sale de la función

Pasar una lambda tiene un costo: se crea un objeto. Las funciones marcadas inline, como forEach, map o filter, copian el código de la lambda en el lugar de la llamada y ese costo desaparece. Hay un efecto secundario que sorprende: dentro de la lambda de una función inline, un return a secas sale de la función que la contiene, no solo de la lambda. Para salir solo de la vuelta actual se usa una etiqueta, return@forEach:

fun buscarConReturn(nombres: List<String>) {
    nombres.forEach {
        if (it == "Luis") return
        println("Reviso $it")
    }
    println("Fin de buscarConReturn")
}

fun buscarConEtiqueta(nombres: List<String>) {
    nombres.forEach {
        if (it == "Luis") return@forEach
        println("Reviso $it")
    }
    println("Fin de buscarConEtiqueta")
}

fun main() {
    val nombres = listOf("Ana", "Luis", "Eva")
    buscarConReturn(nombres)
    println("---")
    buscarConEtiqueta(nombres)
}
salidaReviso Ana
---
Reviso Ana
Reviso Eva
Fin de buscarConEtiqueta

La primera función nunca escribe «Fin»: el return salió de buscarConReturn completa. Con la etiqueta, solo se saltó a Luis, como un continue.

Funciones de extensión

Una función de extensión añade un método a un tipo que ya existe, sin heredar de él ni tocar su código. Se escribe con el nombre del tipo delante del nombre de la función, y dentro, this es el objeto sobre el que se llama:

fun String.esPalindromo(): Boolean {
    val limpio = this.lowercase().filter { it.isLetter() }
    return limpio == limpio.reversed()
}

fun Int.esPar() = this % 2 == 0

val String.iniciales: String
    get() = split(" ").filter { it.isNotEmpty() }.joinToString("") { it.first().uppercase() }

fun main() {
    println("Anita lava la tina".esPalindromo())
    println("Kotlin".esPalindromo())
    println(7.esPar())
    println("maría fernanda lópez".iniciales)
}
salidatrue
false
false
MFL

La última es una propiedad de extensión: se lee como un campo, pero se calcula cada vez. Buena parte de la biblioteca estándar de Kotlin son extensiones sobre las clases de Java: filter, map o reversed() no existen en java.util.List ni en java.lang.String; Kotlin las añade así.

Una extensión también puede declararse sobre un tipo que acepta null, y entonces se puede llamar sin ?.. Así funcionan isNullOrBlank() y compañía:

fun String?.oGuion(): String = if (this.isNullOrBlank()) "-" else this

fun main() {
    val telefono: String? = null
    val correo: String? = "ana@example.com"
    println(telefono.oGuion())
    println(correo.oGuion())
}
salida-
ana@example.com

Lo que una extensión no puede hacer

Una extensión no se mete dentro de la clase: es una función normal que recibe el objeto como primer parámetro. Por eso solo ve lo público, y por eso se elige según el tipo declarado de la variable, no según el objeto real (no hay polimorfismo):

open class Animal
class Perro : Animal()

fun Animal.nombre() = "animal"
fun Perro.nombre() = "perro"

fun main() {
    val a: Animal = Perro()
    val p = Perro()
    println(a.nombre())
    println(p.nombre())
}
salidaanimal
perro

El objeto es un Perro en los dos casos, pero a está declarada como Animal y el compilador eligió la extensión de Animal. Si quieres que dependa del objeto real, el método va dentro de la clase con open y override, como en la lección 5. Y si la clase ya tiene un método con el mismo nombre y parámetros, gana siempre el de la clase; el compilador te avisa:

class Cuenta(val saldo: Double) {
    fun describir() = "Cuenta con $saldo"
}

fun Cuenta.describir() = "Esta extensión nunca se usa"

fun main() {
    println(Cuenta(150.0).describir())
}
kotlincextension_sombra.kt:5:12: warning: this extension is shadowed by a member: 'fun describir(): String' defined in 'Cuenta'.
fun Cuenta.describir() = "Esta extensión nunca se usa"
           ^^^^^^^^^
salidaCuenta con 150.0

El error que te ahorra: una extensión que quiere leer lo privado

class Cuenta(private val saldo: Double)

fun Cuenta.enDolares() = saldo / 18.5

fun main() {
    println(Cuenta(1850.0).enDolares())
}
kotlincextension_privado.kt:3:26: error: cannot access 'val saldo: Double': it is private in 'Cuenta'.
fun Cuenta.enDolares() = saldo / 18.5
                         ^^^^^

Si te hace falta lo privado, la función va dentro de la clase. La extensión es para lo que se puede hacer desde fuera con la parte pública.

Funciones de alcance: let, run, apply, also y with

Son cinco funciones de la biblioteca estándar que ejecutan un bloque de código con un objeto. Se parecen mucho y la diferencia está en dos detalles: si dentro del bloque el objeto se llama it o this, y si el bloque devuelve su último valor o el propio objeto.

Cuadro de dos por dos. Con el objeto como it: let devuelve el resultado del lambda y also devuelve el mismo objeto. Con el objeto como this: run y with devuelven el resultado y apply devuelve el mismo objetoFunciones de alcance: cómo llamas al objeto y qué devuelvendevuelve el resultadodevuelve el mismo objetoel objeto es itletalsoel objeto es thisrun, withapplyRegla práctica: apply para configurar, also para un efecto, let con ?. para null.

Las cinco funciones de alcance en un cuadro: arriba, cómo te refieres al objeto; a la derecha, qué devuelve la llamada. Esquema: decodigo.com.
data class Pedido(var cliente: String = "", var total: Double = 0.0, var pagado: Boolean = false)

fun main() {
    // apply: configurar un objeto recién creado (devuelve el objeto)
    val pedido = Pedido().apply {
        cliente = "Ana"
        total = 1299.99
    }

    // also: un efecto de paso, sin cambiar la cadena (devuelve el objeto)
    val registrado = pedido.also { println("Registrando pedido de ${it.cliente}") }

    // let: transformar, muy usado con ?. (devuelve el resultado)
    val correo: String? = "ana@example.com"
    val dominio = correo?.let { it.substringAfter("@") }

    // run: varios cálculos con el objeto como this (devuelve el resultado)
    val conIva = pedido.run { total * 1.16 }

    // with: igual que run, pero recibe el objeto como argumento
    val resumen = with(pedido) { "$cliente debe $total" }

    println(registrado === pedido)
    println(dominio)
    println("%.2f".format(conIva))
    println(resumen)
}
salidaRegistrando pedido de Ana
true
example.com
1507.99
Ana debe 1299.99
  • FunciónEl objeto esDevuelveUso típico
  • letitel resultado del bloquecon ?.: hacer algo solo si no es null
  • runthisel resultado del bloquecalcular algo con varias propiedades
  • with(x)thisel resultado del bloquelo mismo que run, con el objeto como argumento
  • applythisel objetoconfigurar un objeto al crearlo
  • alsoitel objetoregistrar o validar sin romper la cadena

No abuses de ellas: tres funciones de alcance anidadas, con it y this mezclados, son más difíciles de leer que tres líneas normales.

Funciones infix

Una función de un solo parámetro marcada infix se puede llamar sin punto ni paréntesis. Ya la usaste sin saberlo: to, el que arma los pares de mapOf("a" to 1), es una función infix. Puedes escribir las tuyas:

infix fun Int.veces(texto: String) = texto.repeat(this)

fun main() {
    println(3 veces "ja")
    val par = "México" to "CDMX"
    println(par)
    println(par.first)
}
salidajajaja
(México, CDMX)
México

Resumen

  • ParaEscribeRecuerda
  • Una lambda{ x -> x * 2 } o { it * 2 }lo último que se evalúa es lo que devuelve
  • Pasar una función existentelista.filter(::esPar)referencia con ::
  • Transformar una listamap, filter, groupBy, folddevuelven listas nuevas
  • Muchos datos o cortar prontoasSequence()perezosa: nada se hace hasta toList()
  • Añadir un método a un tipo ajenofun String.algo()solo ve lo público y no es polimórfica
  • Configurar un objetoObjeto().apply { ... }devuelve el objeto
  • Algo solo si no es nullx?.let { ... }devuelve el resultado o null

Retos

Reto 1. Con la lista de productos de esta lección, escribe el nombre de cada categoría con el precio promedio de sus productos, ordenadas de la más cara a la más barata.

Ver la solución
fun main() {
    productos
        .groupBy { it.categoria }
        .mapValues { (_, lista) -> lista.map { it.precio }.average() }
        .entries
        .sortedByDescending { it.value }
        .forEach { (categoria, promedio) -> println("$categoria: ${"%.2f".format(promedio)}") }
}
salidaEquipos: 10749.00
Pantallas: 3299.00
Periféricos: 549.00

mapValues transforma los valores de un mapa sin tocar las claves, y average() calcula el promedio de una lista de números. El guion bajo en (_, lista) dice que la clave no se usa.

Reto 2. Escribe una función de extensión List<Int>.mediana() que devuelva la mediana como Double: el valor del centro si la lista ordenada tiene un número impar de elementos, o el promedio de los dos del centro si es par.

Ver la solución
fun List<Int>.mediana(): Double {
    val ordenada = sorted()
    val mitad = size / 2
    return if (size % 2 == 1) ordenada[mitad].toDouble()
    else (ordenada[mitad - 1] + ordenada[mitad]) / 2.0
}

fun main() {
    println(listOf(7, 1, 5).mediana())
    println(listOf(4, 1, 3, 2).mediana())
}
salida5.0
2.5

Dentro de la extensión, sorted() y size se refieren a la lista sobre la que se llama, sin escribir this. Dividir entre 2.0 y no entre 2 evita la división entera.

Reto 3. ¿Qué escribe este programa? val r = listOf(1, 2, 3).asSequence().map { println(it); it * 2 } y después println("listo"). Piénsalo antes de mirar.

Ver la solución
fun main() {
    val r = listOf(1, 2, 3).asSequence().map { println(it); it * 2 }
    println("listo")
}
salidalisto

Solo «listo». La secuencia es perezosa y nadie pidió sus elementos (no hay toList(), first() ni forEach), así que el map nunca se ejecutó. Con listOf(1, 2, 3).map { ... }, sin asSequence(), se habrían impreso 1, 2 y 3 antes de «listo».

Todo el código de esta lección se compiló con Kotlin 2.4.21 (kotlinc) y se ejecutó en la JVM de Temurin 25.0.5 en un contenedor limpio y sin red antes de publicarla; los mensajes del compilador y las salidas están copiados de esa ejecución. ¿Algo no te compila? Revisa tu versión con kotlinc -version.