wandres.dev
EL LENGUAJE · quince años y un compilador nuevo

Los objetivos de compilación: JVM, Native, Wasm y JS

Un frontend y cuatro backends: qué comparten realmente los objetivos de Kotlin, qué no pueden compartir por naturaleza de la plataforma, y cómo esa asimetría condiciona el diseño del lenguaje y de su biblioteca estándar.

⏱ 15 min

Kotlin es uno de los pocos lenguajes de propósito general que compila a una máquina virtual con recolector de basura, a código máquina nativo, a JavaScript y a WebAssembly, con la misma sintaxis y sin dialectos. Eso no es un detalle de implementación: es una restricción permanente sobre lo que el lenguaje puede prometer. Todo lo que la biblioteca estándar común contiene, y sobre todo lo que no contiene, se explica por esta arquitectura.

🎯 Al terminar esta lección sabrás
  • Describir la arquitectura de un frontend común y varios backends, y qué papel juega K2 en ella.
  • Distinguir qué comparten los cuatro objetivos: sintaxis, semántica y biblioteca común.
  • Identificar qué no pueden compartir: modelo de memoria, concurrencia, reflexión e interoperabilidad.
  • Explicar cómo esa asimetría condiciona decisiones concretas del diseño del lenguaje.

Un frontend, varios backends

Desde la 2.0 el compilador tiene una forma limpia. El frontend K2 hace todo lo que depende del lenguaje y de nada más: analiza la sintaxis, resuelve nombres, infiere tipos y emite diagnósticos. Su resultado es una representación semántica unificada, independiente de la plataforma. Después, un traductor convierte esa representación en la representación intermedia compartida, y a partir de ahí cada backend genera lo suyo.

🟣

Kotlin/JVM

Emite bytecode para la máquina virtual de Java. Objetivo original y el más maduro. La 2.4 soporta como destino hasta Java 26, y la interoperabilidad con el ecosistema Java es total y sin coste.

🟣

Kotlin/Native

Compila a código máquina mediante LLVM, sin máquina virtual. Produce binarios y bibliotecas para iOS, macOS, Linux, Windows y watchOS, con interoperabilidad directa con C y con Objective-C o Swift.

🟣

Kotlin/JS

Genera JavaScript moderno con módulos ES. Solo por la vía del backend basado en IR, que sustituyó por completo al traductor antiguo, con eliminación de código muerto agresiva.

🟣

Kotlin/Wasm

Genera WebAssembly aprovechando la propuesta de recolección de basura de la plataforma, en lugar de arrastrar un recolector propio dentro del binario. Es el objetivo más joven y el de evolución más rápida.

flowchart TD
A[Codigo fuente Kotlin] --> B[Frontend K2: nombres, tipos, diagnosticos]
B --> C[Representacion intermedia comun]
C --> D[Backend JVM: bytecode]
C --> E[Backend Native: LLVM]
C --> F[Backend JS: modulos ES]
C --> G[Backend Wasm: WasmGC]
style B fill:#a6e3a1,color:#11111b
style C fill:#89b4fa,color:#11111b

La importancia de esta forma es política además de técnica. Con un frontend único, una característica nueva del lenguaje se especifica y se comprueba una sola vez; antes de K2 había que reimplementar el análisis por objetivo, y eso hacía que los objetivos jóvenes fueran siempre ciudadanos de segunda.

Qué comparten de verdad

Lo compartido es más de lo que suele suponerse y menos de lo que a veces se promete.

La sintaxis y la semántica del lenguaje son idénticas. No hay directivas de preprocesador ni variantes por plataforma. Las clases de datos, las clases selladas, las funciones de extensión, las funciones inline, los delegados de propiedad y las corrutinas existen exactamente igual en los cuatro sitios.

La biblioteca estándar común ofrece el núcleo verdaderamente portátil: colecciones, secuencias, texto, matemáticas, tiempo con Duration, resultados con Result, y las primitivas de las corrutinas. Lo que está en el paquete común está garantizado en todos los objetivos.

El mecanismo expect y actual cubre el resto. En el código común declaras la forma sin cuerpo y en cada objetivo aportas la implementación. Es una abstracción resuelta en tiempo de compilación, no en ejecución: no hay despacho dinámico ni coste asociado.

// en el conjunto de fuentes común
expect fun identificadorDeDispositivo(): String

// en el conjunto de fuentes de JVM
actual fun identificadorDeDispositivo(): String =
    System.getProperty("os.name") ?: "desconocido"

Los conjuntos de fuentes se organizan además de forma jerárquica, lo que permite compartir código entre subconjuntos de objetivos —por ejemplo entre todos los nativos de Apple— sin duplicarlo ni bajarlo al mínimo común denominador:

src/
  commonMain/      # lo que vale en los cuatro objetivos
  jvmMain/         # bytecode: acceso a todo el ecosistema Java
  appleMain/       # compartido entre iOS, macOS y watchOS
    iosMain/
    macosMain/
  jsMain/
  wasmJsMain/

Esa jerarquía es la diferencia entre un proyecto multiplataforma sano y uno que degenera. Sin ella, cualquier cosa que no valga en los cuatro objetivos hay que repetirla en cada uno; con ella, el código compartido entre los tres sistemas de Apple se escribe una sola vez y sigue viendo la interoperabilidad nativa.

Qué no pueden compartir

Aquí empieza la parte honesta, y es donde se pierde tiempo si no se entiende pronto.

