wandres.dev
RENDIMIENTO · dónde asigna Kotlin

El mapa de las asignaciones: dónde nace cada objeto que nadie pidió

Antes de optimizar nada hace falta un inventario. Esta lección levanta el mapa completo de los puntos donde Kotlin crea objetos que el programador no escribió: la instancia de cada lambda no insertada y el envoltorio de cada variable mutable que captura, el empaquetado de los primitivos al cruzar cualquier frontera genérica, la conversión inevitable de un `Int` opcional en objeto, las cadenas intermedias de la concatenación y las plantillas, y la colección completamente nueva que devuelve cada operador de una cadena de transformaciones.

⏱ 22 min

El rendimiento en la plataforma Java rara vez se pierde en el cálculo: se pierde en la memoria. Un procesador moderno suma dos enteros en una fracción de nanosegundo, pero traer esos dos enteros desde el montículo, seguir dos punteros para encontrarlos y dejar detrás dos objetos que alguien tendrá que recolectar cuesta órdenes de magnitud más. Por eso la pregunta correcta al leer un fragmento de Kotlin nunca es cuántas operaciones hace, sino cuántos objetos deja. Y la respuesta incomoda, porque este lenguaje está diseñado para que el código bonito no parezca caro: una cadena de cinco transformaciones sobre una lista se lee como una frase y asigna cinco listas, un contador dentro de una lambda se lee como una variable y vive en un objeto del montículo, un entero opcional se lee como un número y es un puntero. Nada de esto es un defecto. Es el precio de las abstracciones, y este nivel entero consiste en aprender a leerlo en la factura antes de que llegue.

🎯 Al terminar esta lección sabrás
  • Enumerar las cinco familias de asignación que introduce Kotlin sin que aparezcan en el código fuente.
  • Distinguir una asignación por evaluación de expresión de una asignación por iteración, y saber cuál domina en cada forma.
  • Contar con exactitud las colecciones intermedias que produce una cadena de operadores sobre una lista.
  • Leer una expresión idiomática cualquiera y estimar su huella de objetos sin ejecutarla.

Las cinco familias

La primera familia es la lambda que no se inserta. Cuando una función de orden superior no está marcada como insertable, el bloque que recibe se compila como una instancia de una clase que implementa FunctionN, y esa instancia hay que crearla. Si la lambda no captura nada, el compilador puede reutilizar un ejemplar único y la cuenta baja a cero; en cuanto captura una sola variable, cada evaluación de la expresión produce un objeto nuevo. Y si lo que captura es una variable mutable, aparece un segundo objeto: un envoltorio de la familia Ref que existe únicamente para que el valor pueda modificarse desde fuera del marco de pila donde nació.

fun registrar(accion: () -> Unit) = accion()   // sin inline

fun demostracion(nombre: String) {
    var contador = 0
    registrar { contador += nombre.length }
    // dos objetos: la instancia de la lambda y el Ref.IntRef que guarda contador
}

La segunda familia es el empaquetado. Los genéricos de la plataforma se borran hasta una referencia común, de modo que cualquier primitivo que atraviese una frontera genérica se convierte en objeto: un List<Int> no contiene enteros sino punteros a instancias de Integer, y un parámetro de tipo función que recibe un Int lo empaqueta en cada invocación. La tercera familia es la nulabilidad sobre primitivos, que es en realidad un caso particular de la anterior con una consecuencia distinta: un Int se representa como el primitivo de la máquina, pero un Int? no puede hacerlo porque los primitivos no admiten ausencia, así que se representa siempre como objeto. Declarar un contador opcional convierte cada incremento en una asignación.

La cuarta familia son las cadenas de texto. Son inmutables, de modo que toda concatenación construye una cadena nueva; el compilador ayuda transformando las plantillas y las concatenaciones de una misma expresión en un constructor mutable, pero no puede fusionar concatenaciones separadas por instrucciones distintas, y ahí es donde un bucle que va acumulando texto pasa de lineal a cuadrático. La quinta familia son las colecciones temporales, que merecen su propia sección porque es la más cara y la más invisible de todas.

ℹ️
Por evaluación o por iteración: no es lo mismo

Conviene clasificar cada asignación por su ritmo antes de preocuparse por ella. La instancia de una lambda se crea una vez por evaluación de la expresión que la contiene, así que en una función llamada tres veces es ruido estadístico. El empaquetado de un primitivo ocurre una vez por elemento procesado, así que en un bucle de un millón de vueltas es la partida dominante. Una lista intermedia se asigna una vez por operador, pero su tamaño es proporcional al de la entrada, de modo que no cuesta una asignación sino una asignación grande. Antes de tocar nada, sitúa cada objeto en uno de esos tres ritmos.

Cada operador devuelve una colección nueva

Esta es la asignación que más código idiomático produce y la que menos gente cuenta. Los operadores de la librería estándar sobre Iterable son ansiosos: map recorre la entrada completa, construye un ArrayList del tamaño adecuado y lo devuelve; filter hace lo mismo aunque no sepa de antemano cuántos elementos sobrevivirán, de modo que además puede redimensionar su almacén interno varias veces durante el recorrido. Encadenar operadores encadena listas.

