wandres.dev
ANOTACIONES Y REFLEXIÓN · mirar el propio código

La alternativa: generar en compilación en vez de reflexionar en ejecución

Por qué el ecosistema de Kotlin abandonó la introspección en tiempo de ejecución a favor de la generación en tiempo de compilación: las dos familias de mecanismos que lo hacen posible, qué gana exactamente el código generado en tamaño, arranque, verificación y portabilidad, en qué casos la reflexión sigue siendo la respuesta correcta, y cómo esta decisión abre el camino hacia el procesamiento de símbolos del nivel siguiente.

⏱ 20 min

Si se observa el ecosistema de Kotlin con algo de distancia, aparece un patrón que ninguna decisión aislada explica: las bibliotecas que más se usan resuelven en compilación problemas que sus equivalentes de la generación anterior resolvían en ejecución. La serialización dejó de recorrer propiedades para generar un lector y un escritor por clase. La inyección de dependencias dejó de buscar constructores para generar el gráfico completo como código ordinario. El acceso a datos dejó de traducir consultas al vuelo para verificarlas contra el esquema antes de que el programa exista. La composición de interfaces se apoya en una extensión del compilador que reescribe funciones. No hay coordinación entre esos proyectos, ni una campaña que los empujara: hay una restricción común, que es la existencia de objetivos sin modelo dinámico de clases, y un incentivo común, que es que generar se ha vuelto barato. Este nivel es el cierre de la reflexión y, a la vez, el planteamiento del problema que el siguiente resuelve.

🎯 Al terminar esta lección sabrás
  • Distinguir las dos familias de generación en compilación y saber cuál interviene en cada biblioteca conocida.
  • Enumerar lo que gana el código generado frente a la reflexión en peso, arranque, verificación y portabilidad.
  • Reconocer los costes reales de generar y los casos donde la reflexión sigue siendo la elección correcta.
  • Situar el procesamiento de símbolos como la vía abierta a quien no escribe el compilador.

Dos familias que hacen el mismo trabajo

Los mecanismos que sustituyen a la reflexión se agrupan en dos familias con propiedades muy distintas. La primera son las extensiones del compilador, que se ejecutan dentro de él y pueden modificar la representación intermedia: añadir miembros a una clase, reescribir el cuerpo de una función, insertar comprobaciones o cambiar la forma en que se compila una llamada. Su poder es total y su coste de entrada también, porque dependen de interfaces internas que cambian con las versiones y porque un fallo en ellas produce errores muy difíciles de leer.

La segunda son los procesadores de símbolos, que se ejecutan antes de la compilación propiamente dicha, leen una vista estructurada de las declaraciones del programa y emiten ficheros nuevos que se compilan junto al resto. No pueden modificar lo escrito, solo añadir, y esa limitación es exactamente lo que los hace tratables: la interfaz que consumen es estable, el resultado es código legible que se puede abrir y depurar, y el modelo mental es el de un generador y no el de un compilador.

flowchart TD
A[Metadato escrito con anotaciones] --> B[Extension del compilador]
A --> C[Procesador de simbolos]
A --> D[Reflexion en ejecucion]
B --> E[Modifica la representacion intermedia]
C --> F[Emite ficheros nuevos que se compilan]
D --> G[Interpreta el metadato en cada arranque]
E --> H[Coste pagado una vez al compilar]
F --> H
G --> I[Coste pagado en cada ejecucion]

Las dos familias comparten la entrada: las anotaciones del primer nivel de este bloque. La diferencia con la reflexión no está en cómo se marca el código, sino en quién lee la marca y cuándo. La misma etiqueta que un consumidor de ejecución leería con retención de ejecución y coste de decodificación, un procesador la lee con retención de fuente y coste cero para el usuario final.

Existe además una tercera vía histórica, hoy en retirada, que consistía en ejecutar los procesadores de anotaciones de Java sobre una vista de Kotlin traducida a los tipos del ecosistema anterior. Funcionaba, permitió reutilizar durante años una biblioteca de procesadores enorme y tenía dos defectos estructurales: obligaba a generar un modelo intermedio en el que se perdía información propia de Kotlin, y no podía existir fuera de la plataforma de Java. Su sustitución por un procesamiento nativo del lenguaje no fue una mejora de rendimiento, aunque también lo fuera, sino la condición necesaria para que el mecanismo funcionara en todos los objetivos.

🪞

Serialización

Genera un lector y un escritor por clase marcada. Sustituye el recorrido de propiedades por accesos directos conocidos en compilación.

🧵

Inyección de dependencias

Genera el gráfico de construcción como código ordinario. Un enlace que falta se convierte en un error de compilación con posición exacta.

🗄️

Acceso a datos

Verifica las consultas contra el esquema y genera el mapeo. El error de columna aparece antes de que exista el artefacto.

🎨

Interfaces declarativas

Una extensión del compilador reescribe funciones para introducir seguimiento de estado sin que el usuario escriba nada de eso.

Lo que gana el código generado

