wandres.dev
KSP Y PLUGINS · generar en vez de reflexionar

Por qué generar código: compilación contra ejecución

Toda pregunta que un programa se hace puede responderse en tres momentos distintos: cuando escribes, cuando compilas o cuando ejecutas. Esta lección estudia ese eje temporal como una decisión de diseño, compara la generación de código con la reflexión y con el escrito a mano, y establece qué familia de problemas se resuelve mejor moviendo el trabajo hacia el compilador.

⏱ 20 min

Hay una pregunta que atraviesa toda la ingeniería de software y que casi nunca se formula de manera explícita: cuándo se responde cada pregunta que el programa necesita responder. Convertir un objeto a JSON exige saber qué propiedades tiene y cómo se llaman en el formato de salida. Inyectar una dependencia exige saber qué constructor invocar y con qué argumentos. Mapear una fila de base de datos a una clase exige saber la correspondencia entre columnas y campos. Ninguna de esas preguntas es difícil; lo interesante es que todas admiten al menos tres respuestas distintas según el momento en que se resuelvan, y que ese momento determina el rendimiento, la seguridad de tipos, el tamaño del binario, la velocidad de construcción y hasta la calidad de los mensajes de error que verá quien use tu librería. La generación de código no es una técnica exótica ni un truco de librería avanzada: es simplemente la decisión de responder esas preguntas mientras se compila, y viene con un intercambio muy concreto que conviene entender antes de escribir la primera anotación.

🎯 Al terminar esta lección sabrás
  • Situar cualquier decisión del programa en el eje de tres momentos: escritura, compilación y ejecución.
  • Comparar reflexión, generación de código y escritura manual según coste, seguridad y mantenimiento.
  • Reconocer la familia de problemas cuya solución natural es generar en lugar de reflexionar.
  • Formular el intercambio en términos medibles y no como preferencia estética.

Las tres épocas de una pregunta

Empecemos por fijar el vocabulario, porque la confusión habitual viene de mezclar dos ejes distintos. Un eje es el de quién produce el código: tú o una herramienta. Otro es el de cuándo se responde la pregunta que el código resuelve. Son independientes, y lo interesante ocurre en el segundo.

Considera algo tan corriente como serializar una clase de datos. Si la resuelves en tiempo de escritura, escribes tú mismo la función que recorre las propiedades y construye la cadena; el compilador comprueba cada línea, el rendimiento es óptimo y el coste lo pagas en tecleo y en el riesgo de olvidar actualizar la función cuando añadas un campo. Si la resuelves en tiempo de ejecución, usas reflexión: el programa se pregunta a sí mismo qué propiedades tiene la clase en el momento de necesitarlo, lo cual es enormemente flexible pero implica que un campo mal nombrado se descubre en producción y no en la consola del compilador.

Queda la tercera vía. En tiempo de compilación un procesador lee la estructura de tu código, deduce lo mismo que habría deducido la reflexión y escribe el código que habrías escrito tú a mano. El programa resultante no reflexiona sobre nada: en el binario final solo hay una función normal que accede a propiedades por su nombre real, verificada por el compilador como cualquier otra.

Conviene notar que las tres vías responden literalmente la misma pregunta y producen resultados equivalentes. No compiten en potencia, compiten en el reparto del coste. La primera concentra todo el gasto en la persona que escribe. La segunda lo concentra en la máquina que construye. La tercera lo distribuye en cada ejecución del programa, para siempre y multiplicado por el número de usuarios.

// Escritura: el humano transcribe lo que ya sabe la clase
fun Usuario.aMapaManual(): Map<String, Any?> =
    mapOf("id" to id, "nombre" to nombre, "activo" to activo)

// Ejecucion: el programa se lo pregunta a si mismo cada vez
fun Usuario.aMapaReflexivo(): Map<String, Any?> =
    Usuario::class.memberProperties.associate { it.name to it.get(this) }

