Cuándo no usar inline: la explosión de código
La marca de inserción tiene un coste que crece con el número de puntos de llamada y con el tamaño del cuerpo, y ese coste se paga en el artefacto compilado, en el tiempo de compilación, en la caché de instrucciones, en la legibilidad de las trazas de error y en la compatibilidad binaria de una biblioteca. Esta lección reúne los criterios negativos, explica por qué marcar una función sin lambdas casi nunca compensa frente a lo que ya hace la máquina virtual, y propone el patrón que permite conservar la ganancia sin pagar la duplicación.
Toda herramienta que mejora algo sin pedir nada aparente termina usándose de más, y la marca de inserción es un caso de manual. Se aprende que elimina asignaciones, se comprueba que en un microensayo la versión marcada gana, y a partir de ahí la palabra empieza a aparecer delante de funciones que no reciben ninguna lambda, delante de cuerpos de cincuenta líneas y delante de métodos públicos de bibliotecas que después no se podrán cambiar. El resultado es un artefacto más grande, una compilación más lenta, unas trazas de error peores y, con frecuencia, un programa que va igual o algo peor que antes. Esta lección es la contraparte necesaria de las tres anteriores: la lista de razones para no escribir la palabra, ordenadas de la más medible a la más insidiosa, y el criterio positivo que queda cuando se han descontado todas.
- Cuantificar el crecimiento del código generado en función del tamaño del cuerpo y del número de puntos de llamada.
- Explicar por qué insertar una función grande puede empeorar el rendimiento en lugar de mejorarlo.
- Justificar por qué marcar una función sin parámetros de tipo función casi nunca aporta nada frente al optimizador de la plataforma.
- Aplicar el patrón que conserva el ahorro de asignaciones sin duplicar el cuerpo completo en cada llamada.
La aritmética de la duplicación
El cálculo es elemental y por eso resulta fácil de ignorar. Si el cuerpo de una función ocupa una cantidad de instrucciones y se la llama desde un número de lugares distintos del código fuente, el artefacto compilado contendrá el producto de ambas magnitudes en lugar de la suma. Con cuerpos de tres o cuatro instrucciones el producto es irrelevante; con cuerpos de cuarenta y decenas de puntos de llamada, la diferencia se mide en decenas de miles de instrucciones que no existían.
// Cuerpo pequeno, muchas llamadas: la duplicacion es despreciable
inline fun <T> T.tambien(bloque: (T) -> Unit): T { bloque(this); return this }
// Cuerpo grande, muchas llamadas: la duplicacion es el problema
inline fun procesarLote(datos: List<Registro>, filtro: (Registro) -> Boolean) {
// cuarenta lineas de validacion, normalizacion y escritura
}
Esa magnitud no es solo estética. En plataformas con límites duros de tamaño de artefacto o de número de métodos por unidad, la suma de todas las copias es una restricción real que aparece de golpe cuando ya es tarde. Y el tiempo de compilación crece por partida doble: el compilador debe transportar y reescribir el cuerpo en cada punto, y las fases posteriores trabajan sobre un código intermedio mayor.
Hay además un efecto de segundo orden que multiplica el cálculo anterior y que casi nadie contabiliza: la duplicación es transitiva. Si una función insertable llama a otra función insertable, el cuerpo de la segunda entra dentro de la copia de la primera antes de que esa copia se replique en cada punto de llamada. Con tres niveles de anidamiento y cuerpos moderados, el volumen final deja de ser una suma para convertirse en un producto de productos, y la magnitud resultante ya no se puede estimar de cabeza.
inline fun nivelUno(b: () -> Unit) { preparar(); nivelDos(b); limpiar() }
inline fun nivelDos(b: () -> Unit) { validar(); nivelTres(b) }
inline fun nivelTres(b: () -> Unit) { medir { b() } }
// Cada punto de llamada de nivelUno recibe los tres cuerpos anidados
Es el malentendido más caro de este nivel. Una función insertable llamada desde un único sitio y ejecutada mil millones de veces no duplica nada: hay una sola copia. Una función insertable llamada desde ochenta sitios y ejecutada tres veces en total produce ochenta copias. El coste es una propiedad del código fuente, la ganancia es una propiedad de la ejecución, y las dos hay que estimarlas por separado porque no tienen nada que ver la una con la otra.
Por qué un cuerpo grande insertado puede ir más despacio
Aquí es donde el razonamiento intuitivo falla. La máquina virtual mantiene su propia política de inserción en tiempo de ejecución, y esa política tiene umbrales de tamaño: los métodos pequeños se insertan casi siempre, los medianos se insertan si se ejecutan con frecuencia suficiente, y los que superan cierto volumen no se insertan nunca. Cuando el compilador de Kotlin copia un cuerpo voluminoso dentro de un método que ya era grande, el método resultante puede cruzar ese umbral y quedar excluido de optimizaciones que antes recibía.
flowchart TD
A[Funcion marcada como insertable] --> B{Cuerpo pequeno}
B -- Si --> C[El metodo destino sigue bajo el umbral]
C --> D[La maquina lo optimiza y compila a codigo nativo]
B -- No --> E[El metodo destino crece por encima del umbral]
E --> F[Se descarta la insercion en tiempo de ejecucion]
F --> G[Peor uso de la cache de instrucciones]El umbral no es una cifra que convenga memorizar, entre otras cosas porque depende de la implementación y de la configuración, pero sí conviene retener su orden de magnitud: los métodos que se insertan sin condiciones son diminutos, del tamaño de un acceso o de una operación aritmética, y los que se insertan por frecuencia son todavía modestos. Un cuerpo de cuarenta líneas de Kotlin está lejísimos de cualquiera de los dos, y al inyectarlo dentro de un método caliente lo que se consigue es sacar de la zona optimizable a un método que antes estaba dentro.
A ese efecto se suma el de la memoria caché de instrucciones. Un bucle que cabía entero en la caché deja de caber cuando se le inyectan cuarenta líneas repetidas, y el coste de los fallos de caché supera con facilidad el de las asignaciones que se pretendía evitar. Ninguno de los dos fenómenos se observa en un ensayo aislado con un único punto de llamada, que es precisamente el escenario donde se suelen tomar estas decisiones. Un ensayo honesto tendría que reproducir la dispersión real de llamadas del programa, y como eso casi nunca es viable, la conclusión práctica es desconfiar de la medición y volver al criterio estructural: tamaño del cuerpo y número de sitios desde donde se invoca.
// Mide algo que no existe en produccion: un unico punto de llamada
fun ensayo() {
repeat(10_000_000) { procesarLote(datos) { it.valido } }
}
Insertar sin lambdas: el caso que casi nunca compensa
Cuando la función no recibe ningún parámetro de tipo función, la marca deja de tener aquello que la justificaba. No hay ningún objeto que evitar, ningún retorno no local que habilitar y ningún parámetro de tipo que conservar; queda únicamente el ahorro de la llamada, que es exactamente el trabajo que la máquina virtual hace por su cuenta y mejor, porque decide con perfiles de ejecución reales en lugar de con una heurística estática. El compilador de Kotlin lo sabe y emite una advertencia diciendo que el impacto esperado es insignificante.
inline fun cuadrado(x: Int) = x * x // advertencia: no aporta nada
inline fun <reified T> comoOSiNo(v: Any?): T? = v as? T // legitimo: hay reified
@Suppress("NOTHING_TO_INLINE")
inline operator fun Punto.plus(otro: Punto) = Punto(x + otro.x, y + otro.y)
Conviene enunciar el argumento en su forma más fuerte, porque suele encontrarse resistencia. El compilador de Kotlin decide insertar antes de saber nada sobre la ejecución: no conoce la frecuencia de cada llamada, no conoce el tipo dinámico de los receptores y no puede rehacer la decisión si resulta equivocada. La máquina virtual decide después de haber contado ejecuciones reales, conoce los tipos que han pasado por cada punto y puede descartar y rehacer la compilación cuando las suposiciones dejan de cumplirse. Competir con eso desde una anotación estática es, en el caso general, una mala apuesta; lo que la anotación aporta es lo que la máquina no puede hacer, que es no crear un objeto que ya se creó.
La tercera forma del ejemplo muestra la excepción razonable. Hay funciones diminutas, típicamente accesos y operadores de una sola expresión, donde la marca se pone por motivos que no son de rendimiento: evitar un método sintético en una biblioteca, mantener estable la forma del código generado o permitir que una llamada se resuelva sin intermediarios en un contexto donde eso importa. En esos casos la advertencia se silencia de forma explícita, que es la manera correcta de dejar constancia de que la decisión fue deliberada.
Compilación más lenta
El cuerpo se transporta y reescribe en cada punto de llamada, y las fases posteriores trabajan sobre más código intermedio. Con muchas funciones grandes marcadas, se nota en cada compilación incremental.
Trazas peores
Las líneas de la traza de error apuntan al método donde acabó la copia, no a la función original. Depurar una función insertable llamada desde muchos sitios es notablemente más incómodo.
Contrato binario congelado
En una biblioteca, el cuerpo insertable pasa a formar parte de la interfaz publicada. Cambiarlo no afecta a quien ya compiló, y no puede tocar declaraciones no públicas sin marcarlas.
El patrón que resuelve la tensión
Cuando una función necesita recibir lambdas pero su cuerpo es grande, no hay que elegir entre las dos cosas: se separan. Se deja una función insertable mínima que solo hace lo que exige la marca, es decir, invocar los bloques y resolver los parámetros de tipo conservados, y se delega todo el volumen en una función corriente que no se duplica en ningún sitio. La copia que llega a cada punto de llamada es entonces de unas pocas instrucciones, y el ahorro de asignaciones se conserva íntegro.
inline fun <reified T> leerConfiguracion(
ruta: String,
alFallar: (Throwable) -> T
): T = try {
leerYConvertir(ruta, T::class.java) as T
} catch (e: Throwable) {
alFallar(e)
}
// Todo el volumen vive aqui y se compila una sola vez
private fun leerYConvertir(ruta: String, tipo: Class<*>): Any { /* muchas lineas */ }
La división tiene además una ventaja de mantenimiento que compensa por sí sola el rodeo. La parte marcada es lo único que queda congelado en el código de los consumidores, y al reducirla a tres líneas se reduce en la misma proporción lo que ya no se podrá cambiar sin recompilar a todo el mundo. La parte corriente conserva plena libertad para evolucionar, puede acceder a declaraciones internas sin ninguna anotación adicional y aparece con su propio nombre en las trazas de error, que es exactamente donde interesa que aparezca.
// Lo que se congela: tres lineas. Lo que evoluciona: todo lo demas
inline fun <T> medirYRegistrar(etiqueta: String, bloque: () -> T): T {
val inicio = System.nanoTime()
try { return bloque() } finally { registrar(etiqueta, System.nanoTime() - inicio) }
}
private fun registrar(etiqueta: String, nanos: Long) { /* formato, filtros, destinos */ }
El criterio final se puede enunciar en una frase que cabe en la cabeza. Marca una función como insertable si recibe lambdas y su cuerpo es pequeño, o si necesita conservar un parámetro de tipo; deja el cuerpo grande fuera, en una función normal a la que la insertable llama. Todo lo demás, incluida la sospecha de que ahorrar una llamada podría ayudar, es trabajo que ya hace otro y que aquí solo produce artefactos más grandes.
Hay una asimetría en este mecanismo que rara vez se enuncia y que cambia por completo la forma de decidir. Cuando se optimiza un cuerpo de función, se puede volver atrás: nadie fuera se entera, porque el contrato era la firma y la firma no cambió. Cuando se marca una función como insertable, lo que se está modificando es el contrato mismo, y la marcha atrás tiene consecuencias visibles para todo el que la usaba. Quitar la palabra rompe cualquier retorno no local escrito por los consumidores, invalida cualquier parámetro de tipo conservado que dependiera de ella y obliga a recompilar a todo el mundo para que el cambio tenga efecto. Añadirla más tarde, en cambio, no rompe nada pero congela el cuerpo desde ese momento y arrastra la restricción de no poder tocar lo que no sea público. Es decir: la decisión pertenece al mismo estrato que elegir si un parámetro es nulable o si una clase es abierta, y merece el mismo cuidado y el mismo momento, que es el del diseño y no el de la afinación posterior. De ahí se sigue la disciplina práctica que conviene adoptar: no se marca una función porque un perfilador señale una asignación, sino porque su razón de existir es recibir un bloque y ejecutarlo en el sitio, y esa razón se conoce al escribirla. Si aparece después una necesidad de rendimiento en una función que no se diseñó así, la respuesta correcta casi nunca es añadir la palabra, sino mirar dónde se está evaluando la expresión lambda y moverla. La abstracción no cuesta cara por existir; cuesta cara por instanciarse en el sitio equivocado, y ninguna palabra clave arregla un diseño que pone la creación de objetos dentro del bucle más caliente del programa.
- Busca en tu código una función insertable sin parámetros de tipo función, quítale la marca y explica qué se ha perdido exactamente.
- Estima el crecimiento en instrucciones de una función insertable de cincuenta líneas llamada desde treinta puntos, y compáralo con el ahorro de asignaciones.
- Divide una función insertable grande en una parte mínima marcada y una parte voluminosa corriente. Justifica qué se queda en cada lado.
- Provoca una excepción dentro de una lambda pasada a una función insertable y describe qué aparece en la traza y qué falta.
- Argumenta por qué añadir o quitar la marca en una biblioteca publicada no es simétrico, y qué implica cada dirección para los consumidores.