Evolucionar sin romper: obsolescencia, sobrecarga y calendario
Una biblioteca que no puede cambiar está muerta y una que cambia rompiendo también. La salida es un procedimiento: marcar el símbolo viejo con `@Deprecated` y una sustitución automática, atravesar sus tres niveles de severidad en un orden que nunca retira el símbolo del binario antes de tiempo, preferir la sobrecarga al parámetro con valor por defecto y anunciar por adelantado el calendario completo de retirada. Esta lección desarrolla ese procedimiento paso a paso.
Las dos lecciones anteriores dejan un panorama que se puede leer como una parálisis: casi cualquier cambio interesante en una API rompe algo, y ahora además hay una herramienta que lo detecta y detiene la construcción. Si el asunto acabara ahí, la conclusión razonable sería no tocar nunca nada, y esa conclusión es tan mala como la contraria, porque una biblioteca que no puede corregir sus errores de diseño se convierte en un pasivo que sus consumidores acaban abandonando. La salida no es técnica sino procedimental, y consiste en aceptar que la sustitución de un símbolo no es un evento sino un proceso con duración: hay un intervalo, medido en versiones y en meses, durante el cual conviven lo viejo y lo nuevo, el compilador empuja a la gente hacia lo nuevo con una intensidad creciente y el binario antiguo sigue existiendo para que nada estalle. Diseñar bien ese intervalo es la mitad del oficio de mantener una biblioteca.
- Describir el ciclo de vida completo de un símbolo publicado, desde su introducción hasta su retirada efectiva.
- Usar
@Deprecatedcon sus tres niveles y conReplaceWithpara que la migración del consumidor sea automática. - Sustituir el parámetro con valor por defecto por sobrecargas que preserven el descriptor original.
- Publicar un calendario de retirada verificable y alinearlo con la numeración de versiones del proyecto.
El ciclo de vida de un símbolo publicado
Un símbolo publicado atraviesa cuatro estados, y el error más común de los equipos que empiezan a mantener bibliotecas es intentar saltar del primero al último. El estado normal es el de vigencia: el símbolo se ofrece, se documenta y se recomienda. El segundo es el de desaconsejado con aviso, en el que sigue funcionando exactamente igual pero el compilador señala su uso y ofrece la alternativa. El tercero es el de desaconsejado con error, en el que el código nuevo ya no puede escribirlo, aunque el código antiguo ya compilado siga enlazando sin problema. El cuarto es el de oculto, en el que el símbolo desaparece de la vista del compilador y permanece únicamente en el artefacto, como una reliquia que solo el bytecode antiguo puede invocar.
Ese cuarto estado es la pieza que casi nadie usa y la que hace que todo el esquema funcione, porque separa por primera vez dos cosas que hasta ahora iban juntas: retirar algo de la API de fuente y retirarlo de la API binaria. Un símbolo oculto ya no forma parte de la superficie que nadie puede escribir, de modo que a efectos de diseño está muerto; y sin embargo sigue en el artefacto, de modo que a efectos de enlazado sigue vivo. La retirada real, la que borra la línea del volcado y el método de la clase, se reserva para el cambio de versión mayor.
La duración de cada estado es una decisión de proyecto y no una propiedad del lenguaje, y de ella depende que el procedimiento funcione o resulte insufrible. Un intervalo demasiado corto no da tiempo a migrar a quien actualiza dos veces al año, con lo que la ruptura llega igual, solo que precedida de un aviso que el consumidor nunca llegó a ver. Un intervalo demasiado largo acumula una capa de símbolos zombis que hay que seguir compilando, probando y documentando, y que además compiten en el autocompletado con las alternativas que deberían sustituirlos. El punto razonable en la mayoría de proyectos son dos o tres versiones menores por estado, con un mínimo expresado también en meses.
flowchart LR A[Vigente] --> B[Desaconsejado con aviso] B --> C[Desaconsejado con error] C --> D[Oculto: fuera de la fuente y dentro del binario] D --> E[Eliminado en la siguiente version mayor]
Los tres niveles y la sustitución automática
La anotación que gobierna los estados intermedios lleva tres piezas y conviene usar las tres. El mensaje explica por qué el símbolo se retira, que es la información que el consumidor necesita para decidir si su caso es el previsto. El nivel elige la severidad. Y la cláusula de reemplazo, ReplaceWith, describe la expresión sustituta con la sintaxis del propio Kotlin, lo que permite al entorno de desarrollo ofrecer una corrección automática y, sobre todo, aplicarla en masa a un proyecto entero.
@Deprecated(
message = "El tiempo limite se configura ahora en Opciones",
replaceWith = ReplaceWith(
"conectar(host, Opciones(tiempoLimite))",
"com.ejemplo.red.Opciones"
),
level = DeprecationLevel.WARNING
)
public fun conectar(host: String, tiempoLimite: Int): Conexion =
conectar(host, Opciones(tiempoLimite))
public fun conectar(host: String, opciones: Opciones): Conexion { /* ... */ }
El segundo argumento de la cláusula de reemplazo es la lista de importaciones que la expresión necesita, y omitirlo es el descuido más frecuente: sin él, la corrección automática produce código que no compila y el consumidor deja de confiar en la herramienta. Merece la pena verificar cada sustitución aplicándola una vez sobre un fichero real antes de publicarla.
Merece la pena vigilar un efecto colateral que arruina la experiencia de quien consume: la propagación del aviso hacia dentro de la propia biblioteca. Si el símbolo desaconsejado sigue usándose internamente, cada uso genera un aviso en tu propia construcción, y el equipo aprende a ignorar los avisos de obsolescencia justo en el proyecto donde más importan. La disciplina consiste en migrar todos los usos internos en el mismo cambio que introduce la marca, dejando la implementación antigua reducida a una delegación de una sola línea.
Sobre los niveles, la regla es que el paso de aviso a error se hace en una versión menor y el paso a oculto también, mientras que la eliminación física espera a la mayor. Los tres primeros son compatibles en binario: el símbolo sigue estando en el artefacto en los tres, y lo único que cambia es lo que el compilador permite escribir. Esa propiedad es la que autoriza a endurecer la severidad sin coordinarse con nadie.
// Version 1.4: se anuncia
@Deprecated("Usa Opciones", ReplaceWith("conectar(host, Opciones(tiempoLimite))"))
public fun conectar(host: String, tiempoLimite: Int): Conexion = TODO()
// Version 1.6: deja de poder escribirse, sigue enlazando
@Deprecated("Usa Opciones", level = DeprecationLevel.ERROR)
public fun conectar(host: String, tiempoLimite: Int): Conexion = TODO()
// Version 1.8: desaparece de la vista, permanece en el artefacto
@Deprecated("Usa Opciones", level = DeprecationLevel.HIDDEN)
public fun conectar(host: String, tiempoLimite: Int): Conexion = TODO()
La biblioteca estándar formaliza esta escalera con una anotación propia que registra en qué versión concreta cada símbolo pasó a aviso, a error y a oculto, de modo que la política deja de ser una nota en un registro de cambios y pasa a estar en el propio código. Aunque esa anotación sea interna al proyecto de Kotlin, el patrón es reproducible en cualquier biblioteca y vale la pena copiarlo.
La sobrecarga en lugar del parámetro nuevo
El caso más frecuente de evolución es el que abría la segunda lección: hace falta un parámetro más. La solución idiomática dentro de una aplicación es el valor por defecto, y es exactamente la que no se puede usar en una biblioteca publicada, porque borra el descriptor anterior. La alternativa es escribir dos funciones reales, de modo que el descriptor viejo siga existiendo por derecho propio y no por cortesía del compilador.
// En una biblioteca publicada, esto anade un simbolo y no destruye ninguno
public fun conectar(host: String): Conexion =
conectar(host, Opciones.PorDefecto)
public fun conectar(host: String, opciones: Opciones): Conexion { /* ... */ }
La diferencia con la versión de un solo método y un valor por defecto es puramente binaria, porque para quien escribe la llamada ambas se usan igual. Esa es justamente la razón de que el error sea tan común: el código del consumidor no distingue las dos formas, y solo el artefacto lo hace.
La sobrecarga tiene además una ventaja de diseño que compensa su verbosidad: obliga a decidir qué combinaciones de argumentos tienen sentido en lugar de ofrecer el producto cartesiano de todas ellas, y ese ejercicio suele revelar que dos o tres parámetros opcionales estaban pidiendo agruparse en un objeto de configuración. Cuando el número de variantes crece, el objeto de opciones no es un rodeo sino la respuesta correcta, porque desplaza la evolución futura a un tipo que sí puede crecer con parámetros por defecto sin tocar ninguna firma pública.
// El objeto de opciones absorbe el crecimiento futuro sin tocar conectar
public class Opciones private constructor(
public val tiempoLimite: Int,
public val reintentos: Int,
) {
public companion object {
public val PorDefecto: Opciones = Opciones(30, 0)
}
public fun conTiempoLimite(valor: Int): Opciones = Opciones(valor, reintentos)
public fun conReintentos(valor: Int): Opciones = Opciones(tiempoLimite, valor)
}
El patrón de construir variantes con métodos que devuelven una copia modificada, en lugar de exponer un constructor con todos los parámetros, es lo que mantiene abierta la puerta: añadir una opción futura significa añadir un método y un campo privado, dos operaciones estrictamente aditivas. La tentación de convertir esa clase en clase de datos hay que resistirla por los motivos de la segunda lección, porque el constructor generado y la función de copia congelarían de nuevo el conjunto exacto de propiedades.
Existe una mitigación parcial para quien ya cometió el error y necesita restaurar un descriptor perdido, que consiste en pedir al compilador que genere las sobrecargas correspondientes a los parámetros finales omitidos. Funciona solo para parámetros al final de la lista, multiplica los símbolos del artefacto y no sustituye a un diseño pensado, pero es la vía rápida para devolver la vida a un método que desapareció por descuido en una versión menor.
Antes de marcar algo como desaconsejado, escribe la línea de código que el consumidor tendrá que escribir en su lugar. Si esa línea es más larga, más oscura o pierde alguna garantía, el problema no está en el símbolo viejo sino en el nuevo, y todavía estás a tiempo de rediseñarlo.
El calendario y su publicación
Nada de lo anterior sirve si el consumidor no sabe cuánto tiempo tiene. Un aviso sin fecha se ignora indefinidamente y luego se convierte en una queja legítima el día que se transforma en error. La contrapartida de exigir una migración es anunciar por adelantado su calendario completo, en un documento estable del repositorio y no solo en el registro de cambios de una versión que nadie relee.
Un calendario defendible para un proyecto con entregas regulares fija tres cosas. Primero, una duración mínima del estado de aviso, expresada en versiones menores y también en tiempo, porque hay consumidores que actualizan dos veces al año y para ellos el número de versiones no significa nada. Segundo, el compromiso de que ningún símbolo se elimina del artefacto fuera de una versión mayor, que es la promesa que permite a un consumidor actualizar versiones menores sin leer nada. Y tercero, una excepción escrita y estrecha para los fallos de seguridad, único caso en el que una retirada acelerada resulta aceptable, y que conviene tener redactada antes de necesitarla.
El calendario conviene además hacerlo verificable, y no solo publicable. Una forma barata consiste en que el propio código registre la versión en la que cada símbolo entró en cada estado, de modo que una prueba de la biblioteca pueda recorrer las anotaciones y fallar cuando alguna lleve más versiones de las permitidas en el mismo nivel. Con eso, el olvido deja de ser posible: un símbolo desaconsejado en la versión 1.4 y todavía en aviso en la 1.9 detiene la construcción y obliga a decidir si se endurece o si el calendario era irreal.
Conviene medir el proceso además de anunciarlo. Si el proyecto tiene consumidores conocidos, la señal más útil es cuántos siguen invocando el símbolo desaconsejado al acercarse la fecha de endurecimiento; si no los tiene, sirve el rastro de la sustitución automática en los proyectos públicos que dependen de la biblioteca. Un símbolo que sigue muy usado dos versiones después del aviso es un símbolo cuya sustitución no convence, y esa es información de diseño, no de comunicación.
Cuatro estados
Vigente, aviso, error y oculto. Los tres últimos conservan el símbolo en el artefacto; solo la versión mayor lo borra.
Sobrecarga, no defecto
En una biblioteca publicada el parámetro con valor por defecto destruye el descriptor anterior. Dos funciones reales no destruyen nada.
Fecha por delante
Un aviso sin calendario publicado no migra a nadie y convierte el endurecimiento posterior en una ruptura por sorpresa.
Merece la pena detenerse en la forma exacta que tiene la solución de esta lección, porque es una forma que reaparece en muchos otros sitios y que casi nunca se nombra. El problema de partida era una contradicción aparentemente insoluble entre dos exigencias legítimas: la API debe poder mejorar, porque ninguna decisión de diseño sobrevive intacta al contacto con miles de usos reales, y la API no debe romper, porque cada ruptura transfiere trabajo a gente que no participó en la decisión. Lo que resuelve la contradicción no es elegir una de las dos, sino introducir tiempo en el sistema: el símbolo viejo y el nuevo coexisten durante un intervalo, y esa coexistencia convierte una decisión binaria en una transición gestionable. Es exactamente la misma estructura que hace funcionar una migración de esquema en dos fases, un despliegue con lanzamiento gradual, una bandera de característica o una regla de compatibilidad hacia atrás en un protocolo de red; en los cinco casos, lo que parecía un salto imposible se vuelve trivial en cuanto se admite un periodo en el que ambos mundos son válidos a la vez. La consecuencia práctica más útil de verlo así es que la pregunta correcta ante cualquier cambio de API deja de ser si rompe o no rompe, que es una pregunta pobre, y pasa a ser cuál es el camino desde el estado actual hasta el deseado, cuántas paradas tiene y qué recibe el consumidor en cada una. Una biblioteca cuyo mantenedor sabe responder esa pregunta puede corregir prácticamente cualquier error de diseño, incluidos los estructurales, sin perder a nadie por el camino; una cuyo mantenedor no sabe responderla acaba siempre en uno de los dos finales conocidos, que son el artefacto congelado que arrastra durante años una firma que todo el mundo sabe equivocada, o la ruptura brusca que fragmenta el ecosistema en dos versiones incompatibles y hace que la mitad de los consumidores nunca vuelva.
- Elige una función pública tuya que tenga un defecto de diseño real y escribe primero la línea exacta que el consumidor escribirá en su lugar.
- Marca la vieja con
@DeprecatedincluyendoReplaceWithcon sus importaciones y aplica la corrección automática sobre un proyecto de prueba. - Convierte un parámetro con valor por defecto en dos sobrecargas y comprueba en el volcado de superficie que el descriptor original reaparece.
- Redacta el calendario de retirada de tu proyecto en tres reglas, con duración mínima, promesa de versión mayor y excepción de seguridad.
- Simula el ciclo completo publicando tres versiones locales con los niveles de aviso, error y oculto, y verifica en cada una que un consumidor antiguo sigue ejecutando.