La primera ganancia es de peso, y es doble: desaparece la biblioteca de reflexión y, con ella, desaparece el obstáculo a la poda. El código generado es código ordinario, alcanzable por caminos que el optimizador entiende, de modo que lo que no se usa se elimina y lo que se conserva se puede ofuscar sin reglas especiales.

La segunda es de arranque. No hay nada que decodificar porque no hay descripción que interpretar: la información que la reflexión reconstruiría en cada ejecución ya está encarnada en instrucciones. Un mapeo de serialización generado se resuelve con accesos directos a campos en lugar de con búsquedas de propiedades por nombre.

La tercera, y la más importante a largo plazo, es de verificación. Un error de configuración deja de ser una excepción en producción y pasa a ser un error de compilación con posición en el fichero. Una dependencia que falta, un campo obligatorio sin valor por defecto, un tipo que no se sabe serializar o una consulta que no encaja con el esquema se detienen antes de que el artefacto exista. Ese desplazamiento del momento del fallo vale más que las dos ganancias anteriores juntas, porque cambia la naturaleza del error y no solo su coste.

La cuarta es de portabilidad, y es la que hizo inevitable el giro. El código generado funciona igual en todos los objetivos porque es código; la reflexión completa solo funciona donde hay un modelo dinámico de clases. Cualquier biblioteca que aspire a ser multiplataforma no tiene realmente elección.

Hay una quinta ganancia menos citada y muy valiosa en la práctica: el resultado es inspeccionable. Cuando algo va mal en una solución reflexiva, el diagnóstico consiste en razonar sobre lo que la biblioteca habrá deducido del metadato, un ejercicio que solo domina quien escribió la biblioteca. Cuando algo va mal en una solución generada, el diagnóstico consiste en abrir el fichero generado y leerlo. La diferencia entre depurar una deducción y depurar un texto es enorme, y explica buena parte de la preferencia de los equipos que han vivido las dos épocas.

📝
La marca no cambia, cambia el lector

Fíjate en que la anotación que el usuario escribe es prácticamente la misma en ambos mundos. Migrar de reflexión a generación rara vez obliga a reescribir el código anotado: obliga a sustituir al consumidor y a cambiar la política de retención. Esa continuidad es lo que permitió al ecosistema moverse sin romper a sus usuarios.

// Lo que el usuario escribe
@Serializable
data class Pedido(val id: Long, val cliente: String, val total: Double = 0.0)

// Lo que el mecanismo genera, en esencia: un descriptor con los nombres y los
// tipos ya resueltos, y una escritura campo a campo con acceso directo
private object PedidoSerializador {
    val descriptor = descriptorDe("Pedido", "id" to LARGO, "cliente" to CADENA, "total" to REAL)

    fun escribir(salida: Salida, v: Pedido) {
        salida.escribirLargo(0, v.id)
        salida.escribirCadena(1, v.cliente)
        salida.escribirReal(2, v.total)
    }
}

Compárese eso con lo que haría la versión reflexiva del mismo trabajo: enumerar las propiedades de la clase, decodificar el metadato para saber cuáles son y de qué tipo, leer sus anotaciones, resolver el método de lectura de cada una y llamarlo empaquetando el receptor en un arreglo. Todo lo que la versión generada hace con una instrucción por campo, la reflexiva lo hace con una búsqueda, una comprobación y una invocación indirecta, y lo repite en cada proceso que arranca.

Lo que cuesta y cuándo no compensa

Generar no es gratis y presentarlo como si lo fuera lleva a decepciones. El coste se traslada al momento de compilar: el proyecto tarda más, el gráfico de tareas se complica, la compilación incremental se vuelve más frágil porque un cambio en una clase anotada obliga a regenerar y recompilar sus dependientes, y aparece una categoría de errores nueva cuyo origen está en código que nadie escribió. A eso se suma que el resultado hay que saber inspeccionarlo: cuando el generador se equivoca o cuando el usuario le da una entrada que no previó, el diagnóstico exige leer la salida generada.

Hay además un coste de diseño que se subestima: una biblioteca que genera código publica dos interfaces en vez de una. La primera es la que el usuario escribe, formada por las anotaciones y sus parámetros. La segunda es la que el código generado usa, formada por los tipos de apoyo que la biblioteca expone para que la salida compile. Cambiar la segunda rompe a todo el que tenga código generado por una versión anterior, y como ese código no lo escribió nadie, el usuario no entiende por qué le está fallando. Las bibliotecas maduras marcan esa segunda interfaz como interna o experimental precisamente para poder moverla.

Y quedan casos donde la reflexión sigue siendo la respuesta correcta, no un residuo histórico. Cuando el tipo a tratar no existe en el momento de compilar, porque llega de un módulo cargado dinámicamente o de un complemento de terceros, no hay nada que generar. Cuando la herramienta debe inspeccionar código ajeno, como un depurador, un perfilador o un marco de pruebas que descubre clases, la introspección es su materia de trabajo. Y cuando el programa vive en un servidor de larga vida donde el arranque se amortiza y el peso no se percibe, la simplicidad de una solución reflexiva puede ganar honestamente a la complejidad de una cadena de generación.