val resultado = usuarios
    .filter { it.activo }        // ArrayList nuevo, mas los redimensionados
    .map { it.edad }             // ArrayList nuevo de Integer empaquetados
    .filter { it > 18 }          // ArrayList nuevo
    .map { it * 2 }              // ArrayList nuevo
    .take(10)                    // ArrayList nuevo con diez elementos

Esa expresión recorre la colección cinco veces completas y deja cinco listas intermedias, cuatro de ellas del orden de magnitud de la entrada, para quedarse con diez elementos. La versión perezosa recorre una sola vez, se detiene en cuanto tiene los diez y no materializa ninguna lista intermedia, a cambio de asignar un pequeño objeto envoltorio por operador y un iterador por etapa. La diferencia no está en el número de objetos sino en su tamaño: cinco envoltorios diminutos frente a cuatro listas proporcionales a la entrada.

flowchart TD
A[Cadena de operadores sobre una lista] --> B{Ansiosa o perezosa}
B -- Ansiosa --> C[Un ArrayList nuevo por operador]
C --> D[Un recorrido completo por operador]
D --> E[Coste proporcional a entrada por etapa]
B -- Perezosa --> F[Un envoltorio pequeno por operador]
F --> G[Un unico recorrido con parada temprana]
G --> H[Coste proporcional al resultado]

El matiz que evita convertir esto en dogma es que la ruta perezosa tiene su propio impuesto: cada elemento atraviesa una llamada virtual por etapa, y sobre colecciones cortas ese impuesto supera con holgura al de asignar dos listas pequeñas. La frontera empírica está muy por encima de lo que la intuición sugiere, y la quinta lección de este nivel la mide en lugar de suponerla.

No todos los operadores cuestan igual, y conviene tener una jerarquía aproximada en la cabeza. Los que producen una sola lista del tamaño de la entrada son los baratos dentro de los caros. Los que producen una estructura asociativa asignan además una entrada de mapa por clave y un array interno que se redimensiona por potencias. Los que ordenan copian la colección a un array auxiliar antes de trabajar. Y los que aplanan asignan una lista intermedia por elemento del nivel externo, además de la lista final.

lista.map { it.x }        // una lista
lista.sortedBy { it.x }   // un array auxiliar mas una lista
lista.groupBy { it.x }    // un mapa, un array interno y una lista por clave
lista.flatMap { it.hijos } // una lista por elemento externo mas la lista final
lista.associateWith { f(it) }  // un mapa mas un objeto por valor si f devuelve primitivo

Merece la pena señalar que casi todos estos operadores están marcados como insertables, de modo que la lambda que reciben no se instancia. Lo que asignan no es el bloque sino el resultado, y esa distinción es exactamente la que confunde a quien ha aprendido que la marca inline elimina asignaciones: elimina las del bloque, no las de la colección que el operador construye para devolver.

El primitivo que dejó de ser primitivo

La conversión de un primitivo en objeto no aparece nunca en el código fuente, y por eso conviene aprender a reconocer sus disparadores. Son tres: entrar en un tipo genérico, adquirir la posibilidad de ser nulo y guardarse en una propiedad de tipo Any. Cualquiera de los tres basta para que un valor que ocupaba cuatro bytes en un registro pase a ocupar un objeto con su cabecera en el montículo.

📦

La frontera genérica

Un List<Int>, un Map<String, Long> o un Pair<Int, Int> almacenan referencias. El empaquetado ocurre al insertar y el desempaquetado al leer, una vez por elemento.

La opcionalidad

Un Int? es un objeto siempre, incluso cuando contiene un valor. Un contador declarado opcional asigna en cada incremento porque el resultado vuelve a empaquetarse.

🧮

El array especializado

Un IntArray guarda primitivos de verdad y no empaqueta nada. Un Array<Int> es un array de referencias y empaqueta todo. Se escriben casi igual y cuestan cosas distintas.

La lambda añade su propia dimensión al problema, porque un tipo función sobre primitivos también atraviesa la frontera genérica. Una función que recibe un parámetro de tipo (Int) -> Boolean y la invoca dentro de un bucle empaqueta un entero por vuelta, salvo que la función esté insertada y el compilador pueda copiar el cuerpo en el punto de llamada y saltarse la invocación por completo. De ahí que la mayoría de los operadores de colecciones de la librería estándar estén marcados como insertables: no por acelerar el salto, sino por evitar exactamente este empaquetado.

fun sumaLenta(datos: IntArray, criterio: (Int) -> Boolean): Int {
    var total = 0
    for (v in datos) if (criterio(v)) total += v   // empaqueta v en cada vuelta
    return total
}

inline fun sumaRapida(datos: IntArray, criterio: (Int) -> Boolean): Int {
    var total = 0
    for (v in datos) if (criterio(v)) total += v   // el cuerpo se copia: cero objetos
    return total
}

Leer una expresión y contar

