wandres.dev
LAMBDAS · funciones como valores

Qué es una lambda en el runtime

Una lambda no es una entidad primitiva de la máquina: es un objeto que implementa una interfaz. Esta lección desmonta la representación real, desde la familia de interfaces FunctionN y su método invoke hasta la asignación de una instancia por evaluación, el caso especial de la lambda sin capturas, el paso de clases anónimas a invokedynamic, y el mecanismo por el que Kotlin consigue capturar variables mutables usando envoltorios de referencia donde Java exige que todo sea efectivamente final.

⏱ 20 min

La sintaxis de las lambdas es tan ligera que induce una creencia falsa: que escribir { it * 2 } es gratis, que ahí no hay nada, que el compilador simplemente coloca esas instrucciones donde hagan falta. La realidad de la JVM es más prosaica y bastante más interesante. La máquina virtual no tiene ningún concepto de función como valor; solo sabe de clases, objetos y llamadas a métodos. Cualquier lenguaje que quiera ofrecer funciones de primera clase sobre ella tiene que fabricar la ilusión con los materiales disponibles, y la forma concreta en que Kotlin lo hace determina cuánta memoria se asigna, cuántos objetos se crean por segundo en un bucle cerrado y por qué existe la palabra clave inline. Esta lección es la que convierte la intuición de que las lambdas cuestan algo en un conocimiento exacto de qué cuestan y cuándo.

🎯 Al terminar esta lección sabrás
  • Describir la familia de interfaces FunctionN y explicar cómo un tipo función se traduce a una de ellas.
  • Determinar en qué casos una lambda produce una asignación por evaluación y en cuáles se reutiliza una única instancia.
  • Explicar el mecanismo de captura de variables y por qué en Kotlin las capturadas pueden ser mutables.
  • Estimar el coste real de una lambda en un punto caliente y saber qué herramientas del lenguaje lo eliminan.

Una lambda es un objeto con un método

Todo tipo función de Kotlin es azúcar de notación sobre una interfaz real de la librería estándar. La familia se llama FunctionN, donde la letra final es la aridad, y cada miembro declara un único método llamado invoke con esa cantidad de parámetros. El tipo (Int) -> String es exactamente Function1<Int, String>, el tipo () -> Unit es Function0<Unit> y el tipo (A, B, C) -> D es Function3<A, B, C, D>. La familia llega hasta veintidós parámetros; por encima de esa cifra existe un mecanismo distinto y menos eficiente, lo cual es una pista suficientemente clara sobre lo que el diseño del lenguaje considera razonable.

val duplicar: (Int) -> Int = { it * 2 }

duplicar(21)          // azucar sintactico
duplicar.invoke(21)   // la llamada real que ocurre debajo

// Ambas expresiones tienen el mismo tipo declarado
val comoInterfaz: Function1<Int, Int> = duplicar

De esta representación se derivan tres consecuencias que no son obvias. La primera es que los parámetros de las interfaces FunctionN son genéricos, y en la JVM los genéricos se borran y trabajan sobre referencias; por tanto un (Int) -> Int sobre la plataforma Java empaqueta sus enteros en objetos en cada llamada, salvo que el compilador consiga evitarlo. La segunda es que una lambda puede tratarse como cualquier otro objeto: compararse por identidad, guardarse en una colección, tener un toString. La tercera es que el operador de llamada con paréntesis es una convención resuelta en tiempo de compilación hacia invoke, de modo que cualquier clase propia que declare un operator fun invoke se vuelve llamable con la misma sintaxis sin ser una función.

flowchart LR
A[Codigo fuente: llave n flecha n por dos] --> B[Compilador Kotlin]
B --> C[Instancia de Function1]
C --> D[Metodo invoke con el cuerpo]
E[Llamada con parentesis] --> D

Esa tercera consecuencia merece verse escrita, porque revela que el operador de llamada no distingue entre una lambda y cualquier otro objeto que declare la operación adecuada. La sintaxis con paréntesis no es un privilegio de las funciones: es una convención de operador como lo son la suma o el acceso por índice.

class Escalador(private val factor: Int) {
    operator fun invoke(valor: Int): Int = valor * factor
}

val porTres = Escalador(3)
porTres(7)                              // se llama con parentesis sin ser lambda
listOf(1, 2, 3).map(porTres::invoke)    // y encaja donde se espera un tipo funcion

Sobre la forma exacta de esa instancia hubo un cambio importante. Durante años el compilador generaba una clase anónima por cada lambda del código fuente, lo que hinchaba el número de clases del artefacto final y penalizaba el arranque. Desde Kotlin 2.0 la generación por defecto usa la instrucción invokedynamic de la JVM, de modo que la clase se fabrica en tiempo de ejecución la primera vez que hace falta. El efecto visible es un artefacto más pequeño y un arranque distinto; el efecto invisible, y más importante para esta lección, es que el número de objetos asignados en tiempo de ejecución no cambia sustancialmente. La estrategia de generación afecta a cuántas clases existen, no a cuántas instancias se crean.

