Funciones de orden superior y lambda en Kotlin

1. Descripción general

En Kotlin, las funciones son “ciudadanos de primera clase”. Puedes asignar funciones a variables, pasarlas como parámetros, o retornarlas desde otras funciones. Las funciones que reciben o retornan otras funciones se llaman funciones de orden superior. La forma más simple de crear tales funciones es con lambdas.

Este artículo cubre tipos de función, sintaxis lambda, referencias de función, closures, lambdas con receiver, y la palabra clave inline. Después de leerlo, podrás escribir tus propias funciones de orden superior. También sabrás cómo evitar algunos errores comunes.

2. Tipo de función

Cada función en Kotlin tiene un tipo, llamado tipo de función. La sintaxis consta de una lista de tipos de parámetros entre paréntesis, una flecha, y luego el tipo de retorno:

fun main() {
    val add: (Int, Int) -> Int = { a, b -> a + b }
    val greet: (String) -> Unit = { name -> println("Hola, $name") }
    val now: () -> Long = { System.currentTimeMillis() }

    println(add(2, 3))
    greet("Lan")
    println(now() > 0)
}

Explicación:

  • (Int, Int) -> Int es una función que recibe dos Int y retorna un Int.
  • (String) -> Unit es una función que no retorna un valor significativo, similar a void en Java.
  • () -> Long es una función sin parámetros.
  • Llamas a una variable función como a una función normal: add(2, 3). Esto es equivalente a add.invoke(2, 3).
5
Hola, Lan
true

El tipo de función también puede ser nullable. En ese caso, debes envolver todo el tipo entre paréntesis: ((Order) -> Unit)?. Para llamarla de forma segura, usa callback?.invoke(order).

3. Escribe tu primera función de orden superior

Usaremos un modelo simple a lo largo del artículo. El monto usa el tipo Long (en unidades de dong) para evitar errores de punto flotante:

data class Order(val id: String, val customer: String, val amount: Long)

fun calculateTotal(orders: List<Order>, priceOf: (Order) -> Long): Long {
    var total = 0L
    for (order in orders) {
        total += priceOf(order)
    }
    return total
}

fun main() {
    val orders = listOf(
        Order("A1", "Lan", 100_000),
        Order("A2", "Minh", 250_000),
        Order("A3", "Lan", 50_000),
    )

    val full = calculateTotal(orders) { it.amount }
    val discounted = calculateTotal(orders) { it.amount * 90 / 100 }

    println(full)
    println(discounted)
}

calculateTotal es una función de orden superior porque el parámetro priceOf tiene el tipo (Order) -> Long. Esta función no sabe cómo calcular el precio de cada orden. Delega eso al llamador. Así, el mismo bucle puede usarse tanto para precios originales como para precios con descuento.

400000
360000

4. Sintaxis lambda

4.1. Diferentes formas

Las lambdas siempre están entre {}. Los parámetros van antes de ->, el cuerpo va después. La expresión final es el valor de retorno, no necesitas escribir return:

val isLarge: (Order) -> Boolean = { order ->
    val threshold = 200_000
    order.amount > threshold   // valor de retorno
}

val isLargeShort: (Order) -> Boolean = { it.amount > 200_000 }

val ignoreSecond: (Order, Int) -> String = { order, _ -> order.id }
  • Cuando lambda tiene un solo parámetro, puedes omitir su declaración y usar el nombre implícito it.
  • Los parámetros no usados deben ser _ para que el código sea claro e evites advertencias del compilador.

4.2. Trailing lambda

Si el último parámetro de una función es un tipo de función, puedes poner la lambda fuera de los paréntesis. Si es el único parámetro, puedes omitir los ():

val orders = listOf(
    Order("A1", "Lan", 100_000),
    Order("A2", "Minh", 250_000),
)

val large = orders.filter { it.amount > 200_000 }
val total = calculateTotal(orders) { it.amount }
val sorted = orders.sortedByDescending { it.amount }.map { it.id }

println(large.map { it.id })
println(sorted)
[A2]
[A2, A1]

Por eso el código Kotlin con colecciones lee casi como lenguaje natural. Todas las funciones filter, map, sortedByDescending en la biblioteca estándar son funciones de orden superior.

5. Referencia de función

Cuando ya tienes una función con la firma correcta, no necesitas envolverla en una lambda. Usa el operador :::

fun isVip(order: Order): Boolean = order.amount >= 200_000

class TaxService(private val ratePercent: Long) {
    fun withTax(order: Order): Long = order.amount * (100 + ratePercent) / 100
}