// Caso legitimo: el tipo llega de un complemento que no existia al compilar
fun cargarComplemento(nombre: String): Complemento {
    val k = Class.forName(nombre).kotlin
    require(k.isSubclassOf(Complemento::class)) { "tipo no compatible" }
    return k.createInstance() as Complemento
}

Aquí no hay alternativa generada posible, porque el nombre de la clase es un dato de configuración y no un símbolo del programa. Nótese, sin embargo, que incluso en este caso legítimo la superficie reflexiva es mínima y está confinada a una única función de frontera: lo que cruza esa frontera es un tipo conocido, y todo lo que viene después vuelve a estar verificado por el compilador. Confinar la reflexión a la carga y devolver inmediatamente un tipo del dominio es la disciplina que separa un uso defendible de una arquitectura que se disuelve en cadenas de texto.

ℹ️
El criterio en una línea

Si la información necesaria está disponible en el momento de compilar, generar es casi siempre mejor. Si no lo está, reflexionar no es una concesión sino la única técnica aplicable. Casi toda la discusión se resuelve determinando con honestidad en cuál de los dos casos estás.

El puente hacia el procesador de símbolos

De las dos familias, solo una está realmente abierta a quien no forma parte del equipo del compilador, y es la de los procesadores de símbolos. Su modelo es sencillo de enunciar: el compilador hace una pasada previa en la que expone una vista de solo lectura del programa, con clases, funciones, propiedades, tipos y anotaciones; el procesador la recorre, decide qué hace falta y escribe ficheros nuevos; después se compila todo junto. No hay acceso a cuerpos de función, no hay modificación de lo escrito y no hay dependencia de estructuras internas, y esas tres renuncias son las que hacen que la interfaz sea estable entre versiones.

// La anotacion la escribes tu, con retencion de fuente
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class Constructible

// Y un procesador la lee en compilacion para emitir, por cada clase marcada,
// el codigo que tu habrias escrito a mano y que la reflexion habria
// improvisado en cada arranque
@Constructible
data class Pedido(val id: Long, val cliente: String)

El nivel siguiente entra en ese mecanismo por dentro: cómo se declara un procesador, qué modelo de símbolos recibe, cómo se emiten ficheros correctamente atribuidos a sus dependencias para que la compilación incremental funcione, y cómo se diagnostican los errores del usuario en el punto exacto de su código y no en el del código generado. Todo lo aprendido en este nivel sobre anotaciones sigue siendo válido allí, con un solo cambio de política: la retención deja de ser de ejecución y pasa a ser de fuente, porque el lector ya no llega tarde.

Mover el trabajo hacia atrás en el tiempo es el patrón que ordena todo este bloque

Conviene extraer el principio general, porque no es un detalle de Kotlin ni una moda de bibliotecas: es una de las ideas más productivas que existen en el diseño de sistemas, y explica de golpe decisiones que parecían inconexas. Todo trabajo que un programa realiza puede colocarse en algún punto de una línea temporal que va desde el momento en que alguien escribe el código hasta el momento en que un usuario ejecuta la instrucción. Cuanto más a la izquierda se coloque, menos veces se hace y más barato resulta en total: lo que ocurre al escribir se hace una vez y lo corrige quien lo escribió, lo que ocurre al compilar se hace una vez por versión en una máquina que a nadie le importa que sude, y lo que ocurre al ejecutar se hace una vez por arranque, por dispositivo y por usuario, para siempre. La reflexión es, en estos términos, la decisión de colocar en el extremo derecho un trabajo cuya información estaba disponible en el extremo izquierdo, y por eso se paga tantas veces. El sistema de tipos es la misma idea aplicada a la corrección: mover hacia la izquierda la detección de errores que de otro modo aparecerían a la derecha. Las funciones incorporadas en línea mueven a la izquierda el coste de la abstracción. Los contratos mueven a la izquierda un conocimiento que el análisis no podía deducir. Las clases selladas mueven a la izquierda la comprobación de exhaustividad. Todo este recorrido, visto desde arriba, es un mismo gesto repetido. Y la generación en compilación es la forma más literal del gesto, porque no mueve una comprobación ni una optimización sino la escritura misma del código: lo que un intérprete de metadatos habría reconstruido en cada arranque queda escrito una vez, verificado por el compilador como cualquier otro código y podado por el optimizador como cualquier otra rama. La consecuencia natural de aceptar este principio es preguntarse quién puede participar en la compilación además del compilador, y esa pregunta tiene desde hace unos años una respuesta accesible que ya no exige conocer las tripas del compilador.

⚔️ Cruza el puente
  1. Toma un caso propio resuelto con reflexión y determina si su información está disponible en el momento de compilar.
  2. Compara el arranque de una serialización basada en reflexión con una basada en generación sobre el mismo modelo de datos.
  3. Localiza en tu proyecto la salida generada por alguna biblioteca y léela para entender qué código sustituye a qué búsqueda.
  4. Enumera tres casos legítimos de reflexión en tu dominio y justifica por qué la información no puede conocerse antes.
  5. Escribe la anotación con retención de fuente que un procesador leería para generar la solución del punto uno, y guárdala para el nivel siguiente.