Una instancia por evaluación, salvo cuando no captura nada

Aquí está la distinción que gobierna todo el rendimiento de este nivel. Una lambda que no hace referencia a nada de su entorno es un valor constante: siempre se comporta igual, así que basta con una única instancia para todo el programa y el compilador la reutiliza. Una lambda que sí hace referencia a algo de su entorno depende de ese algo, y por tanto necesita una instancia nueva cada vez que la expresión se evalúa, porque cada instancia lleva dentro sus propias capturas.

fun sinCaptura(lista: List<Int>) {
    // La misma instancia se reutiliza en todas las llamadas
    lista.forEach { println(it) }
}

fun conCaptura(lista: List<Int>, prefijo: String) {
    // Una instancia nueva por cada llamada a conCaptura
    lista.forEach { println(prefijo + it) }
}

La palabra clave del párrafo anterior es evaluación, no llamada. Si la expresión lambda está dentro de un bucle y captura la variable del bucle, se crea un objeto por vuelta. Si está fuera del bucle y se pasa a una función que la invoca muchas veces, hay un solo objeto y muchas llamadas a invoke. Esa diferencia entre asignar una vez e invocar mil veces, o asignar mil veces e invocar mil veces, es exactamente la que separa un código correcto de un código que llena el recolector de basura sin motivo.

⚠️
El coste no es la lambda: es dónde está escrita

Antes de optimizar nada, localiza la expresión lambda, no la llamada. Una lambda declarada en el cuerpo de un método que se ejecuta sesenta veces por segundo produce sesenta objetos por segundo si captura algo, y cero si no captura nada. La corrección casi nunca consiste en eliminar la lambda, sino en sacarla del punto caliente, en eliminar la captura que la obliga a ser distinta cada vez, o en marcar inline la función que la recibe para que no llegue a existir ningún objeto.

Conviene añadir un matiz que evita conclusiones exageradas. La mayoría de las funciones de orden superior de la librería estándar que se usan a diario, como forEach, map, filter, let, apply o run, están marcadas como inline, y en ellas el cuerpo de la lambda se copia en el punto de llamada y no se crea ningún objeto en absoluto. El coste que estamos describiendo aparece cuando la función receptora no es inline: al almacenar la lambda en una propiedad, al pasarla a una API de Java, al enviarla a través de una frontera de corrutina o al guardarla en una colección de callbacks.

Capturar el entorno, incluso lo mutable

Una lambda puede leer las variables visibles en el punto donde está escrita. Eso se llama captura, y en Kotlin tiene una propiedad que Java no tiene: la variable capturada puede ser mutable, y las modificaciones hechas dentro de la lambda son visibles fuera y viceversa. En Java una variable local capturada debe ser final o efectivamente final, lo que obliga a rodeos con arreglos de un elemento o con clases envoltorio.

fun contarPares(numeros: List<Int>): Int {
    var contador = 0
    numeros.forEach { if (it % 2 == 0) contador++ }
    return contador     // la lambda escribio sobre la variable local
}

El mecanismo que hace posible esto es directo una vez se conoce. Una variable local vive en la pila del método y desaparece cuando el método retorna, mientras que la lambda puede sobrevivirle; por tanto la captura por referencia solo puede funcionar si la variable deja de vivir en la pila. Lo que hace el compilador es promocionarla al montículo dentro de un pequeño objeto envoltorio que contiene un único campo, y reescribir todos los accesos a esa variable, dentro y fuera de la lambda, como accesos a ese campo. Ambas partes miran el mismo objeto, así que ambas ven las mismas escrituras. Las variables capturadas que son inmutables no necesitan envoltorio: basta con copiar el valor dentro de la instancia de la lambda.

λ

Captura de un `val`

El valor se copia en un campo de la instancia de la lambda. Sin envoltorio, sin indirección extra y sin ninguna posibilidad de que las dos partes discrepen.

📦

Captura de un `var`

La variable se promociona a un objeto envoltorio con un campo mutable. Hay una asignación adicional y un salto de indirección en cada lectura y en cada escritura.

🧵

El envoltorio no sincroniza

Que dos flujos de ejecución vean la misma variable capturada no significa que la vean de forma segura. El envoltorio resuelve la vida útil, no la concurrencia: para eso hacen falta las herramientas de los niveles de corrutinas.

Ese detalle explica una diferencia observable entre Kotlin y Java que muchos programadores atribuyen erróneamente a la generosidad del lenguaje. Java no prohíbe capturar variables mutables por rigidez, sino porque su modelo copia el valor dentro de la instancia y una copia no puede reflejar escrituras posteriores; permitirlo produciría un código que parece compartir y no comparte. Kotlin resuelve el mismo problema desde el otro lado, cambiando la representación de la variable en lugar de la de la captura, y por eso puede ofrecer una semántica compartida que es coherente en ambas direcciones. La comodidad tiene un precio en objetos y en indirección, y ese precio se paga aunque la lambda no llegue a escribir nunca.

