Por qué se reescribió el compilador
Reescribir el frontend de un lenguaje con quince años de código en producción no se hace por elegancia: se hace cuando la arquitectura vieja impide construir lo siguiente. Esta lección reconstruye los límites reales del compilador antiguo, el mapa lateral de descriptores que sostenía toda la semántica, la duplicación estructural entre el compilador y el IDE que costaba dos implementaciones de cada regla, y qué desbloqueó exactamente K2 para todo el ecosistema cuando pasó a ser el compilador por defecto en Kotlin 2.0.
Un compilador no se reescribe porque el código sea feo. Se reescribe cuando la forma en que representa el programa deja de admitir las preguntas que hay que hacerle. El frontend original de Kotlin funcionó durante una década y compiló todo Android, pero guardaba el significado del programa fuera del árbol, en un mapa lateral que se rellenaba bajo demanda, y esa decisión —razonable en 2011— acabó siendo la pared contra la que chocaban la velocidad, la paralelización, los plugins de compilador y la posibilidad de que el IDE y el build dijeran lo mismo. K2 no es el compilador antiguo optimizado: es otra representación del programa, con otras propiedades, y casi todo lo que Kotlin ha podido hacer desde 2024 cuelga de ese cambio.
- Explicar por qué la arquitectura de descriptores y
BindingContextlimitaba estructuralmente al frontend antiguo. - Describir la duplicación entre el compilador y el IDE, y qué coste real tenía para el lenguaje.
- Situar
FIRcomo una representación con fases explícitas y por qué eso cambia las reglas del juego. - Enumerar qué capacidades concretas del ecosistema quedaron desbloqueadas por la reescritura.
La pared del compilador antiguo
El frontend clásico, el que hoy llamamos K1, partía del árbol sintáctico de IntelliJ, el PSI, y construía a partir de él una red de descriptores: objetos que describían clases, funciones y propiedades con su semántica ya resuelta. El vínculo entre un nodo del árbol y su descriptor no vivía en el nodo, sino en una tabla externa llamada BindingContext, organizada en rodajas: dame el tipo de esta expresión, dame la función a la que resuelve esta llamada, dame el descriptor de esta declaración.
Ese diseño tiene una virtud enorme y un defecto fatal. La virtud es la pereza: nada se calcula hasta que alguien lo pregunta, lo cual es exactamente lo que un IDE necesita para abrir un fichero de un proyecto de diez millones de líneas sin analizarlo entero. El defecto es que la pereza convierte el orden de las preguntas en parte de la semántica. Si el tipo de una expresión se calcula la primera vez que se pide, y ese cálculo dispara la resolución de otra declaración, que a su vez pide un tipo todavía no calculado, aparecen ciclos que hay que romper con marcas de recursión y con valores de error. El resultado es un sistema correcto en la práctica pero cuyo estado intermedio nadie puede describir: no existe el momento en que el programa está medio resuelto de una manera bien definida.
De ahí salen tres consecuencias que se pagaron durante años. La primera es que paralelizar era inviable: un mapa global mutable rellenado por demanda recursiva no se reparte entre hilos sin reescribirlo. La segunda es que la reutilización entre compilaciones era muy limitada, porque no había un estado estable que guardar. La tercera, la más cara, es que extender el frontend desde fuera resultaba casi imposible: un plugin que quisiera declarar métodos nuevos tenía que fabricar descriptores sintéticos y colarlos en la red por rendijas no diseñadas para eso, y cada versión menor podía mover esas rendijas.
Los usuarios no percibían nada de esto como arquitectura, sino como rarezas: un smart cast que se negaba a aplicarse en un caso que parecía obvio, una inferencia que fallaba al pasar una lambda a un constructor genérico, un error que el IDE marcaba en rojo y el build aceptaba sin protestar. Todas esas anécdotas tienen la misma raíz.
Conviene aterrizar esto en algo que hayas sufrido. El siguiente fragmento era rechazado por el frontend antiguo y es aceptado por K2, y la diferencia no está en ninguna regla nueva del lenguaje sino en que el análisis de flujo pasó a vivir en el árbol y a poder acumular hechos a través de fronteras que antes lo cortaban.
interface Nodo
class Hoja(val valor: Int) : Nodo
fun leer(n: Nodo?): Int {
if (n !is Hoja) return 0
// K1 perdia el hecho al cruzar el retorno temprano en algunos casos;
// K2 lo conserva porque el grafo de flujo es parte de la resolucion.
return n.valor
}
El detalle que importa no es el ejemplo concreto, que además cambió a lo largo de las versiones, sino la naturaleza del arreglo. En K1, cada mejora del análisis de flujo era una excepción añadida a mano a un motor que no tenía una noción unificada de «lo que se sabe en este punto del programa». En K2 esa noción existe como estructura, y las mejoras dejan de ser parches para ser consecuencias.
FIR, la representación del nuevo frontend, invierte la decisión central. El significado vive dentro del árbol, no en un mapa lateral, y el árbol atraviesa una secuencia de fases numeradas y públicas: primero se construye el FIR crudo, casi un calco de la sintaxis; luego se resuelven las importaciones; luego los supertipos; luego los tipos escritos explícitamente; luego el estado de cada declaración —visibilidad, modalidad, modificadores—; luego los contratos; luego los tipos implícitos de las declaraciones sin anotar; y por último los cuerpos. Cada fase deja el árbol en un estado que se puede nombrar, comprobar y, crucialmente, alcanzar para todo un módulo antes de pasar a la siguiente.
flowchart LR A[PSI o LightTree] --> B[FIR crudo] B --> C[Importaciones y supertipos] C --> D[Tipos y estado] D --> E[Contratos y tipos implicitos] E --> F[Resolucion de cuerpos] F --> G[Checkers y diagnosticos]
Esa granularidad es lo que permite lo demás. Se puede resolver por fases en paralelo, se puede parar en una fase concreta y preguntar por el estado del árbol, se puede volcar a disco, y se pueden insertar generadores de declaraciones en un punto conocido del proceso en lugar de en un hueco improvisado.
Dos compiladores para un solo lenguaje
El segundo motivo de la reescritura es menos técnico y más organizativo, y probablemente pesó más. Kotlin nació dentro de un IDE, y durante años el IDE y el compilador compartieron el frontend solo a medias. El compilador de línea de órdenes analizaba módulos completos de una vez; el IDE analizaba ficheros sueltos, en caliente, con el código a medio escribir y sin garantía de que el resto del proyecto compilase. Servir a esos dos consumidores con la misma implementación exigía tantas adaptaciones que en la práctica había dos caminos de código conviviendo, y encima el complemento de Kotlin para IntelliJ mantenía sus propias implementaciones de reglas que el compilador ya conocía.
El precio de esa duplicación se pagaba en tres monedas. La primera, la evidente: cada regla nueva del lenguaje había que implementarla dos veces, y hasta que la segunda implementación llegaba, el IDE y el build discrepaban. La segunda, más sutil: refactorizaciones e inspecciones tenían que reconstruir por su cuenta información que el compilador ya calculaba, con su propia noción aproximada de resolución. La tercera, la decisiva: cualquier propuesta de característica del lenguaje llevaba implícito un coste doble de implementación, y eso frena el diseño.
Hay una asimetría en esa relación que explica por qué el problema no se resolvió antes con disciplina. El compilador analiza un módulo entero, de una vez, con la garantía de que el código está completo y de que las dependencias existen; puede permitirse el lujo de fallar ante cualquier inconsistencia porque su trabajo es precisamente detectarlas. El editor analiza un fichero suelto que está a medio escribir, con la mitad de las llaves sin cerrar y llamando a funciones que aún no existen, y tiene que producir algo útil de todos modos: colorear, autocompletar, ofrecer una refactorización. Servir a los dos con la misma implementación no es cuestión de voluntad, es cuestión de que la representación admita estados parciales bien definidos. K1 no los admitía; FIR, con sus fases, sí.
De ahí sale la pieza que cierra el círculo y que suele quedarse fuera del relato: la API de análisis. No es un detalle de implementación del complemento de IntelliJ, es la interfaz pública mediante la cual cualquier herramienta —el propio IDE, un generador de documentación, un analizador estático, un plugin de otro proveedor— puede preguntarle al compilador qué significa un trozo de código sin formar parte de él. Esa interfaz solo puede existir si hay estados de resolución nombrables que ofrecer, y por eso no era construible sobre la arquitectura anterior.
Una semántica, dos clientes
K2 expone su análisis a través de una API de análisis pensada para consumo externo. El IDE deja de reimplementar reglas y pasa a preguntarle al mismo motor que compila.
Fases con nombre
Que el árbol pase por estados nombrados convierte al frontend en algo consultable a media resolución, que es justo lo que un editor necesita.
Paralelismo posible
Sin mapa global mutable, resolver declaraciones independientes en hilos distintos deja de ser una fantasía.
Puntos de extensión
Generar declaraciones, supertipos o funciones sintéticas pasa a ser una extensión declarada en una fase concreta, no un parche.
Conviene decir con precisión qué se reescribió y qué no. La reescritura de K2 es la del frontend. El backend ya se había rehecho antes: la migración al IR común y al backend de la JVM basado en él culminó en Kotlin 1.5, y fue la que hizo posible que los cuatro destinos compartieran transformaciones. K2 completó el otro extremo del tubo, y por eso Kotlin 2.0 es el punto en el que ambos extremos son, por fin, de la misma generación.
La forma más común de contar K2 es la peor: decir que es el doble de rápido. Es cierto en la fase de análisis de muchos proyectos, y es irrelevante para entender por qué se hizo. Un equipo no dedica media década y decenas de personas a una reescritura de riesgo extremo —con la obligación absoluta de no romper el código existente de millones de usuarios— para recortar segundos en un build. Lo hace cuando descubre que su arquitectura ha dejado de admitir las preguntas del futuro. La pregunta que K1 no admitía no era «cuánto tardas», sino «puedo yo, que no soy tú, saber lo que tú sabes del programa». Esa pregunta la hacen el IDE, cada plugin de compilador, cada generador de código, cada herramienta de análisis estático, cada compilador de otro lenguaje que quiera interoperar. Un frontend que guarda la semántica en un mapa global rellenado por demanda solo puede responderla si el que pregunta forma parte de él, comparte su hilo y respeta su orden de invocación; es decir, solo puede responderla el propio compilador. Al mover el significado dentro del árbol y hacer explícitas las fases, K2 convierte el análisis en algo que se puede exponer, versionar y consumir desde fuera, y eso es un cambio de naturaleza, no de grado. La prueba está en lo que vino después: la API de análisis que sostiene el modo K2 del IDE, los plugins de compilador que hoy funcionan igual en JVM, Native, JS y Wasm, los context parameters que se estabilizaron en 2.4, la exhaustividad y los smart casts mejorados que dejaron de ser casos especiales para ser consecuencias del flujo de control resuelto en el árbol. Ninguna de esas cosas es «el compilador más rápido»; todas son «el compilador que ahora se deja preguntar». La lección general, que vale para cualquier sistema que mantengas, es que el techo de una arquitectura casi nunca aparece como lentitud: aparece como una lista creciente de cosas que serían fáciles si la representación fuese otra. Cuando esa lista deja de encogerse con esfuerzo y empieza a crecer sola, la reescritura ya no es una opción cara, es la única barata.
Qué quedó desbloqueado
Antes de enumerar, conviene fijar el criterio con el que se juzga una reescritura. No es «va más rápido» ni «el código está mejor»: es cuántas cosas que antes eran imposibles pasaron a ser rutinarias. Con ese criterio, la lista es corta y contundente.
La consecuencia más visible para el ecosistema es que los plugins de compilador dejaron de ser artesanía frágil. Compose es el caso emblemático: su plugin inserta parámetros ocultos, reescribe llamadas y participa tanto en el frontend como en el backend, y bajo K1 vivía atado a detalles internos. Con FIR y con el IR común detrás, un plugin declara extensiones en fases conocidas y su transformación de backend se aplica a los cuatro destinos por construcción. Ese es el motivo real de que la generación de código para multiplataforma dejase de ser un problema de cada plugin.
Merece la pena ver la forma que toma esa extensibilidad, aunque no vayas a escribir un plugin, porque explica de dónde salen las capacidades que sí usas. Un plugin declara sus extensiones en fases concretas del frontend y, si además necesita generar código, una transformación sobre la representación intermedia.
// Esqueleto conceptual: el plugin declara donde se engancha,
// no manipula estructuras internas por su cuenta.
class MiRegistrar : CompilerPluginRegistrar() {
override fun ExtensionStorage.registerExtensions(conf: CompilerConfiguration) {
FirExtensionRegistrarAdapter.registerExtension(MisExtensionesFir())
IrGenerationExtension.registerExtension(MiTransformacionIr())
}
}
Lo relevante de ese esqueleto es que hay dos puntos y que son de naturaleza distinta: uno cambia lo que el programa significa, el otro cambia lo que el programa hace. Bajo el frontend antiguo el primero no existía como interfaz declarada, y por eso los plugins que necesitaban declarar miembros nuevos vivían pegados a versiones concretas del compilador.
La segunda consecuencia es la coherencia de diagnósticos. Con un solo motor sirviendo al editor y a la compilación, la clase entera de errores «el IDE dice una cosa y Gradle otra» desaparece por diseño y no por disciplina. Es un beneficio aburrido y es, en horas de trabajo ahorradas, probablemente el mayor de todos.
La tercera es que el lenguaje volvió a moverse. Características que llevaban años bloqueadas por su coste de implementación —análisis de flujo más fino, exhaustividad más honesta, la resolución de sobrecargas con reglas más predecibles, los parámetros de contexto que se estabilizaron en 2.4— pudieron construirse porque ahora se implementan una vez y sobre una representación que las admite.
La cuarta, la que se cita siempre y la que menos explica, es la velocidad. Las mediciones publicadas sobre proyectos reales sitúan la fase de análisis en torno al doble de rápida y la compilación completa en una mejora bastante menor, porque el backend no cambió y sigue costando lo que costaba. Ese matiz es importante para no llevarse una decepción: si tu build está dominado por procesado de anotaciones, por generación de código o por un grafo de módulos mal cortado, K2 no lo va a arreglar. Lo verás en la lección cuarta con datos propios.
Que K2 sea el compilador por defecto desde Kotlin 2.0 quiere decir que el frontend nuevo es el que se usa salvo que pidas explícitamente lo contrario, y que la versión del lenguaje pasó a ser 2.0 o superior. No quiere decir que todo el ecosistema migrase a la vez: cada plugin de compilador tuvo que adaptarse a las nuevas extensiones, y el modo K2 del IDE llegó después. Cuando alguien dice que «K2 le rompió algo», casi siempre está hablando de un plugin desactualizado y no del compilador.
La migración que no rompió nada
Queda por contar la parte que técnicamente es la más difícil y de la que casi nunca se habla, porque su éxito consistió precisamente en que nadie la notara. Reescribir el frontend de un lenguaje con millones de usuarios impone una restricción brutal: el compilador nuevo tiene que aceptar el código existente y producir el mismo comportamiento, incluidos los casos en los que el compilador viejo se equivocaba y alguien construyó algo encima de esa equivocación.
La estrategia para conseguirlo tuvo tres patas. La primera fue la comprobación masiva: compilar con ambos frontends una cantidad enorme de código real, público y privado, y comparar diagnóstico a diagnóstico. Cualquier diferencia era una decisión que alguien tenía que tomar a mano, y las decisiones se agruparon en tres categorías: comportamiento que se conserva, comportamiento que se corrige porque el viejo era erróneo, y comportamiento que se corrige pero con un periodo de aviso previo.
# Fijar explicitamente la version del lenguaje sigue siendo posible,
# y es la valvula que hizo llevadera la transicion.
kotlinc -language-version 1.9 src/
La segunda pata fue la gradualidad de los avisos: los cambios que iban a romper código se publicaron primero como advertencias en versiones anteriores, de modo que quien mantenía sus dependencias al día llegó a 2.0 con el trabajo ya hecho. La tercera fue documentar las incompatibilidades restantes una por una, con su motivo y su solución, en lugar de despacharlas con una nota genérica.
Hay un efecto secundario de esa disciplina que conviene conocer: existen casos, pocos y documentados, en los que K2 rechaza código que K1 aceptaba, y casi todos son de la misma familia. Programas en los que la resolución antigua elegía un candidato por un orden accidental, smart casts que se aplicaban sin que el análisis pudiera justificarlos, o interoperabilidad con Java donde la nulabilidad se daba por buena sin comprobarla. Cuando te topes con uno, la reacción correcta no es buscar cómo desactivar la comprobación: es leer el diagnóstico y preguntarte por qué el compilador antiguo no podía darlo.
El resultado merece enunciarse como lo que es: se sustituyó el componente central de una plataforma sin exigir un éxodo a sus usuarios. Ese es un logro de ingeniería de un tipo distinto al de la propia reescritura, y es el que más difícil resulta de imitar cuando te toque a ti reescribir algo que otras personas ya usan.
- Coge un módulo tuyo real y compílalo midiendo el tiempo total. Anota el dato: te servirá de línea base para las lecciones siguientes de este nivel.
- Busca en tu historial de proyecto un caso en el que el IDE y el
builddiscrepasen. Explica, con lo aprendido, cuál de las dos implementaciones estabas viendo en cada caso. - Escribe una función genérica con una lambda cuyo tipo dependa de la inferencia y razona qué fase de
FIRtiene que haber terminado antes de poder resolver su cuerpo. - Enumera los plugins de compilador que usa tu proyecto. Para cada uno, decide si actúa en el frontend, en el backend o en ambos.
- Redacta en un párrafo el argumento por el que reescribirías tú un sistema propio, sin usar la palabra rendimiento ni una sola vez.