Plugins de compilador: transformar en vez de escribir
La diferencia real entre KSP y un plugin de compilador no es de potencia sino de naturaleza: uno añade fuentes nuevas y el otro reescribe la representación intermedia de las que ya hay. Esta lección explica esa distinción, recorre los plugins que ya usas sin darte cuenta y expone el precio de operar a ese nivel.
Hay una frontera muy concreta que un procesador de símbolos no puede cruzar por diseño: puede escribir ficheros nuevos, pero no puede tocar ni una coma de los que ya existen. Si tu objetivo es añadir un método a una clase declarada por el usuario, cambiar el cuerpo de una función, hacer que una clase final deje de serlo o insertar código antes y después de cada llamada, KSP no te sirve, y no por una limitación accidental que alguna versión futura vaya a levantar, sino porque su modelo de ejecución se apoya justamente en no modificar nada. Para eso existe otra categoría de herramienta que opera un nivel más abajo, dentro del propio compilador, sobre la representación intermedia que este construye después de analizar el código y antes de emitir el binario. La mayoría de programadores de Kotlin usan cuatro o cinco de estos plugins a diario sin haberse detenido nunca a pensar que lo son, y reconocerlos cambia bastante la percepción de qué es exactamente el lenguaje y qué es maquinaria añadida.
- Distinguir con precisión qué puede hacer un procesador de símbolos y qué exige un plugin de compilador.
- Situar el punto de intervención de un plugin dentro de las fases del compilador.
- Reconocer los plugins de uso cotidiano y explicar qué transformación aplica cada uno.
- Valorar el coste de estabilidad y de depuración que implica operar sobre la representación intermedia.
Añadir frente a reescribir
Un procesador de símbolos participa en una fase previa: lee declaraciones, produce ficheros y esos ficheros se compilan después junto al resto, como si los hubieras escrito tú. Todo lo que genera es código Kotlin normal que podrías haber tecleado, y esa propiedad es la que hace el modelo tan comprensible. La contrapartida es que las clases existentes son intocables, y por eso los patrones habituales en librerías basadas en KSP son la función de extensión, la clase acompañante generada o el objeto separado que hace el trabajo.
Esa restricción explica de paso una diferencia estética que se nota al usar librerías. Las construidas sobre generación de fuentes te piden llamar a algo con un nombre nuevo, normalmente una extensión o un objeto derivado del nombre de tu clase; las construidas sobre transformación te dejan invocar miembros que parecen pertenecer a tu propia clase. Cuando una API te sorprende porque un tipo tuyo tiene métodos que tú no escribiste, casi siempre hay un plugin detrás.
Un plugin de compilador entra mucho más adentro. Tiene puntos de extensión en la fase de resolución, donde puede declarar que existen miembros que no aparecen en ningún fuente, y en la fase de generación, donde puede modificar la representación intermedia que el compilador ha construido. En ese segundo punto el código ya no es texto sino un árbol de operaciones tipadas, y un plugin puede recorrerlo y sustituir nodos con total libertad.
Los dos puntos son complementarios y casi siempre se usan juntos. En el primero, el plugin le dice al compilador que cierta clase tiene un miembro que nadie escribió, de modo que el código que lo invoque compile y el editor lo autocomplete; en el segundo, el plugin proporciona el cuerpo real de ese miembro, porque hasta ahí solo existía la promesa de su firma. Declarar sin generar produce un error al emitir el binario, y generar sin declarar produce código que el analizador rechaza antes de llegar allí.
flowchart LR A[Fuentes] --> B[Analisis y resolucion] B --> C[Representacion intermedia] C --> D[Backend JVM Native Wasm JS] E[KSP] -. genera fuentes nuevas .-> A F[Plugin de compilador] -. declara miembros .-> B F -. reescribe nodos .-> C
La consecuencia práctica de esa diferencia es que un plugin puede producir cosas que no son expresables en Kotlin fuente. Puede fabricar un miembro sintético que ninguna sintaxis permitiría declarar, puede eliminar una comprobación, puede inyectar un parámetro adicional en cada llamada a cierta familia de funciones. Esa potencia es exactamente lo que hace posible que características que parecen del lenguaje estén en realidad implementadas fuera de él.
Conviene además notar que los dos mecanismos no son rivales sino vecinos, y que muchas herramientas maduras usan los dos a la vez: un procesador para producir las piezas que sí son código Kotlin corriente y un plugin para las intervenciones que exigen tocar lo declarado por el usuario. La pregunta útil al diseñar no es cuál elegir sino qué parte del problema cae a cada lado de la frontera.
// Lo que KSP puede hacer: anadir algo al lado
fun Usuario.aMapa(): Map<String, Any?> = mapOf("id" to id)
// Lo que solo un plugin puede hacer: que esto compile
// sin que la clase declare nada, porque el miembro es sintetico
val serializador = Usuario.serializer()
Los que ya usas
El caso más ilustrativo es el de serialización. La anotación @Serializable no la procesa KSP: la procesa un plugin de compilador que sintetiza dentro de la propia clase un objeto acompañante con el serializador, junto con un descriptor de la estructura y las funciones de codificación y decodificación. Que el miembro sea sintético es lo que permite escribir la llamada al serializador sobre la clase directamente, sin importar nada generado ni nombrar ningún fichero auxiliar. Además el plugin necesita el sistema de tipos completo para resolver serializadores de tipos genéricos anidados, algo que un generador de fuentes tendría que reimplementar a mano.
@Serializable
data class Punto(val x: Int, val y: Int)
// Ningun fichero declara esto y sin embargo compila,
// porque el plugin sintetizo el miembro dentro de la clase
val json = Json.encodeToString(Punto.serializer(), Punto(1, 2))
Los dos plugins de compatibilidad con el mundo de los marcos de trabajo de Java son más humildes pero igual de instructivos. El primero, all-open, quita el modificador final a las clases marcadas con ciertas anotaciones, porque los marcos que crean proxies en ejecución necesitan poder heredar de ellas y Kotlin cierra las clases por defecto. El segundo, no-arg, sintetiza un constructor sin parámetros para las clases marcadas, porque la persistencia clásica lo exige aunque nadie deba llamarlo desde el código. Ninguna de las dos transformaciones se puede expresar generando ficheros: la primera cambia un modificador de una clase ajena y la segunda añade un miembro a esa misma clase.
plugins {
kotlin("plugin.serialization")
kotlin("plugin.spring") // configura all-open para las anotaciones de Spring
kotlin("plugin.jpa") // configura no-arg para las entidades
kotlin("plugin.compose")
}
El plugin de Compose es el más ambicioso de todos y conviene entender qué hace porque explica muchas de sus reglas. Una función marcada como componible no es una función normal: el plugin le añade parámetros ocultos, entre ellos el objeto que gestiona el árbol de composición y un entero con información de cambios, envuelve su cuerpo en llamadas de apertura y cierre de grupo, e inserta la lógica que permite saltarse la recomposición cuando ningún argumento ha cambiado. Además deduce la estabilidad de los tipos que atraviesan la interfaz para decidir cuándo ese salto es seguro. Todo eso ocurre sobre la representación intermedia, y por eso la anotación no puede aplicarse en cualquier sitio ni las funciones componibles pueden llamarse desde contextos normales.
serialization
Sintetiza el serializador dentro de la clase, con descriptor y funciones de codificación, resolviendo genéricos anidados con el sistema de tipos completo.
all-open
Retira el cierre por defecto de las clases marcadas, para que los marcos que generan proxies puedan heredar de ellas.
no-arg
Añade un constructor sin parámetros que no aparece en ninguna fuente y que solo usa la maquinaria de persistencia.
compose
Reescribe cada función componible con parámetros ocultos, grupos de composición y la lógica que permite omitir recomposiciones.
Hay más, y merece la pena conocerlos porque son útiles: el que convierte una clase en transportable entre componentes de Android, el que reescribe las aserciones para que el mensaje de fallo muestre el valor de cada subexpresión, y el que genera los puentes de interoperabilidad en proyectos multiplataforma.
// Con el plugin de aserciones enriquecidas, esta linea
assert(usuario.nombre.length == esperado.size)
// falla con un mensaje que muestra el valor de cada parte:
// usuario.nombre.length == esperado.size
// | | |
// Ana 3 5
Ese ejemplo resume bien la naturaleza del mecanismo. Nadie podría generar ese mensaje escribiendo ficheros nuevos, porque la información necesaria son las subexpresiones del cuerpo de una función ajena, y los cuerpos son justamente lo que un procesador de símbolos no ve. Solo se puede hacer reescribiendo el árbol de operaciones que el compilador ya construyó.
El precio de bajar ahí
Todo esto tiene un coste, y el primero es la estabilidad. La API interna del compilador no ofrece garantías de compatibilidad entre versiones, de modo que un plugin de terceros puede dejar de funcionar cuando actualizas Kotlin y bloquear la actualización de todo el proyecto. Esta es la razón profunda por la que casi todos los plugins de uso masivo los mantiene el propio equipo del lenguaje o un equipo con relación estrecha con él: son los únicos que pueden seguir el ritmo.
La historia del plugin de Compose es el mejor ejemplo de esa tensión y de cómo se resolvió. Durante años tuvo un ciclo de versiones propio, con una tabla de correspondencias que decía qué versión funcionaba con qué versión de Kotlin, y actualizar el lenguaje significaba esperar a que esa tabla se actualizara. La solución fue trasladar el plugin al mismo repositorio que el compilador y hacer que se publicase con el mismo número, lo cual eliminó la espera de golpe. Es un patrón que conviene reconocer: cuando un plugin se vuelve imprescindible, acaba absorbido por el calendario del lenguaje o acaba siendo un lastre.
El segundo coste es la comprensión. Cuando un procesador genera un fichero, ese fichero existe, se puede abrir y se puede leer; cuando un plugin transforma la representación intermedia, no hay nada que abrir. El código que se ejecuta no coincide con el que está escrito, y esa divergencia es exactamente la que confunde a quien depura por primera vez una función componible y encuentra en la traza parámetros que él no escribió.
El tercer coste es el diagnóstico. Un plugin que aplica una regla puede rechazar código, y si su mensaje no es tan cuidadoso como el del compilador, el usuario recibe un error que no sabe interpretar y que además no puede buscar en la documentación del lenguaje, porque la regla no está allí. Las quejas habituales sobre determinadas restricciones de Compose vienen casi todas de aquí: la regla es razonable, el mensaje llega antes de que se haya explicado por qué existe.
El cuarto coste es la interacción entre plugins. Dos transformaciones que por separado funcionan pueden estorbarse cuando se aplican sobre el mismo código, y el orden en que se ejecutan no siempre está bajo tu control. Los casos son raros, pero cuando ocurren producen fallos que ninguna de las dos herramientas reconoce como suyos, y la investigación acaba inevitablemente en el volcado de la representación intermedia.
Se puede ver el resultado de la transformación pidiendo al compilador que vuelque la representación intermedia, o inspeccionando el bytecode generado desde el entorno de desarrollo. Es un ejercicio muy recomendable al menos una vez con una función componible sencilla, porque el resto de reglas de la herramienta dejan de parecer arbitrarias.
La API interna cambia, la documentación es escasa y el mantenimiento es perpetuo. Antes de escribir un plugin propio, comprueba si el problema se resuelve con un procesador, con una función de extensión o con un cambio de diseño. Los casos legítimos existen, pero son muchos menos de los que uno cree al descubrir la técnica.
Cuando uno hace el inventario de los plugins que intervienen en un proyecto típico llega a una conclusión incómoda y muy productiva: el Kotlin que efectivamente se escribe a diario no es el Kotlin que define la especificación, sino esa especificación más una capa de transformaciones que se han vuelto tan invisibles que se confunden con el lenguaje. Nadie piensa que la llamada al serializador de una clase sea magia, y sin embargo lo es en el sentido preciso de que no hay ninguna regla del lenguaje que la justifique: hay un plugin que fabricó ese miembro. Nadie piensa que una función componible sea una función distinta, y sin embargo lo es, con parámetros que no aparecen en su firma y un cuerpo que no es el que se lee. Esta observación importa por dos motivos prácticos. El primero es que explica de golpe una categoría entera de errores desconcertantes, esos en los que el compilador se queja de algo que no está escrito o el depurador muestra un marco que nadie declaró; en cuanto se sabe que hay una transformación de por medio, la pregunta correcta deja de ser qué hice mal y pasa a ser qué reescribió esto y bajo qué reglas. El segundo motivo es más estratégico y tiene que ver con cómo se elige tecnología. Cada plugin que se adopta es una parte del lenguaje que el proyecto ya no controla, atada a un calendario de versiones ajeno, y esa deuda se paga toda junta el día que hay que actualizar el compilador y resulta que un plugin no está listo. La disciplina razonable no es evitarlos, porque serían imposibles de evitar y además funcionan muy bien, sino saber exactamente cuáles hay, quién los mantiene y qué haría el proyecto si uno de ellos se quedara atrás. Un plugin bien mantenido es una extensión del lenguaje; uno abandonado es una versión de Kotlin en la que estás congelado.
- Enumera todos los plugins de compilador activos en tu proyecto y anota quién mantiene cada uno.
- Elige una clase con
@Serializabley localiza en el bytecode el miembro sintético que ningún fuente declara. - Vuelca la representación intermedia de una función componible mínima y cuenta los parámetros que no escribiste.
- Toma una entidad de persistencia y comprueba qué ocurre exactamente si desactivas el plugin
no-arg. - Escribe para tu equipo el plan de contingencia si el plugin más crítico no estuviera listo en la próxima versión de Kotlin.