Por qué existe inline: el objeto que no llega a nacer
Una función de orden superior corriente paga tres impuestos silenciosos: una instancia por cada lambda que recibe, una llamada virtual por cada invocación y un empaquetado por cada primitivo que atraviesa la frontera genérica. Esta lección describe con precisión qué hace el compilador cuando marca una función como insertable, qué desaparece del código generado, qué aparece a cambio, por qué la ganancia se mide en asignaciones evitadas y no en velocidad bruta de salto, y cuál es el precio exacto que se paga en tamaño de código.
Hay una pregunta que casi nadie se hace al aprender Kotlin y que, sin embargo, es la puerta de entrada a todo este nivel: si insertar el cuerpo de una función en el punto de llamada es una optimización clásica que cualquier compilador moderno sabe hacer solo, y que además la máquina virtual repite en tiempo de ejecución con mejor información que la que tiene el compilador, entonces por qué demonios necesita Kotlin una palabra clave para pedirlo a mano. La respuesta no tiene nada que ver con la velocidad de las llamadas. Tiene que ver con que las funciones de orden superior de este lenguaje reciben objetos, no funciones, y que un objeto hay que crearlo, hay que recolectarlo y hay que atravesarlo con una llamada virtual cada vez que se usa. La palabra inline no existe para acelerar un salto: existe para que el objeto no llegue a nacer. Y como todo lo que se gana en un compilador se paga en otro sitio, este nivel entero consiste en aprender dónde se paga.
- Enumerar los tres costes concretos que introduce una función de orden superior no insertada.
- Describir paso a paso la transformación que aplica el compilador a la función y a sus lambdas literales.
- Distinguir la inserción del cuerpo de la función de la inserción de las lambdas, y saber cuándo solo ocurre la primera.
- Cuantificar el crecimiento del código generado y expresar la regla de decisión que se deriva de él.
Los tres impuestos de una función de orden superior
Partamos de una función completamente ordinaria que recibe un bloque y lo ejecuta un número de veces. Sin ninguna marca, el compilador la traduce con total literalidad: el parámetro es una referencia a un objeto que implementa Function0, y ejecutar el bloque significa llamar a su método invoke a través de una interfaz.
fun repetir(veces: Int, accion: (Int) -> Unit) {
for (i in 0 until veces) accion(i)
}
fun uso(nombres: List<String>) {
var total = 0
repetir(nombres.size) { i -> total += nombres[i].length }
}
El primer impuesto es la asignación. La lambda captura total y nombres, así que no puede reutilizarse una instancia única: cada evaluación de la expresión produce un objeto nuevo, y además la variable mutable capturada se promociona a su propio envoltorio en el montículo. El segundo impuesto es la llamada. Cada vuelta del bucle ejecuta una invocación de interfaz sobre un receptor cuyo tipo dinámico el compilador no conoce; si el mismo punto de llamada recibe lambdas de clases distintas a lo largo de la vida del programa, el compilador de la máquina virtual lo clasifica como polimórfico y renuncia a insertarlo. El tercer impuesto es el empaquetado: el parámetro de invoke es genérico y en la plataforma Java los genéricos se borran hasta la referencia común, de modo que un (Int) -> Unit convierte cada entero en un objeto antes de pasarlo.
Las tres partidas no crecen igual. La asignación de la lambda ocurre una vez por evaluación de la expresión, así que en una función llamada pocas veces es irrelevante. El empaquetado ocurre una vez por invocación, así que domina en cuanto hay un bucle largo dentro. Y la llamada virtual no cuesta casi nada por sí misma, pero bloquea otras optimizaciones posteriores porque impide que la máquina vea el cuerpo real. Antes de discutir si una función debe ser insertable, conviene saber cuál de las tres se está pagando.
Qué hace exactamente el compilador
Marcar la función con inline desencadena dos transformaciones distintas que conviene no confundir, porque solo la segunda es la que justifica la palabra clave. La primera es la inserción del cuerpo de la función: en cada punto donde se la llama, el compilador sustituye la llamada por una copia del cuerpo, renombrando las variables locales para que no choquen con las del entorno de destino. La segunda es la inserción de las lambdas: dentro de esa copia, cada llamada a un parámetro de tipo función se sustituye a su vez por una copia del cuerpo de la lambda literal que se pasó en ese punto de llamada concreto.
inline fun repetir(veces: Int, accion: (Int) -> Unit) {
for (i in 0 until veces) accion(i)
}
// Lo que acaba existiendo en el codigo generado de uso:
fun uso(nombres: List<String>) {
var total = 0
for (i in 0 until nombres.size) total += nombres[i].length
}
La segunda transformación es la que borra los tres impuestos de golpe. No hay objeto que asignar porque no queda ninguna referencia al bloque; no hay envoltorio para total porque la variable no sale del método donde vive y puede seguir en la pila; no hay llamada virtual porque no queda llamada; y no hay empaquetado porque el entero nunca cruza la frontera genérica de invoke. El resultado no es una versión más rápida del mismo código: es un código distinto, del que han desaparecido categorías enteras de trabajo.
flowchart TD
A[Llamada a una funcion de orden superior] --> B{La funcion es inline}
B -- No --> C[Se instancia un objeto FunctionN]
C --> D[Cada uso es una llamada invoke por interfaz]
D --> E[Los primitivos se empaquetan al cruzar]
B -- Si --> F[Se copia el cuerpo de la funcion]
F --> G[Se copia el cuerpo de cada lambda literal]
G --> H[Cero objetos y cero llamadas intermedias]Conviene precisar que la función insertable sigue existiendo también como método de verdad en el artefacto compilado, salvo que se marque de forma explícita para que no lo haga. Ese método es el que ven la reflexión y los lenguajes vecinos de la plataforma, que no entienden la transformación y necesitan algo a lo que llamar. La copia y el método coexisten: la copia es lo que ejecuta el código Kotlin compilado, el método es lo que queda disponible para todo lo demás.
Hay un matiz decisivo que suele pasarse por alto. La segunda transformación solo puede aplicarse cuando el argumento es una lambda escrita literalmente en ese punto de llamada, porque solo entonces el compilador sabe qué código copiar. Si lo que se pasa es una variable de tipo función, una referencia guardada en una propiedad o el resultado de otra expresión, el cuerpo de la función insertable se copia igualmente, pero la invocación del parámetro sigue siendo una llamada real sobre un objeto que ya existía. Se pierde exactamente la parte que compensaba, y se conserva el crecimiento del código.
val guardada: (Int) -> Unit = { println(it) }
fun demostracion() {
repetir(3) { println(it) } // insercion completa: no hay objeto
repetir(3, guardada) // el cuerpo se copia, pero invoke sigue existiendo
}
El precio se paga en tamaño de código
La inserción es una duplicación, y una duplicación multiplica. Una función insertable de veinte instrucciones llamada desde cuarenta sitios distintos deja ochocientas instrucciones en el artefacto en lugar de veinte más cuarenta llamadas. El crecimiento es lineal en el número de puntos de llamada, no en el número de ejecuciones, y por eso una función pequeña llamada muchísimas veces en tiempo de ejecución puede ser una candidata perfecta mientras que una función grande llamada desde muchos sitios del código fuente es una candidata pésima.
Crece el artefacto
Cada punto de llamada recibe una copia del cuerpo. En plataformas con límites de tamaño o de número de métodos, la suma de todas las copias es una magnitud que se acaba notando.
Crece la presión sobre la caché
Más instrucciones distintas ejecutando lo mismo significa peor aprovechamiento de la caché de instrucciones, y ese efecto puede anular la ganancia si la función insertada es voluminosa.
Se congela en quien llama
La copia vive en el código compilado del consumidor. Cambiar el cuerpo de una función insertable no tiene efecto sobre quien ya la compiló hasta que ese consumidor se recompile.
Esa tercera consecuencia se llama fragilidad binaria y merece una advertencia explícita, porque no es un detalle de rendimiento sino de mantenimiento. Al publicar una biblioteca con funciones insertables públicas, su cuerpo pasa a formar parte del contrato: un consumidor compilado contra la versión antigua seguirá ejecutando la versión antigua aunque actualice la dependencia. Es exactamente el mismo fenómeno que ocurre con las constantes de compilación, y por el mismo motivo. De ahí se deriva además la restricción de que una función insertable pública no puede acceder a declaraciones no públicas: el cuerpo copiado acabaría ejecutándose fuera del módulo donde esas declaraciones son visibles.
private fun ayudaInterna() = 42
// Error: la copia acabaria en modulos donde ayudaInterna no es visible
inline fun expuesta(): Int = ayudaInterna()
@PublishedApi
internal fun ayudaPublicada() = 42
inline fun correcta(): Int = ayudaPublicada() // valido y con contrato explicito
La regla de decisión que se deriva de todo esto
Si el beneficio de insertar es eliminar objetos y llamadas asociadas a las lambdas, y el coste es duplicar código por cada punto de llamada, la regla se escribe sola: la marca compensa cuando la función es pequeña y su razón de ser es recibir lambdas, y no compensa cuando es grande o cuando no recibe ninguna. Ese segundo caso es tan común que el propio compilador emite una advertencia al encontrarlo, señalando que el impacto esperado es insignificante y que la inserción funciona mejor con parámetros de tipo función.
// Advertencia del compilador: no hay lambdas que eliminar
inline fun sumar(a: Int, b: Int): Int = a + b
// Legitimo aunque no tenga lambdas: el motivo es reified, no el rendimiento
inline fun <reified T> comoOSiNo(valor: Any?): T? = valor as? T
Junto al rendimiento hay dos motivos independientes para escribir la marca, y ambos son semánticos en lugar de cuantitativos. El primero es habilitar el retorno no local, que convierte una función de biblioteca en algo indistinguible de una estructura de control del lenguaje. El segundo es conservar un parámetro de tipo para poder interrogarlo en tiempo de ejecución, que es el asunto de la tercera lección de este nivel. Cuando la marca se pone por cualquiera de esos dos motivos, la discusión sobre asignaciones evitadas es irrelevante: la palabra clave no es opcional porque sin ella el código simplemente no compila.
inline fun <T> conRecurso(r: AutoCloseable, bloque: () -> T): T =
try { bloque() } finally { r.close() }
fun buscar(): String? {
conRecurso(abrirFichero()) {
return "encontrado" // solo posible porque conRecurso es insertable
}
return null
}
Conviene añadir el argumento que suele faltar en esta discusión. La máquina virtual ya inserta llamadas por su cuenta y lo hace mejor que el compilador, porque decide con datos de ejecución reales y no con una heurística estática. Lo que la máquina no puede hacer es deshacer una asignación que ya ocurrió en un punto donde el análisis de escape no alcanza, ni descubrir el cuerpo de una lambda en un punto de llamada que ve como polimórfico. La palabra clave de Kotlin no compite con el optimizador de la plataforma: le quita de encima un problema que el optimizador no estaba en condiciones de resolver.
El error conceptual más extendido sobre este mecanismo consiste en clasificarlo como una optimización, y hay que desmontarlo porque impide entender todo lo que viene después. Una optimización preserva la semántica y solo altera el coste; la inserción de Kotlin altera la semántica de forma visible y deliberada, y esa alteración es la mitad de su valor. Al copiar el cuerpo de la lambda dentro del método que la escribe, el compilador no está ahorrando una llamada: está haciendo que ese código deje de pertenecer a un método ajeno y pase a pertenecer al método donde el programador lo escribió, con todo lo que eso arrastra. Por eso, y solo por eso, un return desnudo puede abandonar la función envolvente, que es una capacidad reservada en cualquier otro lenguaje a las palabras clave del compilador. Por eso, y solo por eso, un parámetro de tipo puede sobrevivir al borrado y responder a la pregunta de qué tipo es, porque en el punto de llamada el tipo concreto todavía se conoce y la copia se especializa allí. Por eso una función de la librería estándar puede garantizar que un bloque se ejecuta exactamente una vez y permitir que dentro de él se inicialice un valor inmutable declarado fuera. La palabra inline es el mecanismo con el que Kotlin desplaza la frontera entre lo que es lenguaje y lo que es biblioteca: lo que en otros ecosistemas exigiría añadir sintaxis nueva al compilador, aquí se escribe como una función corriente que cualquiera puede definir. Verlo como un truco de rendimiento lleva a usarlo mal en las dos direcciones, salpicando la marca por funciones que no reciben lambdas y quitándola de aquellas cuya semántica depende de ella. Verlo como un cambio de nivel explica de una vez la palabra clave, sus dos modificadores acompañantes, el borrado de tipos y la mitad de la librería estándar.
- Escribe una función de orden superior sin marcar y su versión insertable, llámalas desde un bucle largo y enumera cuántos objetos produce cada una.
- Pasa a una función insertable una lambda literal y después una variable de tipo función. Explica qué parte de la transformación se pierde en el segundo caso.
- Declara una función insertable pública que llame a una privada y traduce el error del compilador al lenguaje de módulos y visibilidad.
- Marca como insertable una función sin parámetros de tipo función y razona por qué el compilador te advierte, apelando a lo que hace la máquina virtual por su cuenta.
- Estima el crecimiento del código de una función insertable de treinta instrucciones llamada desde veinticinco lugares, y decide si la mantendrías.