El ejercicio que convierte todo lo anterior en una habilidad consiste en tomar cualquier expresión idiomática y recorrerla nombrando los objetos que deja. Hay un procedimiento fiable: primero se marcan las lambdas y se pregunta si la función que las recibe está insertada; después se marcan los puntos donde un primitivo entra en un genérico, en un opcional o en un tipo función; después se cuentan los operadores de colección y se multiplica cada uno por el tamaño de la entrada; y por último se buscan las concatenaciones que sobreviven a una instrucción.

fun informe(pedidos: List<Pedido>): String {
    var texto = ""
    pedidos
        .groupBy { it.cliente }                     // un LinkedHashMap y una lista por clave
        .mapValues { (_, ps) -> ps.sumOf { it.total } }  // otro mapa y un Long empaquetado por clave
        .forEach { (cliente, total) ->
            texto += "$cliente: $total\n"           // una cadena nueva por cliente
        }
    return texto
}

Aplicado a ese ejemplo, el procedimiento se recorre en menos de un minuto y devuelve una respuesta ordenada por importancia. Las tres lambdas están dentro de operadores insertables, de modo que la primera familia no aporta nada. La segunda y la tercera sí aparecen, porque el resultado de la suma es un primitivo que entra como valor de un mapa. La cuarta aparece en la última línea y es la peor de todas. Y la quinta aporta dos estructuras completas.

Ese fragmento tiene una huella que crece de forma distinta en cada línea: el agrupamiento asigna un mapa y tantas listas como claves distintas, la agregación asigna un segundo mapa y un objeto por valor sumado, y la acumulación de texto asigna una cadena por iteración cuya longitud crece en cada vuelta, lo que la hace cuadrática en el volumen total. La corrección de las dos primeras líneas es discutible según el tamaño de la entrada; la de la tercera no lo es, porque cambia el orden de complejidad y basta con acumular en un constructor mutable para que desaparezca.

fun informeMejor(pedidos: List<Pedido>): String = buildString {
    val totales = HashMap<String, Long>()
    for (p in pedidos) totales[p.cliente] = (totales[p.cliente] ?: 0L) + p.total
    for ((cliente, total) in totales) append(cliente).append(": ").append(total).append('\n')
}

La versión reescrita elimina la familia cuadrática y una de las dos estructuras asociativas, y sigue siendo perfectamente legible. Ese es el patrón que conviene interiorizar antes de seguir: la mayoría de los problemas de asignación reales no se resuelven abandonando el estilo del lenguaje, sino corrigiendo el punto concreto donde una construcción cambia el orden de complejidad.

El mapa de asignaciones es una habilidad de lectura, no una lista de prohibiciones

Existe una manera equivocada de aprovechar este inventario, y consiste en convertirlo en una lista de cosas que no se hacen: nada de lambdas fuera de funciones insertables, nada de enteros opcionales, nada de cadenas de operadores. Quien programa así escribe código feo y lento a la vez, porque paga el coste de la ilegibilidad sin cobrar el beneficio, que en la abrumadora mayoría del código no existe. La utilidad real del mapa es otra y es puramente cognitiva: permite mirar una expresión y saber, antes de medir, dónde estarían los objetos si hubiera un problema. Eso cambia el trabajo por completo. Sin el mapa, un perfilador que señala una presión de memoria alta es un dato inerte, porque la reacción típica es cambiar cosas al azar hasta que el número baja. Con el mapa, ese mismo dato se convierte en una hipótesis inmediata y comprobable: el bucle caliente recorre un List<Int>, luego hay un desempaquetado por elemento, luego un IntArray debería eliminarlo, y si no lo elimina la hipótesis era falsa y hay que buscar en otro sitio. La diferencia entre un programador que optimiza y uno que juega a la lotería es exactamente esa capacidad de formular hipótesis falsables sobre dónde vive la memoria. Y hay una segunda consecuencia, más importante que la primera: conocer el mapa es lo que permite escribir código idiomático con la conciencia tranquila en el noventa y nueve por ciento restante. Quien sabe que una cadena de cinco operadores sobre una lista de doce elementos asigna cinco listas minúsculas que morirán en la primera generación del recolector, y sabe además que la máquina virtual las trata casi como memoria de pila, deja de preocuparse por ellas para siempre. El mapa no sirve para tener miedo en todas partes: sirve para tenerlo solo donde toca.

⚔️ Levanta el inventario de tu propio código
  1. Toma una función de tu proyecto con una cadena de al menos tres operadores y escribe cuántas colecciones intermedias asigna y de qué tamaño es cada una.
  2. Busca una lambda que capture una variable mutable y explica qué dos objetos produce cada evaluación de esa expresión.
  3. Localiza un Int? en una ruta que se ejecute muchas veces y razona qué asignación desaparecería al sustituirlo por un valor centinela o un tipo dedicado.
  4. Encuentra una acumulación de texto dentro de un bucle y calcula cómo crece su coste con el número de vueltas.
  5. Escribe la misma función dos veces, una con la cadena ansiosa y otra perezosa, y predice por escrito cuál gana antes de medir nada.