Optimizar con criterio: dónde cuesta el idioma bonito y dónde da igual
El cierre del nivel convierte el inventario de asignaciones y el método de medición en decisiones de diseño. Esta lección delimita las pocas situaciones en las que el estilo idiomático de Kotlin tiene un coste que alguien nota, contrapone con números la evaluación perezosa y la ansiosa en lugar de repetir la consigna habitual, ordena el catálogo de cambios que de verdad reducen asignaciones, y formula la regla que subordina cualquier optimización a la existencia previa de una medición que la justifique.
Llegados aquí existe un riesgo real, y es que todo lo aprendido en este nivel se convierta en desconfianza generalizada hacia el propio lenguaje. Quien acaba de descubrir que cada operador asigna una lista, que cada entero opcional es un objeto y que cada lambda capturadora tiene un coste, siente la tentación de escribir bucles manuales con arrays especializados en todas partes, y el resultado es código que nadie quiere mantener y que además no va más rápido, porque el noventa y nueve por ciento de él no estaba en ningún camino crítico. El criterio consiste precisamente en lo contrario: en usar el conocimiento del coste para saber dónde ignorarlo. Un programa tiene una o dos rutas donde el rendimiento se decide y miles de líneas donde lo único que importa es que se entiendan. La destreza no está en optimizar mucho, sino en localizar con precisión ese uno por ciento y dejar el resto en paz.
- Delimitar las condiciones concretas bajo las cuales el estilo idiomático tiene un coste observable.
- Contrastar con datos la evaluación perezosa y la ansiosa en lugar de aplicar una regla memorizada.
- Ordenar por relación entre beneficio y daño el catálogo de transformaciones que reducen asignaciones.
- Formular y defender la regla que exige una medición previa a cualquier cambio motivado por rendimiento.
Dónde cuesta de verdad
El coste de una construcción no depende solo de lo que asigna sino de cuántas veces se ejecuta y de qué comparte esa ejecución con el resto del sistema. Combinando ambas cosas aparecen tres condiciones que, cuando se dan a la vez, convierten una asignación irrelevante en un problema medible: que el fragmento esté dentro de un bucle recorrido muchas veces, que su asignación sea proporcional al tamaño de los datos y no constante, y que el objeto resultante sobreviva lo suficiente para escapar de la primera generación del recolector.
Ruta caliente y volumen
Un bucle que procesa millones de elementos por segundo amplifica cualquier asignación por elemento. La misma línea fuera del bucle no cuesta nada.
Asignación proporcional
Una lista intermedia del tamaño de la entrada es cara aunque sea una sola asignación. Un envoltorio de tamaño fijo por etapa no lo es aunque sean cinco.
Objetos que sobreviven
La memoria efímera es casi gratuita porque muere en la primera recolección. Los objetos que se guardan en cachés o estructuras de larga vida son los que fuerzan trabajo de verdad.
Fuera de esa intersección, la aritmética del asunto es aplastante. Una función de configuración que se ejecuta una vez al arrancar puede asignar diez mil objetos sin que nadie lo perciba jamás. Un manejador de peticiones cuya latencia está dominada por una consulta a la base de datos de doce milisegundos no mejora porque su transformación de resultados baje de ochocientos nanosegundos a cuatrocientos. La pregunta previa a cualquier optimización no es cuánto asigna esto, sino qué fracción del tiempo total representa, y esa fracción casi nunca se puede adivinar.
Buena parte de la intuición sobre el coste de asignar viene de sistemas con gestión manual de memoria, donde crear y destruir un objeto es una operación con coste fijo apreciable. En un montículo generacional la asignación es poco más que incrementar un puntero, y la recolección de la generación joven copia únicamente los objetos que sobrevivieron, de modo que el coste es proporcional a lo que vive y no a lo que murió. Eso significa que un millón de objetos efímeros pueden salir más baratos que mil objetos longevos. La métrica que importa no es cuántos objetos se crean, sino cuántos sobreviven.
Conviene además tener presente que el idioma bonito de Kotlin es, en la mayoría de los casos, exactamente igual de rápido que su alternativa fea. Una función de alcance como let o apply es insertable y no deja nada. Una data class genera métodos ordinarios. Una expresión when compila a la misma tabla de saltos que una cadena de condiciones. Una propiedad con captador implícito se convierte en un acceso a campo en cuanto la máquina inserta el método. La lista de construcciones idiomáticas que cuestan algo es corta, y ya está entera en las cuatro lecciones anteriores.
// Todas estas construcciones son gratuitas o casi:
val nombre = usuario?.let { it.nombre } ?: "anonimo"
val punto = Punto(x, y).copy(y = 0.0)
val etiqueta = when (estado) { Estado.ACTIVO -> "on"; Estado.INACTIVO -> "off" }
val n = coleccion.count { it.valido }
Perezoso frente a ansioso, con números
La consigna habitual dice que las secuencias son más eficientes que las listas para cadenas de operadores, y la consigna es falsa en la mayoría de los casos donde se aplica. Una secuencia elimina las colecciones intermedias, pero introduce un objeto envoltorio por etapa, un iterador por etapa y, sobre todo, una llamada virtual por elemento y por etapa que la máquina insertará o no según el perfil del punto de llamada. Sobre colecciones pequeñas ese impuesto por elemento supera con holgura el ahorro de asignar dos listas minúsculas.
// Cadena de dos operadores sobre enteros, ordenes de magnitud tipicos:
// tam 10 la lista gana con claridad
// tam 1000 empatan o gana ligeramente la lista
// tam 100000 gana la secuencia, y la ventaja crece con las etapas
// con parada temprana la secuencia gana en cualquier tamano
Los factores que desplazan la frontera son cuatro y conviene tenerlos presentes porque explican por qué dos personas midiendo lo mismo llegan a conclusiones opuestas. El número de etapas favorece a la secuencia, porque cada etapa añadida cuesta una lista completa al lado ansioso y solo un envoltorio al perezoso. La presencia de un operador con parada temprana favorece de forma decisiva a la secuencia, porque el lado ansioso recorre y materializa todo antes de descartar. El coste de la función que se aplica a cada elemento favorece a la secuencia si es alto, porque diluye el impuesto de la llamada. Y el tamaño de los elementos favorece a la secuencia si son grandes, porque las listas intermedias ocupan más.
flowchart TD
A[Cadena de operadores] --> B{Hay parada temprana}
B -- Si --> C[Secuencia sin discusion]
B -- No --> D{Tamano de la entrada}
D -- Pequeno --> E[Lista: menos indireccion]
D -- Grande --> F{Numero de etapas}
F -- Una o dos --> G[Diferencia despreciable]
F -- Tres o mas --> H[Secuencia: se evitan listas grandes]Existe además una tercera vía que suele ser mejor que las dos y que casi nadie considera: fusionar las etapas a mano dentro de un único operador. Una cadena de dos transformaciones y un filtro puede escribirse muchas veces como un solo mapNotNull o un solo fold, con una única lista de salida, un único recorrido y sin ningún envoltorio perezoso. La expresión resultante sigue siendo declarativa y elimina el dilema por completo.
// Tres etapas, dos listas intermedias
val a = xs.map { it * 2 }.filter { it % 3 == 0 }.map { it.toString() }
// Una etapa, una lista, sin secuencias ni bucles manuales
val b = xs.mapNotNull { v -> (v * 2).takeIf { it % 3 == 0 }?.toString() }
Hay además una consideración que no es de rendimiento y que a menudo decide la cuestión antes que los números: la secuencia cambia el orden de evaluación. En la versión ansiosa todos los elementos pasan por la primera etapa antes de que ninguno pase por la segunda; en la perezosa cada elemento atraviesa la cadena entera antes de que empiece el siguiente. Si las etapas tienen efectos observables, registran métricas o dependen del orden, el cambio no es una optimización sino una modificación del comportamiento.
El catálogo, ordenado por daño
No todas las transformaciones que reducen asignaciones cuestan lo mismo en legibilidad, y ordenarlas por esa relación es lo que permite empezar por arriba y detenerse en cuanto el número obtenido es suficiente. En el primer tramo están los cambios que no empeoran el código en absoluto y que a veces incluso lo mejoran: reservar la capacidad de una colección cuyo tamaño final se conoce, sustituir una concatenación acumulativa dentro de un bucle por un constructor mutable, evitar un Int opcional que solo existía para señalar ausencia cuando hay un centinela natural, o mover fuera del bucle una expresión invariante.
// Antes
fun juntar(partes: List<String>): String {
var s = ""
for (p in partes) s += p // cuadratico
return s
}
// Despues: mismo tamano, misma claridad, coste lineal
fun juntarBien(partes: List<String>): String =
buildString { for (p in partes) append(p) }
En el segundo tramo están los cambios que sacrifican algo de expresividad a cambio de una mejora clara y localizada: sustituir una lista de números por un array especializado en la estructura interna de un componente, marcar como insertable una función de orden superior pequeña y muy llamada, o reemplazar una cadena de operadores por un único recorrido manual dentro de una función privada. Estos cambios se justifican, pero deben quedar confinados: nunca deberían aparecer en la firma pública de una API ni obligar a quien la consume a razonar en esos términos.
// Frontera publica idiomatica, interior optimizado y encapsulado
fun estadisticas(muestras: List<Double>): Resumen {
val datos = muestras.toDoubleArray()
var suma = 0.0
var maximo = Double.NEGATIVE_INFINITY
for (v in datos) { suma += v; if (v > maximo) maximo = v }
return Resumen(media = suma / datos.size, maximo = maximo)
}
El encapsulamiento del ejemplo anterior es la parte importante y no el bucle. Una función pública que recibe una lista y devuelve un objeto de dominio puede optimizar su interior tantas veces como haga falta sin obligar a nadie a cambiar nada; una función pública que recibe un DoubleArray porque alguien midió una vez impone esa decisión a todos sus consumidores y a todas sus versiones futuras. La regla que se deduce es que el rendimiento se paga por dentro y la claridad se ofrece por fuera.
En el tercer tramo están los cambios que degradan el código de forma permanente y que solo se justifican con una medición contundente y un comentario que la documente: agrupaciones de objetos reutilizables, estructuras de datos especializadas de terceros, desenrollado manual de bucles o aplanamiento de abstracciones que existían por buenas razones. Cada uno de ellos añade una deuda que alguien pagará dentro de dos años sin saber por qué está ahí, y por eso la medición que los justifica debería quedar escrita junto al código.
Ningún cambio sin un número delante
Hay un corolario incómodo de esa regla y conviene enunciarlo antes de que alguien lo descubra por su cuenta: aplicarla obliga a tener instrumentación antes de tener problemas. Un sistema sin métricas de latencia por percentil, sin perfilado disponible en producción y sin una forma de comparar dos despliegues no puede cumplir la regla, y por tanto no puede optimizar con criterio; solo puede adivinar. Invertir en esa instrumentación es, casi siempre, la decisión de rendimiento con mejor retorno que un equipo puede tomar, y es anterior a cualquier discusión sobre listas y secuencias.
La regla que cierra el nivel es breve y radical: no se modifica código por motivos de rendimiento sin una medición previa que identifique el punto y una medición posterior que confirme la mejora en la métrica que le importa a alguien. Sin la primera, se optimiza el sitio equivocado, que es el desenlace por defecto porque la intuición sobre dónde se va el tiempo es notoriamente mala. Sin la segunda, se acumula complejidad sin saber si compró algo, lo que es peor que no haber tocado nada.
// Comentario que deberia acompanar a todo cambio del tercer tramo
// Sustituido el mapa por dos arrays paralelos.
// Perfil del 2026-03: este metodo suponia el 41 por ciento de las asignaciones
// del servicio y el 12 por ciento del percentil 99 de latencia.
// Tras el cambio: 3 por ciento de asignaciones, percentil 99 un 9 por ciento menor.
// Si esta ruta deja de ser caliente, revertir: la version anterior era mas clara.
Toda la disciplina de este nivel se apoya en una contabilidad que casi nunca se hace explícita, y merece la pena hacerla porque es la que justifica que la regla sea tan estricta. Cuando se evalúa una optimización se compara casi siempre una sola magnitud, el tiempo o la memoria ahorrados, contra cero. Pero el otro lado de la balanza no es cero: es el coste acumulado de que ese código sea más difícil de leer, más difícil de modificar, más fácil de romper y más costoso de explicar a quien llegue después. Ese coste no se paga una vez sino cada vez que alguien abre el fichero, y se paga durante toda la vida del sistema, que suele ser mucho más larga que la vida del cuello de botella que motivó el cambio. Un servicio se reescribe, una dependencia cambia, el volumen de datos se desplaza, y la ruta que era crítica deja de serlo; la agrupación de objetos que se introdujo para salvarla se queda, sin embargo, para siempre, porque nadie se atreve a quitar algo que alguien puso por rendimiento. De ahí se derivan tres consecuencias prácticas que valen más que cualquier truco. La primera es que el orden de exploración importa: siempre hay que agotar los cambios que no cuestan legibilidad antes de tocar los que sí, porque la mayoría de los problemas reales se resuelven en ese primer tramo y quien empieza por el tercero paga la deuda sin necesidad. La segunda es que toda optimización del tramo caro debe llevar escrito su motivo, su medición y su condición de reversión, porque sin eso se convierte en folclore y el folclore no se borra nunca. La tercera, y la más importante, es que la mejor optimización sigue siendo la que elimina trabajo en lugar de acelerarlo: una consulta que no se hace, una estructura que no se recorre dos veces, un resultado que se calcula una vez en lugar de mil. Esas mejoras no compiten con la claridad porque normalmente la aumentan, y sistemáticamente superan en orden de magnitud a cualquier ajuste de asignaciones. Todo lo aprendido en este nivel existe para el caso en que ya se ha eliminado el trabajo evitable y aun así el número no sale. Ese caso es raro, y precisamente por eso conviene llegar a él con las herramientas afiladas y la contabilidad completa.
- Identifica en un proyecto real la ruta que consume más tiempo y comprueba qué fracción del total representa antes de tocar nada.
- Mide una cadena de operadores en versión ansiosa y perezosa con tres tamaños de entrada y escribe dónde está la frontera en tu caso.
- Recorre tu código buscando cambios del primer tramo y aplica todos los que encuentres sin medir, porque no empeoran nada.
- Elige una optimización del tercer tramo que ya exista en tu base de código y averigua si alguien puede justificar hoy por qué está ahí.
- Redacta el comentario de justificación y de reversión que acompañaría a la próxima optimización cara que introduzcas.