fun main() {
    val orders = listOf(Order("A1", "Lan", 100_000), Order("A2", "Minh", 250_000))
    val taxService = TaxService(10)

    println(orders.filter(::isVip).map { it.id })   // función top-level
    println(orders.map(Order::amount))              // referencia de propiedad
    println(calculateTotal(orders, taxService::withTax)) // referencia acoplada
}
  • ::isVip es una referencia a una función top-level.
  • Order::amount es una referencia a una propiedad. Tiene el tipo (Order) -> Long.
  • taxService::withTax es una referencia acoplada: la función ya está vinculada al objeto taxService.
[A2]
[100000, 250000]
385000

Las referencias de función suelen ser más legibles que lambdas cuando el nombre de la función describe claramente la intención.

6. Retornar funciones y closures

6.1. Función que retorna función

Una función de orden superior también puede retornar una función. Esto es útil cuando quieres crear comportamiento basado en configuración:

fun discountFor(level: String): (Long) -> Long {
    val percent = when (level) {
        "GOLD" -> 80
        "SILVER" -> 90
        else -> 100
    }
    return { amount -> amount * percent / 100 }
}

fun main() {
    val goldPrice = discountFor("GOLD")
    val normalPrice = discountFor("NONE")

    println(goldPrice(500_000))
    println(normalPrice(500_000))
}
400000
500000

La lambda que se retorna todavía “recuerda” la variable percent, aunque discountFor ya haya terminado. Este mecanismo se llama closure.

Nota: No escribas "GOLD" -> { amount -> amount * 80 / 100 } dentro de when. En esa posición, {} se interpreta como un bloque, no como lambda, así que el código no compilará. Calcula el valor primero y luego retorna la lambda como en el ejemplo anterior. También puedes envolver la lambda entre paréntesis: ({ amount -> ... }).

6.2. Closure puede modificar variables

A diferencia de las lambdas en Java, las lambdas en Kotlin pueden cambiar variables var que capturan:

fun orderIdGenerator(prefix: String): () -> String {
    var next = 1
    return { "$prefix-${next++}" }
}

fun main() {
    val nextId = orderIdGenerator("ORD")
    println(nextId())
    println(nextId())
    println(nextId())
}
ORD-1
ORD-2
ORD-3

El compilador envuelve next en un objeto referenciable, llamado Ref, para que la lambda y la función externa compartan un valor.

Nota: Modificar una variable compartida en un closure no es thread-safe. Si múltiples threads llaman nextId(), podrías recibir IDs duplicados. En ese caso, usa AtomicInteger.

7. Lambda con receiver

Kotlin tiene una forma especial de tipo de función: T.() -> R. Dentro de esta lambda, this es el objeto de tipo T. Accedes directamente a los miembros sin necesidad de prefijo. Esta es la base de DSLs como buildString, apply, o Gradle Kotlin DSL:

class OrderBuilder {
    var id: String = ""
    var customer: String = ""
    var amount: Long = 0

    fun build(): Order = Order(id, customer, amount)
}

fun order(block: OrderBuilder.() -> Unit): Order =
    OrderBuilder().apply(block).build()

fun main() {
    val o = order {
        id = "B1"
        customer = "Hoa"
        amount = 120_000
    }
    println(o)
}
Order(id=B1, customer=Hoa, amount=120000)

block tiene el tipo OrderBuilder.() -> Unit. Por lo tanto dentro de la lambda, id = "B1" es en realidad this.id = "B1". apply(block) llama la lambda con receiver siendo el builder que acabamos de crear, y luego retorna el mismo builder.

8. inline y return en lambda

8.1. Costo de lambda

En la JVM, cada lambda típicamente se compila en un objeto. Si la lambda captura variables, cada llamada podría crear un nuevo objeto. Para funciones llamadas muchas veces, este costo se acumula significativamente.

Marcar una función como inline resuelve este problema. El compilador copia el cuerpo de la función y el cuerpo de la lambda directamente al sitio de llamada, así no crea ningún objeto:

inline fun <T> measure(label: String, block: () -> T): T {
    val start = System.nanoTime()
    val result = block()
    println("$label: ${(System.nanoTime() - start) / 1_000} µs")
    return result
}

fun main() {
    val orders = (1..1_000).map { Order("O$it", "C$it", it * 1_000L) }
    val total = measure("Calcular total") { calculateTotal(orders) { it.amount } }
    println(total)
}
Calcular total: 412 µs
500500000

