El pipeline: del texto a la representación intermedia
Un fichero de Kotlin atraviesa un tubo con etapas muy distintas entre sí antes de convertirse en código: el analizador léxico produce componentes léxicos, el sintáctico produce un árbol que aún no significa nada, la construcción de FIR produce una representación cruda que se resuelve en fases numeradas, los comprobadores emiten diagnósticos sobre el árbol ya resuelto y por último Fir2Ir baja todo a la representación intermedia común. Esta lección recorre ese tubo etapa por etapa y explica qué información aparece exactamente en cada una.
La palabra «compilar» esconde media docena de trabajos que no se parecen en nada. Reconocer palabras en un flujo de caracteres, construir un árbol a partir de una gramática, decidir a qué función concreta se refiere un nombre, inferir el tipo de una expresión sin anotación, comprobar que nadie ha violado una regla del lenguaje y traducir todo eso a una forma más pobre y más regular son cinco problemas distintos, con estructuras de datos distintas y modos de fallo distintos. El frontend de K2 los separa con una nitidez que el compilador antiguo no tenía, y esa separación es exactamente lo que puedes usar para entender —y para diagnosticar— qué le está pasando a tu código.
- Distinguir las etapas léxica y sintáctica y saber por qué existen dos analizadores sintácticos distintos.
- Describir cómo se construye el
FIRcrudo y qué añade cada fase de resolución. - Explicar dónde ocurren exactamente la resolución de llamadas, la inferencia y los
smart casts. - Situar los comprobadores y la conversión
Fir2Ircomo el final del frontend y el principio del backend.
De caracteres a árbol sintáctico
La primera etapa no sabe nada de Kotlin salvo su alfabeto. El analizador léxico recorre el texto y produce componentes léxicos: identificadores, literales, palabras clave, signos de puntuación. Aquí ya se toman decisiones que después parecen semánticas pero no lo son: que val sea palabra clave dura y data sea blanda —es decir, que puedas llamar data a una variable— se decide en esta capa y en la gramática, no en la resolución.
La segunda etapa agrupa esos componentes según la gramática y produce un árbol sintáctico. Un detalle práctico importante: Kotlin tiene dos analizadores sintácticos. El clásico produce PSI, el árbol de IntelliJ, que conserva cada espacio y cada comentario porque un editor necesita reescribir el fichero sin perder nada. El otro produce un árbol ligero, el LightTree, mucho más barato en memoria y en asignaciones, pensado para compilar desde línea de órdenes donde nadie va a editar nada. K2 usa el ligero por defecto en compilación y el PSI cuando el consumidor es el IDE. Es la misma gramática con dos materializaciones.
# Ambas rutas producen el mismo programa; cambian el arbol y el coste.
kotlinc Main.kt -include-runtime -d app.jar
Lo esencial de esta etapa es lo que no hace. El árbol sintáctico no sabe a qué se refiere un nombre, no conoce tipos, no sabe si una llamada existe. Un fichero que llame a una función inexistente pasa el análisis sintáctico sin una queja: sintácticamente es una llamada perfecta. Todos los errores que te importan de verdad viven más adelante.
// Sintacticamente impecable, semanticamente imposible.
fun demo() {
val data = 1 // 'data' es palabra clave blanda: vale como nombre
inexistente(data) // el parser no protesta; el frontend si
}
Ese reparto de responsabilidades tiene una consecuencia visible en la calidad de los mensajes. Un error de sintaxis puede señalarte la posición exacta y sugerir el signo que falta, porque el texto original sigue disponible y la gramática dice qué se esperaba ahí. Un error semántico no puede hacerlo con la misma precisión, y por eso los mensajes de resolución hablan de candidatos y de restricciones en lugar de columnas. También explica la recuperación de errores: el analizador sintáctico no se rinde ante una llave que falta, sino que inserta un nodo de error y sigue, para que las etapas posteriores puedan trabajar con el resto del fichero. Es lo que permite que el editor siga siendo útil mientras escribes.
Construir FIR y resolverlo por fases
A partir del árbol sintáctico se construye el FIR crudo. Es casi un calco de la sintaxis, pero ya con la forma de un grafo de declaraciones: ficheros, clases, funciones, propiedades, expresiones. Lo que le falta es todo el significado. Los tipos escritos aún no apuntan a nada, las llamadas aún no apuntan a ninguna función, y las declaraciones sin anotación de tipo no tienen tipo.
A partir de ahí el árbol atraviesa fases de resolución numeradas. Cada fase toma el árbol en un estado conocido y lo devuelve en el siguiente, y —esto es lo importante— la fase se completa para todas las declaraciones del módulo antes de pasar a la siguiente. Ese contrato es lo que permite paralelizar y lo que permite que un consumidor externo pregunte por el estado del árbol a media resolución.
flowchart TD A[FIR crudo] --> B[Importaciones] B --> C[Supertipos] C --> D[Herederos de sealed] D --> E[Tipos escritos] E --> F[Estado: visibilidad y modalidad] F --> G[Contratos] G --> H[Tipos implicitos de cabeceras] H --> I[Cuerpos] I --> J[Comprobadores]
Merece la pena entender por qué el orden es ese y no otro. Las importaciones van primero porque sin ellas ningún nombre se puede resolver. Los supertipos van antes que nada del cuerpo porque el conjunto de miembros visibles de una clase depende de qué hereda. Los herederos de una clase sealed se recogen en su propia fase porque la exhaustividad de un when necesita conocer la jerarquía cerrada completa, y esa es información que ninguna declaración por separado posee.
La fase de tipos implícitos de cabeceras es la más instructiva. Cuando escribes una función sin anotar su tipo de retorno, el compilador tiene que resolver su cuerpo lo suficiente para conocerlo, y si ese cuerpo llama a otra función igualmente sin anotar, aparece una dependencia. El frontend la trata como lo que es: una relación entre cabeceras que se resuelve antes de tocar los cuerpos en serio, y que se corta con un error explícito cuando es circular.
// El tipo de retorno de ambas se necesita para resolver la otra.
fun a() = b()
fun b() = a() // error: dependencia ciclica en la inferencia
Vale la pena insistir en la palabra «cabeceras». La fase que precede a los cuerpos no resuelve programas enteros: resuelve lo justo para que toda declaración del módulo tenga una firma completa. Esa separación es la que hace posible que la resolución de cuerpos sea, en principio, paralelizable por declaración, porque a partir de ese punto cada cuerpo solo necesita firmas ajenas y ninguna otra cosa que otro hilo pudiera estar modificando.
Solo entonces llega la resolución de cuerpos, que es donde ocurre casi todo lo que asocias con «el compilador me entiende». Aquí se resuelven las llamadas eligiendo entre sobrecargas, se infieren los argumentos de tipo, se aplican las funciones de extensión según el receptor, se decide qué lambda es inline y, sobre todo, se ejecuta el análisis de flujo de datos que sostiene los smart casts.
Que después de if (x is String) puedas usar x como cadena no es una excepción en las reglas de tipos: es el resultado de un grafo de flujo de control construido sobre el FIR del cuerpo, donde cada rama acumula hechos sobre las variables. Por eso el smart cast deja de aplicarse cuando la variable es var capturada por una lambda o una propiedad ajena al módulo: no es capricho, es que el análisis no puede probar el hecho en ese punto del grafo.
La resolución de una llamada merece detallarse porque es donde más gente se pierde. El proceso tiene tres etapas encadenadas. Primero se recolectan candidatos: todas las funciones visibles con ese nombre, agrupadas por prioridad según de dónde vengan —miembros de la clase antes que extensiones, extensiones locales antes que importadas, y así—. Segundo se descartan los que no son aplicables por número de argumentos o por tipos incompatibles. Tercero, si queda más de uno, se elige el más específico según reglas de comparación de firmas, y si el empate persiste el resultado es un error de ambigüedad y no una elección arbitraria.
class Caja(val n: Int) {
fun ver() = "miembro"
}
fun Caja.ver() = "extension"
// Imprime "miembro": los miembros ganan a las extensiones
// en la etapa de prioridad de grupos, antes de comparar tipos.
fun probar() = println(Caja(1).ver())
La inferencia de tipos corre entrelazada con todo lo anterior y no después, que es la fuente de casi todas las sorpresas. Al resolver una llamada genérica, el compilador no conoce todavía los argumentos de tipo: los trata como incógnitas y va acumulando restricciones a partir de los argumentos, del receptor y del contexto de la propia llamada. Solo cuando el sistema de restricciones está completo lo resuelve. Por eso el tipo de una lambda puede depender de lo que se haga con su resultado, y por eso mover una expresión a una variable intermedia a veces cambia lo que compila: al hacerlo, has cortado el flujo de restricciones en dos.
Comprobar y bajar al IR
Cuando el árbol está resuelto entra en escena una etapa separada y fácil de pasar por alto: los comprobadores. Son cientos de reglas independientes que recorren el FIR ya resuelto y emiten diagnósticos. Su separación del propio proceso de resolución es una decisión de diseño con consecuencias prácticas: la resolución no se detiene ante un error, sino que produce nodos marcados como erróneos, y los comprobadores traducen esas marcas y sus propias inspecciones en mensajes. Por eso un fichero con un error te devuelve además todos los demás errores en lugar de rendirse en el primero.
Es también la razón de que la distinción entre error y aviso sea, literalmente, un atributo de cada diagnóstico. De ahí sale el comportamiento de las opciones que ya usas.
# Todo aviso pasa a ser error: util en CI, doloroso al migrar.
kotlinc -Werror src/
# Activar comprobaciones y avisos de deprecacion de la siguiente version.
kotlinc -progressive src/
Superados los comprobadores, el frontend ha terminado. Lo que sigue es la traducción a la representación intermedia común mediante el paso conocido como Fir2Ir. Aquí conviene tener clara la diferencia de propósito entre las dos representaciones. FIR está construido para preguntar: qué significa este nombre, qué tipo tiene esto, es correcto este programa. El IR está construido para transformar: es más regular, más explícito y más pobre, con menos azúcar y con cada referencia ya apuntando a un símbolo concreto.
Léxico y sintaxis
Reconocen forma, no significado. Un programa que llama a lo que no existe pasa esta etapa sin una queja.
FIR por fases
Importaciones, supertipos, tipos, estado, contratos, cabeceras y cuerpos. Cada fase termina para todo el módulo antes de la siguiente.
Comprobadores aparte
La resolución no aborta ante el error: lo marca. Los comprobadores traducen marcas y reglas en diagnósticos.
Fir2Ir
Frontera entre preguntar y transformar. A partir de aquí el compilador ya no razona sobre el lenguaje, sino sobre código.
Esa diferencia se aprecia en un detalle concreto: en IR toda referencia pasa por un símbolo, un identificador estable que apunta a la declaración correspondiente sin depender de nombres ni de resolución. Esto es lo que permite que una referencia sobreviva a las transformaciones posteriores, que se serialice en una biblioteca y que se vuelva a enlazar al materializarla para otro destino. Es también, dicho sea de paso, el origen del error más críptico que verás en multiplataforma: cuando un símbolo no se puede enlazar porque la biblioteca se compiló contra una versión distinta de otra biblioteca, el mensaje habla de un símbolo no encontrado y no de tu código, porque para entonces tu código ya no existe como tal.
# Fijar la version del lenguaje y de la API afecta al frontend;
# el destino de la JVM afecta a lo que el backend puede emitir.
kotlinc -language-version 2.2 -api-version 2.2 -jvm-target 21 src/
En esa traducción se pierde deliberadamente información: los tipos ya no llevan la historia de cómo se infirieron, los smart casts se convierten en conversiones explícitas, los valores por defecto y las funciones de extensión toman formas más mecánicas. Perder información es el objetivo, porque el backend no debe volver a plantearse preguntas del frontend. Y también es la razón de que un plugin de compilador tenga que elegir en qué lado actúa: en el frontend si necesita influir en lo que el programa significa, en el backend si solo necesita cambiar lo que el programa hace.
Se enseña el pipeline como si fuese acumulativo: el texto gana estructura, la estructura gana tipos, los tipos ganan comprobación, y al final sale un ejecutable. Esa imagen es cómoda y es falsa en su parte más importante. Lo que hace útil a cada etapa no es lo que añade, sino lo que se permite olvidar al pasar a la siguiente, porque olvidar es lo que reduce el espacio de casos que la etapa posterior tiene que contemplar. El analizador léxico existe para que el sintáctico no vuelva a mirar caracteres nunca más; el sintáctico, para que la construcción de FIR no vuelva a mirar la gramática; la resolución, para que los comprobadores no tengan que decidir a qué se refiere un nombre; y Fir2Ir existe, sobre todo, para que ninguna transformación de backend pueda hacerse jamás una pregunta de tipado. Esa disciplina tiene un corolario que se palpa al programar: los errores que un compilador puede darte bien son exactamente los de la etapa en la que la información todavía existe. Un error de sintaxis puede señalarte la columna exacta porque el texto sigue ahí; un error de inferencia puede contarte la cadena de restricciones porque el sistema de tipos aún está montado; pero cuando algo se rompe en el backend, el mensaje resultante es opaco no por descuido, sino porque para entonces la información con la que se habría escrito un mensaje humano ya se descartó a propósito, tres etapas antes. Ahí está la razón profunda de que las reglas del lenguaje se comprueben tan temprano y de que un compilador maduro invierta la mayor parte de su ingeniería en el frontend: no es que la generación de código sea fácil, es que la generación de código no tiene con qué explicarse. Y de ahí sale la única heurística que necesitas cuando un build falla de forma incomprensible: pregúntate en qué etapa se rompió. Si el mensaje habla de tipos, nombres o exhaustividad, tienes toda la información y el problema es tuyo. Si el mensaje habla de un símbolo no encontrado, de una firma que no cuadra o de una excepción interna del generador, casi siempre estás viendo el eco de una inconsistencia que se coló antes —dependencias compiladas con versiones distintas, un plugin desincronizado, un caché sucio— y el sitio donde buscarla no es donde reventó.
- Escribe un fichero con una llamada a una función que no existe y compílalo. Confirma que el fallo llega como diagnóstico resuelto y no como error de sintaxis.
- Provoca a propósito una dependencia cíclica entre dos funciones sin tipo de retorno anotado y lee el mensaje. Anótalo con la fase en la que se produce.
- Construye un caso donde el
smart castfuncione y otro donde no, cambiando solovalporvarcapturada. Explica el resultado en términos de análisis de flujo. - Compila el mismo módulo con
-Werrory sin él. Clasifica cada diagnóstico que cambie de categoría. - Elige una función tuya con parámetros por defecto y lambdas, y escribe cómo crees que la verá el
IR. Guárdalo: en la lección cinco lo comprobarás.