Cuándo NO generar: el coste que nadie contabiliza
Cierre del nivel con el criterio inverso: el impuesto permanente que un procesador cobra en cada construcción, la opacidad del código que nadie lee, el efecto sobre la depuración y sobre quien entra nuevo al proyecto, y el repaso de las alternativas más simples que resuelven la mayoría de los casos sin añadir maquinaria.
Después de cuatro capítulos dedicados a la maquinaria, toca el capítulo que evita que se use mal. La generación de código tiene una propiedad peculiar entre las técnicas avanzadas: sus beneficios se ven el primer día y sus costes se ven el segundo año. El día que introduces un procesador eliminas doscientas líneas repetitivas y todo el mundo aplaude; dos años después el proyecto tarda el doble en construir, nadie recuerda por qué aquel fichero generado tiene esa forma, la persona que escribió el procesador ya no está, y una actualización del compilador está bloqueada por una dependencia que no se mantiene. Ninguno de esos problemas es hipotético y todos son consecuencia directa de una decisión que en su momento parecía obviamente buena. Este capítulo no pretende disuadir de generar, sino dotar de la contabilidad completa, porque la asimetría temporal entre beneficio y coste es justamente lo que hace que esta decisión se tome mal con tanta frecuencia.
- Contabilizar el coste real de un procesador en tiempo de construcción y en ciclo de trabajo.
- Reconocer los síntomas de opacidad en un proyecto con mucho código generado.
- Recorrer las alternativas más simples y saber hasta dónde llega cada una.
- Aplicar un criterio explícito de umbral antes de introducir generación nueva.
El impuesto de construcción
Conviene empezar por lo que sí se puede medir, porque es la parte del argumento que nadie puede discutir con opiniones. El resto de costes de esta lección son reales pero cualitativos; este no.
El coste más medible es el tiempo. Cada procesador añade trabajo a cada construcción, y aunque KSP sea mucho más barato que el mecanismo que sustituyó, barato no es gratis. Lo relevante no es el número absoluto sino su posición en el ciclo: el tiempo de construcción incremental es el que separa escribir una línea de ver si funciona, y ese intervalo se repite decenas de veces al día por cada persona del equipo. Un aumento de veinte segundos parece ridículo en una hoja de cálculo y resulta devastador en la práctica, porque es exactamente el umbral a partir del cual la atención se va a otra parte.
Hay además un efecto de segundo orden que se pasa por alto y que suele doler más que el coste directo: las dependencias de invalidación. Un fichero generado de forma agregadora depende de todo el conjunto anotado, así que tocar cualquier clase del conjunto obliga a regenerarlo y a recompilar todo lo que dependa de él. Basta con un índice global mal diseñado para convertir un proyecto con buena modularidad en uno donde cada cambio recompila la mitad.
// Medida honesta: tocar una sola clase y cronometrar
// ./gradlew :app:compileKotlin --profile
// repetir con la linea del procesador comentada
dependencies {
ksp("com.ejemplo:procesador:1.0")
}
El tercer componente del impuesto es el más difícil de ver porque no se paga en segundos sino en calendario. Cada procesador es una dependencia que debe publicar una versión compatible cada vez que sale una versión nueva de Kotlin, y el proyecto no puede actualizar hasta que el más lento de todos lo haya hecho. Un equipo con seis procesadores no espera al promedio, espera al máximo, y esa espera se acumula versión tras versión hasta que el proyecto vive permanentemente un año por detrás.
Ciclo
Mide el incremental, no la construcción limpia. El limpio se sufre una vez al día y el incremental cincuenta.
Invalidación
Un agregador mal colocado propaga cada cambio a todo el módulo y anula el beneficio de haberlo dividido.
Acoplamiento
Cada procesador ata el proyecto al calendario de versiones de un tercero. La actualización va al ritmo del más lento.
Arranque en frío
Las herramientas de construcción pagan además el arranque de la fase de procesamiento en cada ejecución nueva.
Lo que nadie lee y nadie entiende
El segundo coste es cognitivo y no aparece en ninguna medición. El código generado tiene una característica que lo distingue de todo lo demás en el proyecto: nadie lo revisa. No pasa por revisión de cambios, no se discute en ninguna reunión y normalmente ni siquiera está en el control de versiones. Eso lo convierte en la única parte del sistema donde una decisión discutible puede replicarse trescientas veces sin que nadie opine.
El efecto sobre la depuración es inmediato. Una traza que atraviesa código generado presenta nombres de fichero que no existen en el editor, líneas que no corresponden a nada escrito y a veces clases con sufijos automáticos que no significan nada para quien las lee. La persona que depura tiene que reconstruir mentalmente qué produjo aquello antes de poder pensar en el fallo, y si el procesador es de terceros a menudo esa reconstrucción es imposible sin leer su código fuente.
// Lo que escribiste
@Inyectable class Servicio(private val repo: Repo)
// Lo que aparece en la traza cuando algo falla al arrancar
// at com.ejemplo.Servicio_Factory.get(Servicio_Factory.kt:31)
// at com.ejemplo.Componente_Impl.servicio(Componente_Impl.kt:118)
// Ninguno de esos dos ficheros existe en el editor
El efecto sobre quien entra al proyecto es todavía mayor. Un programador nuevo puede leer una función y entenderla; no puede leer una anotación y entender qué implica, porque su significado está en un procesador que vive en otro repositorio. Cada anotación mágica es una parte del sistema que solo se aprende preguntando, y la cantidad de conocimiento que no está en el código sube en línea recta con el número de procesadores.
Existe un caso especialmente perverso, que es el del procesador escrito dentro de la propia casa. Los de terceros al menos tienen documentación, incidencias públicas y otras personas que los usan; el de casa tiene un autor, cero documentación y la suposición tácita de que ese autor seguirá disponible. Cuando esa suposición falla, el equipo se encuentra con una parte del compilador que nadie entiende y que nadie se atreve a tocar, lo que en la práctica congela cualquier cambio en la forma del código generado.
Si tu procesador produce funciones que otros módulos llaman, esas funciones son API pública aunque nadie las haya escrito. Cambiar su forma rompe a los usuarios exactamente igual que cambiar una firma a mano, con el agravante de que no hay ningún fichero donde ese contrato esté escrito para poder discutirlo.
Hay tres señales que casi siempre indican que el proyecto ha pasado el punto de rentabilidad. La primera es que alguien pregunte dónde está definida una función y la respuesta sea que no está definida. La segunda es que la solución habitual ante un fallo raro sea limpiar y reconstruir. La tercera es que exista una anotación que solo dos personas del equipo saben configurar.
Alternativas más simples
Antes de introducir generación conviene agotar el catálogo de mecanismos que Kotlin ya ofrece, porque una parte considerable de los casos que parecen pedir un procesador se resuelven con lenguaje corriente y sin ninguna maquinaria.
La primera alternativa es la más obvia y la más despreciada: escribirlo a mano. Veinte adaptadores escritos a mano son quinientas líneas aburridas, verificadas, legibles y depurables, y la aritmética de mantenerlas rara vez es peor que la de mantener un procesador. La generación empieza a compensar cuando la cifra es de cientos y cuando el patrón cambia poco. Conviene recordar además que las quinientas líneas se escriben una vez y el procesador se mantiene siempre, de modo que la comparación honesta no es entre dos costes iniciales sino entre un coste único y una renta perpetua.
La segunda es la combinación de funciones en línea con parámetros de tipo materializados, que resuelve muchos casos donde se creía necesitar el tipo en ejecución. La tercera son las jerarquías selladas con un when exhaustivo, que sustituyen con ventaja a los registros generados porque el compilador obliga a completar cada rama cuando se añade un caso nuevo. La cuarta son los delegados de propiedad, que capturan patrones repetidos de acceso sin generar nada.
// En vez de generar un registro de convertidores por tipo
inline fun <reified T> leer(texto: String): T = analizar(texto, T::class)
// En vez de generar un indice de subtipos
sealed interface Evento {
data class Alta(val id: Long) : Evento
data class Baja(val id: Long) : Evento
}
fun despachar(e: Evento): String = when (e) {
is Evento.Alta -> "alta " + e.id
is Evento.Baja -> "baja " + e.id
}
Las cuatro comparten una virtud que ninguna herramienta externa puede ofrecer: todo lo que hacen es visible en el editor, navegable con el cursor y comprensible sin salir del proyecto. Cuando alguien pregunta de dónde sale una función, la respuesta se da pulsando una tecla, y esa capacidad de responder preguntas sin ceremonia es probablemente el activo más subestimado de un código base.
Queda una quinta alternativa que a menudo es la mejor y casi nunca se considera, que es generar una vez y consignar el resultado. Si el patrón cambia dos veces al año, un guion que produce el código y lo deja en el repositorio ofrece casi todo el beneficio sin ningún impuesto de construcción: el resultado es legible, revisable, depurable y no ata el proyecto a ninguna herramienta. El precio es acordarse de volver a ejecutarlo, y para eso basta con una comprobación en la integración continua.
flowchart TD
A[Patron repetido] --> B{Cuantas repeticiones}
B -->|Pocas| C[Escribirlo a mano]
B -->|Muchas| D{Cambia a menudo}
D -->|No| E[Generar una vez y consignar]
D -->|Si| F{Basta con lenguaje}
F -->|Si| G[inline reified o sealed o delegados]
F -->|No| H[Procesador de simbolos]El criterio
Reunir todo lo anterior da una lista corta de preguntas que conviene responder por escrito antes de añadir un procesador nuevo. Cuántas repeticiones hay hoy y cuántas se prevén. Cuánto tiempo de construcción incremental añade, medido y no estimado. Quién mantiene la herramienta y qué pasa si deja de hacerlo. Qué verá en la traza quien depure un fallo dentro de lo generado. Y la más incómoda de todas: qué haría falta para quitarlo dentro de tres años.
Ese conjunto de preguntas produce respuestas muy distintas según el caso, y esa es justamente su utilidad. Para la serialización de un proyecto multiplataforma todas se responden bien: la repetición es masiva, el coste está medido y es pequeño, el mantenedor es el equipo del lenguaje y la alternativa manual es inviable en varios objetivos. Para un procesador propio que ahorra treinta líneas de andamiaje en cuatro clases, casi ninguna se responde bien, y sin embargo ese es el tipo de procesador que más se escribe, porque escribirlo es divertido y contabilizarlo no lo es.
Merece la pena añadir un matiz sobre el momento de decidir. Introducir generación al principio de un proyecto es casi siempre prematuro, porque todavía no se sabe si el patrón se repetirá ni qué forma acabará teniendo; introducirla cuando ya hay ciento cincuenta repeticiones idénticas y estables es una decisión informada. El orden que produce menos arrepentimiento es escribir a mano hasta que duela, observar la forma que el patrón adoptó por sí solo y solo entonces automatizar exactamente esa forma.
Una herramienta de la que se puede salir es una decisión; una de la que no se puede salir es un compromiso. Antes de adoptar un procesador, describe en dos frases cómo sería sustituirlo por código escrito a mano. Si la respuesta es que habría que escribir a mano diez mil líneas que nadie sabe cómo son, estás adoptando un compromiso y conviene tratarlo como tal.
Hay un patrón de razonamiento que aparece una y otra vez en la vida de un proyecto y que merece nombrarse porque, una vez visto, resulta imposible dejar de reconocerlo. Alguien detecta repetición, siente la incomodidad legítima que produce, y construye un mecanismo que la elimina. La repetición desaparece, el recuento de líneas baja y todo el mundo queda satisfecho. Lo que no se contabiliza en ese momento es que la repetición era visible, local y verificable, mientras que el mecanismo que la sustituye es invisible, global y solo verificable por quien lo escribió. Se ha cambiado una cantidad de trabajo por una cantidad de conocimiento tácito, y esos dos recursos no se comportan igual con el tiempo: el trabajo repetitivo se reparte entre todos y no crece si el equipo cambia, mientras que el conocimiento tácito se concentra en pocas cabezas y se evapora en cuanto esas cabezas se van. La generación de código es probablemente el ejemplo más puro de este intercambio, porque su beneficio es inmediato y perfectamente medible en líneas ahorradas, mientras que su coste es diferido y no se puede medir en nada. Esto no significa que generar esté mal, y de hecho hay dominios enteros, como la serialización o la inyección de dependencias, donde no generar sería sencillamente irracional. Significa que el argumento a favor nunca puede ser que hay repetición, porque la repetición sola no basta; el argumento tiene que ser que la repetición es masiva, mecánica, estable y verificable, y que además el mecanismo que la sustituye va a estar mantenido por alguien durante todo el tiempo que el proyecto viva. Cuando esas cuatro condiciones se cumplen, generar es una de las mejores decisiones disponibles. Cuando falla cualquiera de ellas, lo que se está construyendo no es una abstracción sino una deuda con forma de anotación, y la factura la pagará alguien que hoy ni siquiera trabaja aquí.
- Mide el tiempo incremental de tu proyecto con y sin cada procesador activo y ordénalos por coste.
- Localiza el fichero generado más grande y léelo entero, anotando cuántas decisiones de diseño contiene que nadie revisó.
- Provoca un fallo dentro de código generado y describe qué vería una persona nueva en el equipo al leer la traza.
- Elige un procesador propio y comprueba si el mismo resultado se logra con jerarquías selladas, materialización de tipos o delegados.
- Escribe el plan de salida de tu procesador más crítico y estima cuánto costaría ejecutarlo.