// Compilacion: alguien escribe la primera version por ti, una vez
@AMapa
data class Usuario(val id: Long, val nombre: String, val activo: Boolean)
flowchart LR
A[Pregunta del programa] --> B[Tiempo de escritura]
A --> C[Tiempo de compilacion]
A --> D[Tiempo de ejecucion]
B --> E[Coste humano alto y verificado]
C --> F[Coste de build y verificado]
D --> G[Coste por llamada y sin verificar]

La tabla mental que conviene grabar es esta: lo que se decide antes se comprueba antes y cuesta menos por ejecución; lo que se decide después es más flexible y cuesta más cada vez que ocurre. La generación de código ocupa un punto muy particular de ese eje, porque conserva casi toda la comodidad de la reflexión sin pagar su precio en ejecución.

📝
El eje tiene un cuarto punto

Existe un momento aún más temprano que el de escritura, que es el de diseño del lenguaje. Cuando Kotlin decide que la nulabilidad forma parte del tipo, está respondiendo en la propia gramática una pregunta que otros lenguajes dejan para la ejecución. Ver los cuatro momentos juntos ayuda a entender por qué ciertas características parecen gratuitas: alguien las pagó antes, en un sitio donde el coste se paga una sola vez para todo el mundo.

Qué compras y qué pagas

El beneficio más obvio es el rendimiento. Una llamada reflexiva implica consultar metadatos, resolver nombres, comprobar accesibilidad y a menudo empaquetar argumentos en un array de tipo Array<Any?>; una llamada generada es una invocación directa que el compilador y la máquina virtual optimizan como cualquier otra. En arranques en frío la diferencia es abismal, y en plataformas donde la reflexión no existe o está mutilada, como Kotlin/Native o Kotlin/JS, deja de ser una cuestión de rendimiento para convertirse en una de viabilidad.

El segundo beneficio es la verificación. Un error en la configuración reflexiva se manifiesta como una excepción en ejecución, en el peor momento y con un mensaje pobre. El mismo error en un procesador se manifiesta como un mensaje del compilador, con fichero y línea, antes de que nadie despliegue nada. Esta diferencia es la que explica por qué las librerías serias de inyección de dependencias migraron a generación: no por velocidad, sino porque un grafo de dependencias incompleto debe ser un fallo de compilación.

El tercer beneficio, menos citado, es el tamaño y la poda. El código muerto que nunca se genera no pesa, mientras que la reflexión obliga a conservar metadatos de todo lo que podría consultarse, lo que arruina cualquier estrategia de minimización agresiva. Quien haya mantenido alguna vez una lista de reglas de conservación para un ofuscador reconocerá el síntoma: esa lista es la factura de haber decidido tarde.

⚠️
El coste oculto de decidir tarde

Una llamada reflexiva no solo cuesta más que una directa: cuesta de forma impredecible. Suele ser barata cuando el código está caliente y cara en el arranque, que es justo el momento en que el usuario está mirando. Medir en un banco de pruebas ya calentado oculta precisamente el coste que importa.

Hay un cuarto beneficio que solo se aprecia en librerías destinadas a otros, y es la calidad del diagnóstico. Un procesador puede exigir condiciones sobre el código anotado y explicarlas con un mensaje propio situado en la línea exacta. Esa capacidad convierte contratos que antes vivían en la documentación, y que por tanto se incumplían con regularidad, en reglas que el compilador impone. La diferencia entre una librería agradable y una desagradable suele estar casi entera en este punto.

Los costes son igual de reales. El primero es el tiempo de construcción: cada procesador añade una fase al ciclo, y un proyecto con cinco procesadores puede duplicar el tiempo de compilación incremental. El segundo es la depurabilidad: el código que falla no es el que escribiste, y seguir una traza hasta un fichero generado que nadie ha leído nunca resulta desconcertante. El tercero es el acoplamiento a la herramienta: el procesador debe seguir el ritmo de las versiones del compilador, y un proyecto atrapado en una versión antigua porque un procesador no se actualizó es una situación mucho más común de lo que parece.