El modelo de memoria. La JVM tiene un recolector generacional maduro; Wasm delega en el recolector del anfitrión; JavaScript usa el del motor; y Kotlin/Native tiene el suyo propio, un recolector con trazado que desde 2022 sustituyó al modelo anterior de congelación de objetos. Aquel modelo antiguo exigía marcar los objetos compartidos entre hilos como inmutables y fue, con diferencia, la mayor fuente de fricción en la historia del soporte multiplataforma. Su retirada es la razón de que hoy el código concurrente común se parezca al de la JVM.

La concurrencia observable. Las corrutinas son comunes, pero los despachadores no. En JavaScript y en Wasm dentro del navegador no hay hilos reales en el sentido clásico: hay un bucle de eventos. El código común que asume paralelismo verdadero funciona en JVM y en Native, y se degrada en el navegador. Por eso lo idiomático es escribir contra la abstracción de suspensión y dejar la elección del despachador en la frontera de la plataforma.

// común: solo suspende, no decide dónde se ejecuta
suspend fun sincronizar(repo: Repositorio) = withContext(dispatcherDeIO) {
    repo.descargar()
}

// declarado en común, resuelto por objetivo
expect val dispatcherDeIO: CoroutineDispatcher

La reflexión. Es completa en la JVM y deliberadamente mínima fuera de ella, porque conservarla entera impediría eliminar código muerto y dispararía el tamaño del binario. Esta es la causa de que el ecosistema Kotlin haya empujado tanto hacia los complementos de compilador: la serialización resuelve en tiempo de compilación lo que en Java se resolvía leyendo anotaciones en ejecución. La restricción de una plataforma acabó produciendo una solución mejor para todas.

La interoperabilidad. Cada objetivo mira hacia un ecosistema distinto: Java, C y Objective-C, JavaScript, y el anfitrión de Wasm. Ese código de frontera nunca es común por definición.

Cómo esa asimetría condiciona el diseño

Lo interesante es que estas restricciones no se quedan en la biblioteca: se filtran hacia arriba y se convierten en características del lenguaje. Cuatro ejemplos concretos, todos rastreables hasta la necesidad de sobrevivir en varios objetivos.

Las clases de valor. La JVM no tiene tipos de valor de usuario, así que Kotlin introdujo las clases envolventes que el compilador elimina en línea cuando puede. Es una solución de compilador precisamente porque no podía apoyarse en una capacidad de la plataforma que solo algunas tienen.

Las funciones inline con parámetros reificados. Nacen del borrado de tipos de la JVM, pero su semántica se definió de forma que funcione igual en Native y en JavaScript, donde el problema original ni siquiera existe. Lo que empezó como parche se convirtió en característica uniforme.

La suspensión en lugar del paralelismo. Ya se ha dicho, y es la decisión más importante: la especificación de las corrutinas habla de suspender y reanudar, nunca de hilos. Esa formulación es la que permite el mismo código en un servidor con cientos de núcleos y en una pestaña de navegador con un solo bucle de eventos.

Los complementos de compilador como mecanismo de extensión. Cuando el lenguaje rechaza las macros y varias plataformas rechazan la reflexión, la única vía que queda para generar código es un complemento que actúe sobre la representación intermedia. Por eso la serialización, la inyección de dependencias y la persistencia se han movido en Kotlin hacia esa vía, y por eso K2 tuvo que estabilizar también la interfaz para esos complementos antes de considerarse terminado.

La plataforma más pobre define el contrato

El principio que gobierna todo el diseño multiplataforma de Kotlin es que el conjunto común solo puede prometer lo que el objetivo más limitado puede cumplir. Suena a limitación y en realidad es un método de diseño con efectos secundarios excelentes. Al no poder apoyarse en la reflexión, el ecosistema construyó complementos de compilador que generan el código de serialización antes de ejecutar nada: más rápido, verificable estáticamente y compatible con la compilación anticipada, incluso en la JVM, donde la reflexión sí estaba disponible. Al no poder asumir hilos reales, las corrutinas se especificaron como suspensión y no como paralelismo, lo que las hace correctas tanto sobre un bucle de eventos como sobre un grupo de hilos. Al no poder asumir un recolector concreto, la biblioteca común evita exponer finalizadores y referencias débiles. La lección de arquitectura es general y trasciende a Kotlin: cuando obligas a tu abstracción a sobrevivir en el entorno más hostil que te importa, terminas con una abstracción más honesta en todos los demás. Lo contrario, diseñar para la plataforma cómoda y portar después, produce capas que mienten sobre lo que garantizan, y esas mentiras se pagan siempre en el peor momento.

⚔️ Cartografía de una abstracción
  1. Elige una funcionalidad concreta, como leer un archivo o generar un identificador aleatorio, y escribe su declaración expect en común y dos implementaciones actual distintas.
  2. Busca en la documentación de la biblioteca estándar tres funciones que existan solo en el objetivo JVM y razona por qué no pudieron subir al conjunto común.
  3. Investiga qué ocurre exactamente al lanzar varias corrutinas en paralelo en Kotlin/JS y contrasta el resultado con el mismo código en la JVM.
  4. Explica por escrito por qué la serialización basada en complemento de compilador es preferible a la basada en reflexión, con al menos tres argumentos independientes del soporte multiplataforma.
  5. Documenta el cambio del modelo de memoria de Kotlin/Native: qué exigía el modelo de congelación, por qué se abandonó y qué código dejó de ser necesario tras el cambio.