Los límites: reflexión, tamaño, rendimiento y criterio de elección
Kotlin/Native no es un target incompleto sino un target con otras leyes, y confundir ambas cosas produce equipos que pelean contra restricciones que no van a ceder. Esta lección delimita qué queda exactamente de la reflexión y qué la sustituye, explica por qué un binario nativo parte de varios megabytes y qué se puede hacer al respecto, contrasta con honestidad el rendimiento frente a la JVM separando arranque de rendimiento sostenido, y cierra con un criterio de elección que no depende de preferencias.
Llegado este punto conviene formular la pregunta incómoda: si Kotlin/Native arranca al instante, consume poca memoria y produce binarios autónomos, por qué no se usa para todo. La respuesta no es una sola sino tres, y las tres se derivan de la misma decisión estudiada en la primera lección. Sin máquina virtual no hay metadatos de tipos completos en ejecución, y por tanto no hay reflexión. Sin carga dinámica de clases, todo lo que el programa pueda necesitar tiene que estar dentro del binario, y por tanto el binario abulta. Y sin información de perfil ni recompilación, el optimizador no puede especular, y por tanto el rendimiento sostenido en cargas largas se queda por debajo del de una JVM caliente. Ninguna de las tres es un defecto pendiente de corregir: son el precio, y saber cuál es el precio es lo que permite decidir si compensa.
- Delimitar qué subconjunto de la reflexión sobrevive en Native y qué mecanismos de tiempo de compilación lo sustituyen.
- Explicar de dónde sale el tamaño de un binario nativo y aplicar las reducciones que de verdad funcionan.
- Comparar rendimiento frente a la JVM separando arranque, latencia, rendimiento sostenido y memoria.
- Decidir con criterio explícito cuándo Native es la elección correcta y cuándo no lo es.
La reflexión que no existe
En la JVM, cada clase cargada arrastra una descripción completa de sí misma que el programa puede consultar y usar para construir instancias, invocar métodos por nombre o fabricar implementaciones de una interfaz en tiempo de ejecución. Ese aparato es lo que sostiene la inyección de dependencias clásica, la serialización basada en anotaciones leídas en ejecución y buena parte de los marcos de trabajo del ecosistema. En Native, mantener esos metadatos para todo el programa contradiría la eliminación de código muerto que hace viable el tamaño del binario, así que sencillamente no están.
Lo que sobrevive es un núcleo deliberadamente pequeño: el operador de clase devuelve un KClass con nombre simple, nombre cualificado y comprobación de instancia, y los genéricos materializados con reified junto a typeOf siguen funcionando porque se resuelven en compilación. Lo que no existe es enumerar miembros, construir por nombre, invocar dinámicamente o fabricar delegados en ejecución.
// Funciona en Native: se resuelve en compilacion.
inline fun <reified T : Any> nombre(): String = T::class.simpleName ?: "anonimo"
@Serializable
data class Perfil(val id: String, val edad: Int) // el plugin genera el serializador
fun decodificar(json: String): Perfil = Json.decodeFromString(json)
// No existe en Native: requeriria metadatos completos en ejecucion.
// val instancia = Class.forName(nombre).getDeclaredConstructor().newInstance()
// val miembros = tipo.members.map { it.name }
La sustitución es sistemática y vale la pena verla como un patrón general: todo lo que la JVM resolvía leyendo metadatos en ejecución, Native lo resuelve generando código en compilación. La serialización pasa de leer anotaciones a que un plugin del compilador emita el serializador. El procesamiento de anotaciones pasa a la generación de fuentes. La inyección de dependencias pasa a grafos verificados en compilación en lugar de contenedores que resuelven por tipo en tiempo de ejecución. El resultado suele ser más rápido y detecta antes los errores; el coste es que ya no se puede improvisar en ejecución.
Vista con distancia, la restricción empuja hacia un estilo mejor. Un registro de tipos construido por reflexión al arrancar es un grafo que nadie ha verificado y cuyos errores aparecen como fallos en tiempo de ejecución en el peor momento posible; el mismo grafo expresado explícitamente compila o no compila. La incomodidad es real —hay que escribir la fábrica que antes se deducía sola— pero lo que se pierde es sobre todo la capacidad de equivocarse tarde.
// El patron de sustitucion: lo que la JVM deduciria en ejecucion se declara.
interface Codec<T> { fun leer(json: String): T }
object Registro {
private val codecs = mapOf<KClass<*>, Codec<*>>(
Perfil::class to object : Codec<Perfil> {
override fun leer(json: String) = Json.decodeFromString<Perfil>(json)
},
)
inline fun <reified T : Any> codec(): Codec<T> =
@Suppress("UNCHECKED_CAST") (codecs[T::class] as Codec<T>)
}
La restricción que decide proyectos rara vez es la reflexión en sí, sino su corolario: en Native solo puedes usar bibliotecas multiplataforma. Ni una sola dependencia pensada para la JVM va a funcionar, por trivial que sea. Antes de discutir arranques y tamaños, la pregunta útil es si las cinco o seis bibliotecas que sostienen tu proyecto tienen versión multiplataforma.
El tamaño del binario
Un programa que solo imprime una línea produce un binario de varios megabytes, y la reacción de sorpresa se disipa al enumerar qué contiene: la biblioteca estándar de Kotlin en las partes alcanzables, el runtime con el gestor de memoria y el recolector, las tablas de tipos necesarias para las comprobaciones y el desenrollado de pila, y el soporte de excepciones. No hay nada equivalente a una máquina virtual instalada aparte que aporte todo eso; si el binario lo necesita, el binario lo lleva.
Las reducciones que funcionan son pocas y conocidas. Compilar en modo publicación activa la eliminación agresiva de código inalcanzable, que es con diferencia la que más recorta. Eliminar los símbolos de depuración del artefacto final quita un porcentaje considerable que solo sirve para depurar. Enlazar el framework de forma estática evita el coste de una biblioteca dinámica separada. Y podar dependencias es lo más eficaz de todo, porque cada klib que entra arrastra su parte alcanzable al binario.
Esa última observación tiene una consecuencia que conviene subrayar: la eliminación de código muerto funciona por alcanzabilidad desde la superficie exportada, así que exportar de más engorda de más. Una función pública que nadie usa desde fuera pero que figura en el framework generado ancla todo su grafo de dependencias, y con él la parte de cada biblioteca que ese grafo toca. Reducir deliberadamente lo exportado no es solo higiene de API, es una de las palancas de tamaño más efectivas y de las menos usadas.
También conviene desactivar expectativas sobre lo que no va a ocurrir. El tamaño base no baja con la escala del proyecto: los primeros megabytes son el runtime y la parte inevitable de la biblioteca estándar, y se pagan igual en un programa de diez líneas que en uno de cien mil. La curva es plana al principio y luego crece despacio, lo que significa que Native penaliza mucho a los proyectos pequeños y apenas a los grandes, exactamente al revés de lo que la intuición sugiere.
El número que importa no es el tamaño del binario recién compilado sino el del artefacto instalado: para una aplicación de iOS, el crecimiento del paquete final con el framework incorporado y ya comprimido. La diferencia entre ambas cifras es habitualmente de un factor considerable y discutir la primera lleva a optimizar lo que nadie descarga.
El rendimiento comparado
Comparar Native con la JVM en una sola cifra no significa nada porque las curvas se cruzan. Hay cuatro ejes y en cada uno gana uno distinto.
En arranque gana Native sin discusión: no hay carga de clases, no hay verificación de bytecode y no hay interpretación inicial, así que el primer milisegundo de ejecución ya corre a velocidad definitiva. En memoria residente gana Native también, porque no mantiene metadatos de clases ni código compilado dinámicamente ni las estructuras de un compilador dentro del proceso. En previsibilidad de la latencia suele ganar Native, especialmente ahora que el marcado del recolector es concurrente y la pausa dejó de escalar con el montículo.
En rendimiento sostenido gana la JVM en la mayoría de las cargas largas, y por razones concretas más que por magia. Sus recolectores llevan dos décadas de trabajo y su ruta de reserva es extraordinariamente barata; su compilador de servidor inserta y especializa usando perfiles reales del despliegue concreto; y puede apostar a que un punto de llamada es monomórfico porque siempre tiene la opción de desoptimizar si se equivoca. El compilador anticipado de Native ve el programa entero pero no ve ni un dato de ejecución, y no puede retractarse de nada.
Medir esto sin engañarse exige disciplina, porque los dos errores clásicos empujan en direcciones opuestas. Comparar un binario de depuración de Native contra una JVM caliente exagera la ventaja de la máquina virtual; medir el tiempo interno de un bucle ignorando el arranque del proceso exagera la de Native. La comparación honesta mide el proceso completo, desde fuera, en modo publicación, y con la misma entrada.
fun main(args: Array<String>) {
val n = args.firstOrNull()?.toIntOrNull() ?: 1
// Medir desde fuera: el arranque forma parte del coste real del proceso.
val marca = kotlin.system.getTimeNanos()
val resultado = (0 until n).sumOf { trabajo(it) }
println("$resultado en ${(kotlin.system.getTimeNanos() - marca) / 1_000_000} ms internos")
}
Queda un eje que las comparativas suelen omitir y que en la práctica cuesta más que cualquier diferencia de nanosegundos: el instrumental. En la JVM hay un ecosistema maduro de perfiladores, grabadores de eventos y herramientas de análisis de volcados de memoria que funcionan en producción con sobrecarga mínima. En Native, el perfilado se hace con las herramientas nativas de cada plataforma, las trazas de pila necesitan simbolización para ser legibles, y el análisis de fugas es considerablemente más artesanal. Si tu operación depende de diagnosticar en caliente, eso pesa.
flowchart TD A[Necesitas un binario sin maquina virtual] --> B[El proceso vive milisegundos o segundos] B -->|Si| C[Native encaja bien] B -->|No| D[Es el lado iOS de un modulo compartido] D -->|Si| E[Native es la unica via] D -->|No| F[Dependes de bibliotecas solo de JVM] F -->|Si| G[Descarta Native] F -->|No| H[La carga es larga y muy caliente] H -->|Si| I[La JVM rinde mas en sostenido] H -->|No| C
Hay un matiz que evita conclusiones apresuradas: si lo único que buscabas era arranque y binario autónomo pero tu código y tus dependencias son de la JVM, la comparación honesta no es contra la JVM sino contra la compilación anticipada de imágenes nativas del propio ecosistema JVM, que conserva las bibliotecas a cambio de sus propias restricciones sobre reflexión. Kotlin/Native compite en el hueco donde eso tampoco sirve: cuando el destino es una plataforma Apple, cuando hay que integrarse en un anfitrión escrito en C, o cuando el módulo es compartido.
Cuándo Native es la elección correcta
Los cuatro casos siguientes cubren casi todas las decisiones reales, y merece la pena leerlos como umbrales y no como preferencias. El primero es forzoso, el segundo y el tercero son claros, y el cuarto es el que más veces se elige mal.
El lado Apple de un módulo compartido
El caso dominante y el único donde no hay alternativa. Aquí la pregunta no es si conviene Native sino cómo diseñar bien su frontera.
Procesos efímeros
Herramientas de línea de comandos, ganchos, funciones de vida corta. Todo lo que muere antes de que un compilador dinámico llegue a calentarse.
Anfitriones nativos
Bibliotecas que se cargan dentro de un proceso escrito en C o Swift, donde arrastrar una máquina virtual entera no es una opción realista.
Servicios de larga vida
El caso donde normalmente no compensa: ecosistema restringido, rendimiento sostenido menor y ninguna ventaja que el arranque pueda compensar.
Queda una situación intermedia que genera discusiones largas: el servicio que se despliega en una plataforma de ejecución bajo demanda, donde los procesos nacen y mueren con cada petición y el arranque forma parte de la latencia percibida. Ahí la ventaja de Native es real y grande, pero la pregunta previa sigue mandando: si la lógica depende de bibliotecas del ecosistema JVM, la comparación pertinente no es contra la máquina virtual sino contra las imágenes nativas anticipadas de ese mismo ecosistema, que conservan las dependencias a cambio de imponer sus propias restricciones sobre lo dinámico.
Elige Native si el proceso vive poco, si el destino es una plataforma Apple dentro de un módulo compartido, o si hay que incrustarse en un anfitrión nativo. Descártalo si dependes de bibliotecas que solo existen para la JVM, si la carga es larga y muy caliente, o si tu operación necesita el instrumental de diagnóstico de la máquina virtual. Todo lo demás es afinado.
La discusión sobre compilación anticipada frente a máquina virtual arrastra una carga ideológica que no le corresponde y que empuja a los equipos a elegir por identidad en lugar de por medida: hay quien considera que un binario nativo es intrínsecamente más serio y quien considera que renunciar a un ecosistema de veinticinco años es una imprudencia, y ambas posiciones se sostienen sin mirar un solo número. Lo que este nivel debería haber dejado montado es que la elección se reduce, casi sin residuo, a dos parámetros observables. El primero es la duración del proceso, que determina qué fracción de su vida pasa un programa por debajo de su velocidad definitiva: un binario nativo corre a velocidad máxima desde la primera instrucción y por eso gana por goleada cuando el proceso vive milisegundos, mientras que una máquina virtual necesita ejecutar código muchas veces para justificar el coste de optimizarlo y por eso gana cuando el proceso vive semanas ejecutando el mismo bucle sobre datos con una distribución estable. Ese cruce existe, es medible en tu carga concreta con una tarde de trabajo, y ninguna opinión lo mueve. El segundo parámetro es la lista de dependencias, y actúa como una eliminatoria previa más que como un factor a ponderar: si tu proyecto se apoya en tres bibliotecas que solo existen para la JVM, el debate sobre arranque y tamaño es puramente ornamental porque el código no va a compilar, y la única discusión real es si reescribir esas piezas compensa lo que ganas. Aplicado con honestidad, este par de preguntas expulsa a Kotlin/Native de la mayoría de los servicios de servidor, lo instala sin competencia en el lado Apple del código compartido, lo hace muy atractivo en herramientas de línea de comandos y en bibliotecas incrustadas en anfitriones nativos, y deja una franja intermedia donde la respuesta depende de números concretos. Un ingeniero maduro no defiende el target, lo sitúa: sabe que la restricción de reflexión es la contrapartida de la eliminación de código muerto que hace viable el binario, que el tamaño es la contrapartida de no tener una máquina virtual instalada, y que el rendimiento sostenido menor es la contrapartida de que el usuario no pague nunca por optimizar. Todo el nivel ha sido, en el fondo, la misma frase repetida desde cuatro ángulos: cuando quitas la máquina virtual, no desaparece el trabajo que hacía, solo cambia de sitio.
- Escribe un programa que resuelva una tarea real y compílalo para JVM y para Native. Mide el tiempo total del proceso para una entrada pequeña y para una mil veces mayor. Localiza el punto de cruce.
- Mide la memoria residente máxima de ambas versiones en las dos entradas y explica la diferencia.
- Reduce el binario nativo en pasos —modo publicación, eliminación de símbolos, poda de dependencias— y anota cuántos bytes aporta cada paso.
- Toma una pieza de código que use reflexión en la JVM y reescríbela para Native con generación en compilación. Compara ambas versiones en líneas, rendimiento y momento en que se detectan los errores.
- Escribe media página justificando el target de un proyecto tuyo usando solo los dos parámetros de la sección final. Si no puedes decidir con ellos, identifica qué medida te falta.