La asimetría entre esos dos grupos es lo que hace la decisión difícil, y conviene enunciarla sin adornos: los beneficios los disfruta quien ejecuta el programa y los costes los paga quien lo desarrolla. Un usuario final jamás notará que su aplicación arranca rápido porque nadie reflexiona, pero el equipo notará todos los días los veinte segundos extra de cada compilación. Esa distribución explica por qué la generación se adopta con entusiasmo en librerías, donde el autor optimiza para miles de usuarios, y con reticencia en aplicaciones, donde el mismo equipo paga y cobra.

ℹ️
La reflexión no está prohibida

Nada de esto convierte la reflexión en un error. Sigue siendo la herramienta correcta cuando el conjunto de tipos no se conoce hasta ejecución, cuando construyes una consola de inspección o cuando cargas complementos externos. El argumento es de proporción: usar reflexión para algo que ya se sabe al compilar es pagar en cada llamada un precio que se podía pagar una sola vez.

La familia de problemas que pide generación

Existe un patrón común a casi todos los buenos casos de uso, y merece la pena enunciarlo con precisión: se genera cuando la información necesaria ya está presente en el código fuente y la única alternativa sería escribir a mano algo mecánicamente derivable de esa información. Si un ser humano puede mirar la clase y producir el resultado sin decidir nada, la máquina también puede.

El criterio tiene una formulación negativa igual de útil. Cuando producir el resultado exige tomar decisiones que no están escritas en ninguna parte, generar obliga a inventar un lenguaje de configuración para expresar esas decisiones, y ese lenguaje acaba siendo más complicado que el código que pretendía ahorrar. Toda herramienta de generación que ha degenerado en un monstruo de opciones empezó exactamente así: intentando automatizar algo que no era mecánico.

🔧

Serialización

La forma del dato ya está en la clase. Escribir el codificador a mano es transcribir esa forma, y olvidarse de un campo nuevo es el fallo más frecuente del mundo.

🧩

Inyección

El grafo de dependencias es deducible de los constructores. Resolverlo al compilar convierte un fallo de arranque en un error de compilación.

🗄️

Persistencia

La correspondencia entre tabla y clase es mecánica, y generar permite además verificar las consultas contra el esquema antes de ejecutarlas.

🌐

Clientes de red

Una interfaz anotada describe por completo la llamada; el cuerpo que la implementa es puro andamiaje derivable.

Hay un criterio complementario que ayuda a decidir en los casos dudosos, y es el de la multiplicidad. Generar tiene un coste fijo importante, así que solo compensa cuando el patrón se repite muchas veces. Un solo adaptador se escribe a mano en diez minutos y se lee mejor; doscientos adaptadores idénticos escritos a mano son una fuente inagotable de erratas y de revisiones tediosas. La generación paga su coste fijo con volumen.

A ese criterio conviene añadir el de la estabilidad. Un patrón que se repite mucho pero cambia de forma cada trimestre convierte el procesador en un proyecto paralelo con su propio ciclo de mantenimiento, y entonces el ahorro se evapora. Los casos donde generar sale claramente a cuenta combinan siempre las dos condiciones: mucha repetición y una forma que lleva años sin moverse, que es justamente la situación de la serialización o del mapeo entre tablas y clases.

💡
Pregúntate quién es el autor natural

Antes de introducir un procesador, imagina que un compañero muy meticuloso escribe ese código a mano. Si su trabajo consistiría en copiar mecánicamente lo que ya está declarado, generar es correcto. Si tendría que tomar decisiones, elegir nombres o hacer excepciones, generar producirá una herramienta llena de opciones y de casos especiales.

Por qué en Kotlin importa más