fun ejemplo(entrada: List<Int>) {
    val limite = 10                 // val: se copia dentro de la lambda
    var vistos = 0                  // var: se promociona a un envoltorio
    entrada.filter { it < limite }
        .forEach { vistos++ }
    println(vistos)
}

La advertencia de la tercera tarjeta merece ahora un desarrollo, porque la comodidad de capturar variables mutables invita a un error grave. Un contador capturado por varias lambdas que se ejecutan en paralelo produce resultados incorrectos exactamente igual que cualquier otra variable compartida sin protección, y el hecho de que el código parezca local y contenido no cambia nada: el envoltorio es un objeto compartido en el montículo, con todas las consecuencias habituales. La forma idiomática de contar en Kotlin no es capturar un contador sino usar count, sumOf o fold, que no comparten nada porque el acumulador viaja por la pila de la propia llamada.

Lo que hay que saber medir

Con lo anterior en la mano, el coste de una lambda se descompone en tres partidas independientes que conviene no mezclar. La primera es la asignación de la instancia, que ocurre una vez por evaluación de la expresión y solo si hay capturas. La segunda es el envoltorio de cada variable mutable capturada, que añade un objeto más y una indirección por acceso. La tercera es el empaquetado de los tipos primitivos que atraviesan la frontera genérica de invoke, que ocurre por llamada y no por instancia, y que por tanto es la que domina en los bucles largos.

// Tres asignaciones por invocacion: la lambda, el envoltorio y el boxing por vuelta
fun acumularCaro(datos: List<Int>, procesar: (Int) -> Unit) {
    var total = 0
    datos.forEach { valor -> total += valor; procesar(valor) }
}

// Cero asignaciones: el acumulador viaja por la pila y fold es inline
fun acumularBarato(datos: List<Int>): Int = datos.fold(0) { acc, valor -> acc + valor }

La conclusión operativa no es evitar las lambdas, que sería tirar el lenguaje por la ventana, sino saber dónde mirar cuando el perfilador señale presión de memoria. Casi siempre el hallazgo será una lambda con captura en un método llamado con mucha frecuencia, o una colección de callbacks que se reconstruye en cada ciclo de dibujado. Ambos casos se corrigen moviendo la expresión, no eliminándola.

Las funciones de primera clase son una ilusión sostenida por objetos, y saberlo es lo que permite confiar en ella

Hay una tentación pedagógica muy extendida que consiste en decir que las lambdas son funciones y detenerse ahí, y es una tentación que hay que resistir, porque el modelo mental que produce se rompe precisamente en los momentos en que más falta hace. En la JVM no existen las funciones como valores; existen objetos con métodos, y todo lo que parece una función de primera clase es una instancia de algo que implementa una interfaz con un método llamado invoke. Kotlin no oculta esa verdad, la envuelve: te deja escribir la flecha, te deja llamar con paréntesis, te deja pasar la lambda como si fuera un valor primitivo del lenguaje, pero por debajo siempre hay una clase, siempre hay una instancia y siempre hay un método. Entender eso convierte una docena de fenómenos aparentemente inconexos en un solo hecho con doce consecuencias. Explica por qué inline existe y por qué elimina la asignación en vez de acelerarla, porque no hay nada que optimizar cuando no hay objeto. Explica por qué las variables mutables capturadas necesitan un envoltorio, porque un campo de un objeto sobrevive al método que lo creó y una ranura de la pila no. Explica por qué comparar dos lambdas por igualdad casi nunca da lo que uno espera, porque se están comparando dos instancias. Explica por qué una lambda pasada a una API de Java funciona sin ceremonia, porque una interfaz con un único método abstracto y una FunctionN son la misma forma vista desde dos lenguajes. Y explica, sobre todo, por qué el consejo de rendimiento correcto nunca es dejar de usar lambdas: el coste no está en la abstracción sino en el lugar donde se instancia, y mover una expresión tres líneas más arriba puede eliminar millones de objetos sin cambiar ni una letra del diseño. Quien sabe qué hay debajo puede usar la abstracción sin miedo y sin superstición, que es exactamente el punto al que hay que llegar.

⚔️ Cuenta los objetos
  1. Escribe dos funciones idénticas salvo en que una captura un parámetro y la otra no, y razona cuántas instancias produce cada una al llamarlas mil veces.
  2. Implementa un contador con var capturado y otro con count, y explica qué objeto adicional existe en el primero y en qué región de memoria vive.
  3. Declara una clase con operator fun invoke y pásala donde se espere un tipo función. Justifica por qué la sintaxis de llamada coincide.
  4. Localiza en tu código una lambda escrita dentro de un bucle que capture la variable del bucle, sácala fuera si es posible y explica por qué a veces no lo es.
  5. Argumenta por qué el envoltorio de una variable capturada mutable no aporta ninguna garantía de seguridad frente a accesos concurrentes.