El tiempo medido variará en cada máquina. Las funciones filter, map, forEach en la biblioteca estándar son todas inline.

8.2. Non-local return y label

En una lambda pasada a una función inline, un return simple saldrá de la función externa, no solo de la lambda:

fun findFirstLarge(orders: List<Order>, threshold: Long): Order? {
    orders.forEach {
        if (it.amount > threshold) return it   // sale de findFirstLarge
    }
    return null
}

fun printPaidOrders(orders: List<Order>) {
    orders.forEach {
        if (it.amount == 0L) return@forEach    // solo omite este elemento
        println(it.id)
    }
    println("Listo")
}
  • return it es un non-local return. Termina findFirstLarge completamente.
  • return@forEach es un return con label. Solo termina la ejecución actual de la lambda, como continue.

Con una función no inline, un return simple en una lambda causará un error del compilador. Debes usar un label obligatoriamente.

Nota: Si una función inline pasa la lambda a otro contexto, por ejemplo ejecutarla en otro thread, marca el parámetro como crossinline para prohibir non-local return. Si quieres guardar la lambda en una variable o field, usa noinline.

9. Pruebas de funciones de orden superior

Agrega la dependencia de prueba a pom.xml. El proyecto necesita tener kotlin-maven-plugin configurado:

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.25.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Las funciones de orden superior son muy fáciles de probar. Solo necesitas pasar una lambda simple y verificar el resultado:

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class PricingTest {

    private val orders = listOf(
        Order("A1", "Lan", 100_000),
        Order("A2", "Minh", 250_000),
        Order("A3", "Lan", 50_000),
    )

    @Test
    fun `calculateTotal applies the given price function`() {
        assertThat(calculateTotal(orders) { it.amount }).isEqualTo(400_000L)
        assertThat(calculateTotal(orders) { 1L }).isEqualTo(3L)
    }

    @Test
    fun `discountFor returns function matching customer level`() {
        assertThat(discountFor("GOLD")(500_000)).isEqualTo(400_000L)
        assertThat(discountFor("SILVER")(500_000)).isEqualTo(450_000L)
        assertThat(discountFor("UNKNOWN")(500_000)).isEqualTo(500_000L)
    }

    @Test
    fun `generator keeps its own state per closure`() {
        val first = orderIdGenerator("ORD")
        val second = orderIdGenerator("INV")

        assertThat(listOf(first(), first(), second()))
            .containsExactly("ORD-1", "ORD-2", "INV-1")
    }
}

La tercera prueba confirma un punto importante: cada llamada a orderIdGenerator crea su propio closure, con su propia variable next.

10. Errores comunes y cuándo no usar esto

  • Lambda demasiado larga. Si el cuerpo de la lambda ocupa más de unos pocos líneas, sepáralo en una función con nombre y luego pásala con ::. El código será más legible y más fácil de probar.
  • Abusar de it anidado. Si escribes orders.map { it.items.filter { it.price > 0 } }, el it interno oculta el it externo. Nombra los parámetros claramente.
  • Hacer inline todo. Solo deberías hacer inline funciones pequeñas que tengan parámetros lambda. Hacer inline funciones grandes inflará el bytecode. Hacer inline funciones sin lambdas casi no da beneficio, y el compilador te advertirá.
  • Confundir return. Un return simple en forEach sale de toda la función externa. Esta es la fuente de muchos bugs difíciles de ver. Cuando solo quieras omitir un elemento, usa return@forEach.
  • Capturar objetos pesados. Lambdas guardadas a largo plazo, por ejemplo como listeners, mantendrán referencias a todas las variables que capturan. Esto puede causar fugas de memoria, por ejemplo al mantener toda una Activity en Android.
  • No necesitas abstracción. Si solo hay una forma de procesar y nunca cambia, una función normal es suficiente. No añadas parámetro lambda solo para “estar preparado”.

11. Conclusión

En este artículo, has visto cómo los tipos de función describen la firma de una función. Las lambdas y referencias de función son dos formas de crear valores de función. Las funciones de orden superior reciben o retornan funciones para separar el “marco” del “comportamiento”. También te has familiarizado con closures, lambdas con receiver para construir DSLs, e inline para eliminar el costo de objetos junto con cómo funciona non-local return.

Cuando domines estos conceptos, leerás la biblioteca estándar de Kotlin mucho más fácilmente. También sabrás cuándo escribir APIs de tipo higher-order para tu propio código.

kotlinlambdahigher-order functionprogramación funcional