En un lenguaje que solo compilara para la máquina virtual de Java, la elección entre reflexionar y generar sería casi siempre una discusión de rendimiento y de gusto. En Kotlin no lo es, porque el lenguaje compila además a binario nativo, a WebAssembly y a JavaScript, y en esos destinos la reflexión completa sencillamente no está disponible. Una librería que dependa de inspeccionar tipos en ejecución queda automáticamente confinada a un solo objetivo, mientras que una construida sobre generación funciona en los cuatro sin cambiar una línea.

// Este codigo depende de metadatos que solo existen en la JVM
val nombres = Usuario::class.memberProperties.map { it.name }

// Este otro es una funcion corriente y compila en todos los objetivos
val nombresGenerados = Usuario.camposConocidos

Esta es la razón real de que el ecosistema multiplataforma de Kotlin esté construido casi por completo sobre generación, desde la serialización hasta la inyección de dependencias, y de que las librerías heredadas del mundo de Java que dependen de reflexión sean justamente las que no cruzan la frontera. La decisión sobre el eje temporal deja de ser una preferencia y se convierte en un requisito de portabilidad.

Hay una segunda razón, propia del lenguaje y no de sus destinos. Kotlin tiene conceptos que la reflexión de la plataforma no sabe representar, como las propiedades, la nulabilidad declarada, los valores por defecto o las clases de valor, y consultarlos en ejecución exige leer metadatos auxiliares con heurísticas frágiles. Un procesador que trabaja sobre el fuente los tiene delante sin esfuerzo. Ese contraste es exactamente el tema del capítulo siguiente, que cuenta cómo se pasó de una herramienta prestada de Java a otra que habla en Kotlin desde el primer momento.

El eje temporal es la variable de diseño más infravalorada del oficio

Cuando uno lleva tiempo programando descubre que muchísimas discusiones aparentemente técnicas son en realidad discusiones sobre cuándo debe conocerse una cosa. Los tipos estáticos y los dinámicos no discrepan sobre qué es un tipo, sino sobre si la comprobación ocurre al compilar o al ejecutar. La configuración por fichero y la configuración por código no discrepan sobre qué hay que configurar, sino sobre si un error de configuración se detecta al arrancar o al construir. La reflexión y la generación de código responden exactamente las mismas preguntas y solo se diferencian en el instante en que las responden. Reconocer ese eje cambia la forma de leer cualquier tecnología nueva, porque en cuanto lo ves ya no preguntas qué hace una librería sino en qué momento decide, y esa segunda pregunta predice casi todo lo demás: su rendimiento, la calidad de sus mensajes de error, su comportamiento en plataformas restringidas y hasta el tipo de bug que producirá cuando falle. Hay además una asimetría moral en este eje que conviene tener presente. Mover trabajo hacia atrás, hacia la compilación, casi siempre traslada dolor desde el usuario de tu programa hacia ti, que esperas unos segundos más en cada construcción; mover trabajo hacia adelante, hacia la ejecución, traslada dolor desde ti hacia el usuario, que sufre arranques lentos y fallos en producción. Ninguna de las dos direcciones es universalmente correcta, pero la segunda es la que se toma por inercia, porque es la que no exige decidir nada. Los cuatro capítulos que siguen tratan de la maquinaria que permite tomar la primera dirección de forma deliberada, y del criterio, tratado en el último, para saber cuándo no vale la pena tomarla.

⚔️ Sitúa tus decisiones en el eje
  1. Elige tres librerías que uses a diario y determina para cada una si resuelve su problema por reflexión, por generación o por ambas cosas.
  2. Mide el arranque en frío de un módulo tuyo y estima qué fracción se va en inicializar maquinaria reflexiva.
  3. Busca en tu código un patrón repetido más de veinte veces y escribe a mano qué información necesitaría un generador para producirlo.
  4. Escribe el mismo mapeo de una clase a un mapa en las dos versiones del ejemplo y compara los mensajes de error al renombrar un campo.
  5. Enumera los costes de construcción actuales de tu proyecto y decide cuál sería tu presupuesto máximo aceptable para un procesador nuevo.