Evolucionar sin romper: compatibilidad y deprecación
Qué significa exactamente que un cambio sea compatible, la distinción entre compatibilidad de fuente y compatibilidad binaria, el catálogo de modificaciones seguras y peligrosas, y el uso de la anotación de disponibilidad como herramienta de migración asistida por el compilador.
Publicar una API es aceptar una restricción permanente sobre tu libertad futura. A partir del momento en que alguien la usa, cada decisión que tomaste deja de ser tuya y pasa a ser un contrato, y la ingeniería consiste desde entonces en mejorar el sistema sin romper ese contrato. Swift ofrece para esa tarea un instrumental inusualmente preciso: una distinción explícita entre romper el código fuente y romper el binario, un modo de compilación que habilita la evolución de bibliotecas, y una anotación de disponibilidad que convierte la deprecación en un proceso guiado por el compilador en lugar de en una nota al pie que nadie lee.
- Distinguir compatibilidad de fuente, compatibilidad binaria y compatibilidad semántica.
- Clasificar un cambio propuesto como seguro, arriesgado o rupturista antes de publicarlo.
- Usar la anotación de disponibilidad con sus formas de introducción, deprecación, renombrado y obsolescencia.
- Diseñar una retirada por fases que migre a los usuarios en vez de abandonarlos.
Tres compatibilidades que no son la misma
Se habla de romper una API como si fuera un fenómeno único, y en realidad hay tres capas independientes que pueden romperse por separado.
La compatibilidad de fuente significa que el código que compilaba antes sigue compilando. Es la que preocupa a cualquiera que distribuya un paquete por código fuente, que es el caso normal en el ecosistema de Swift.
La compatibilidad binaria, o estabilidad de ABI, significa que un binario ya compilado contra la versión antigua sigue enlazando y ejecutándose contra la nueva sin recompilar. Solo importa cuando distribuyes marcos precompilados, y en Swift requiere compilar con evolución de biblioteca activada, lo que introduce indirecciones para que el diseño interno de los tipos pueda cambiar sin invalidar el código cliente.
La compatibilidad semántica es la más silenciosa y la que más daño hace: todo compila, todo enlaza, y el comportamiento cambió. Un valor por defecto distinto, un orden de resultados que deja de estar garantizado, una función que ahora se ejecuta en otro hilo. No hay herramienta que la detecte y solo la disciplina de la documentación previa la limita.
// Rompe la fuente: el llamador debe cambiar
- func buscar(_ t: String) -> [R]
+ func buscar(texto t: String) -> [R]
// Compatible en fuente, rompe el binario sin evolucion activada
struct Config { let color: Color; let tamano: Int
+ var margen: Int = 0 }
// Compila, enlaza y cambia el comportamiento
- func buscar(_ t: String, sensible: Bool = false)
+ func buscar(_ t: String, sensible: Bool = true)
Antes de tocar una firma pública pregúntate qué ocurre con cada una de las tres capas por separado. La mayoría de los desastres proceden de asumir que compilar sin avisos equivale a no haber roto nada.
El catálogo de cambios
Conviene tener memorizada la clasificación, porque decide si el cambio cabe en una versión menor o exige una mayor.
Seguros en fuente. Añadir un tipo, una función o una propiedad nueva. Añadir un parámetro con valor por defecto al final. Añadir un requisito a un protocolo acompañado de implementación por defecto. Añadir una conformidad. Relajar una restricción genérica. Convertir un método en lanzador es seguro solo si nadie lo usaba como valor de función.
Rupturistas. Renombrar o eliminar cualquier símbolo público. Cambiar un tipo de parámetro o de retorno. Reordenar parámetros. Añadir un requisito de protocolo sin implementación por defecto. Añadir un caso a una enumeración pública que los consumidores conmuten de forma exhaustiva. Endurecer una restricción genérica.
Zona gris. Añadir una sobrecarga puede volver ambigua una llamada que antes resolvía sin problema. Añadir una conformidad puede cambiar qué implementación se elige cuando hay extensiones compitiendo. Cambiar el valor por defecto de un parámetro no rompe nada visible y altera el comportamiento de todos los llamadores a la vez.
El caso de las enumeraciones merece detalle porque es donde más gente se estrella. Una enumeración pública compilada con evolución de biblioteca se considera no congelada por defecto: el consumidor de otro módulo debe incluir un caso desconocido al conmutar, y así puedes añadir casos sin romperlo. Marcarla como congelada te compromete a no añadir ninguno nunca más, a cambio de que el compilador exija exhaustividad real y pueda optimizar mejor.
switch estado {
case .activo: activar()
case .pausado: pausar()
@unknown default: registrar("estado no reconocido")
}
flowchart TD
A[Cambio propuesto] --> B{Aparece o desaparece un simbolo}
B -- Desaparece --> C[Version mayor]
B -- Aparece --> D{Puede crear ambiguedad}
D -- Si --> E[Revisar con cuidado]
D -- No --> F[Version menor]
A --> G{Cambia comportamiento observable}
G -- Si --> H[Documentar y avisar aunque compile]
style C fill:#f38ba8,color:#11111b
style F fill:#a6e3a1,color:#11111b
style H fill:#f9e2af,color:#11111bLa anotación de disponibilidad
El atributo @available cumple dos funciones distintas que conviene no mezclar: declarar en qué plataformas y versiones existe un símbolo, y declarar en qué fase de su ciclo de vida se encuentra dentro de tu propia biblioteca.
@available(iOS 17, macOS 14, *)
public func buscarConIndiceNuevo(_ texto: String) -> [Resultado]
@available(*, deprecated, message: "Usa buscar con limite explicito")
public func buscarTodo(_ texto: String) -> [Resultado]
@available(*, deprecated, renamed: "buscar(_:limite:)")
public func busqueda(_ texto: String, max: Int) -> [Resultado]
@available(*, unavailable, message: "Eliminado en 3.0. Usa Indice.buscar")
public func buscarLegado(_ texto: String) -> [Resultado]
La forma con renombrado es la más valiosa y la más desaprovechada. No se limita a emitir un aviso: le dice al compilador cuál es el símbolo sustituto, lo que habilita la corrección automática en Xcode. El usuario recibe un aviso con un botón que reescribe la llamada por él, y una migración que habría exigido leer notas de versión se resuelve sin abandonar el editor. El coste para ti es escribir correctamente la firma del sustituto, incluidas las etiquetas.
Del lado de las plataformas, la comprobación en tiempo de ejecución completa el mecanismo, y el asterisco final no es decorativo: significa que en cualquier plataforma no enumerada el símbolo se considera disponible.
if #available(iOS 17, *) {
usarIndiceNuevo()
} else {
usarIndiceClasico()
}
Existen además dos formas menos conocidas y muy útiles en bibliotecas maduras. La que marca obsolescencia a partir de una versión concreta convierte el aviso en error solo para quienes ya compilan contra ella, y la que declara introducción futura permite publicar una API preparada antes de que su plataforma exista.
Deprecar con elegancia
Una deprecación bien hecha es un proceso con fases, no un atributo colocado el día antes de borrar el símbolo. El patrón que funciona tiene cuatro pasos y ocupa al menos dos versiones menores antes de la mayor.
Primero publica el sustituto y deja que conviva con el original sin marcar nada. El usuario necesita poder migrar antes de sentirse presionado a hacerlo. Segundo, marca la deprecación con renombrado e incluye en el mensaje la versión en la que desaparecerá, dato que casi todo el mundo omite y que es justo el que el usuario necesita para planificar. Tercero, mantén el símbolo funcionando durante todo el periodo: una función deprecada que además ha dejado de comportarse bien es una trampa. Cuarto, elimínalo solo en una versión mayor y deja en su lugar, si puedes, una declaración marcada como no disponible con un mensaje que explique adónde ir; ese cadáver anotado convierte un error de símbolo no encontrado en una instrucción de migración.
// Version 2.3: convive el sustituto
public func buscar(_ t: String, limite: Int = .max) -> [Resultado]
// Version 2.4: deprecacion con destino y fecha
@available(*, deprecated, renamed: "buscar(_:limite:)",
message: "Se eliminara en 3.0")
public func busqueda(_ t: String, max: Int) -> [Resultado]
// Version 3.0: lapida util en lugar de silencio
@available(*, unavailable, renamed: "buscar(_:limite:)")
public func busqueda(_ t: String, max: Int) -> [Resultado] { fatalError() }
Un recurso complementario que cuesta poco: cuando renombras un tipo, deja un alias público deprecado. La compatibilidad de fuente se conserva íntegra por una línea, y el aviso guía la migración sin obligar a nadie a detenerse.
Tres capas, no una
Fuente, binario y semántica se rompen por separado. Compilar sin avisos no demuestra nada sobre la tercera.
Renombrado es migración
La forma con renamed habilita la corrección automática y convierte un aviso en un botón que reescribe el código.
Deja lápida
Un símbolo marcado como no disponible con mensaje enseña adónde ir. Borrarlo sin más solo produce un error mudo.
La evolución de interfaces es uno de los pocos ámbitos de la ingeniería donde el coste de una decisión no lo paga quien la toma sino quien viene después, y esa asimetría temporal explica casi todo lo que aquí se discute. Publicar un símbolo equivale a emitir una obligación perpetua contra tu yo futuro, con la particularidad de que el acreedor no eres tú sino un conjunto desconocido y creciente de terceros cuyo código no puedes ver, no puedes probar y no puedes arreglar. De ahí que la superficie pública deba tratarse como un pasivo en el balance y no como un activo: cada declaración exportada aumenta lo que prometes sostener, y a diferencia de la deuda financiera esta no vence sola, porque la única forma de amortizarla es romper el código de alguien. Esto invierte una intuición muy extendida, la de que una biblioteca es mejor cuanto más ofrece; en realidad, mientras la funcionalidad se mide por lo que permite hacer, el coste de mantenimiento se mide por lo que impide cambiar, y ambas magnitudes crecen juntas solo hasta cierto punto, tras el cual la segunda domina y la biblioteca se vuelve incapaz de mejorar sin traicionarse. La consecuencia práctica es que la decisión más importante de diseño no es qué exponer sino qué no exponer, porque lo interno se puede rehacer sin negociar con nadie mientras que lo público solo se puede rehacer negociando con todos; y la consecuencia metodológica es que la deprecación merece tratarse como un mecanismo de primera clase y no como un trámite, ya que es el único procedimiento civilizado para reducir un pasivo sin declararse en quiebra. Hay aquí una lección que excede al software y que cualquiera reconocerá en el diseño de instituciones, de protocolos o de leyes: los sistemas que sobreviven mucho tiempo no son los que aciertan a la primera, cosa que no ocurre nunca, sino los que incorporan desde el principio un procedimiento explícito y no traumático para retirar sus propios errores, con avisos que llegan antes de la ejecución, plazos que permiten adaptarse y caminos de salida señalizados. Un sistema sin ese procedimiento acumula decisiones fósiles hasta que la única evolución posible es la ruptura, y entonces la ruptura llega igualmente, pero de golpe y sin haber avisado a nadie.
Fuente, binario y semántica son tres compatibilidades distintas y solo la primera la vigila el compilador. Añadir con defecto o con implementación por defecto es seguro; renombrar, eliminar y añadir requisitos sin defecto no lo es. Las enumeraciones públicas no congeladas admiten casos nuevos a cambio de exigir caso desconocido al consumidor. Y la deprecación es un proceso de cuatro fases donde renamed hace el trabajo de migración por el usuario.
- Inventaria la superficie pública de un módulo tuyo y marca qué símbolos no deberían haberse expuesto nunca.
- Clasifica cinco cambios que quieras hacer en seguros, arriesgados y rupturistas, justificando cada categoría en las tres capas.
- Deprecia un método con
renamedy comprueba en Xcode que la corrección automática reescribe la llamada. - Convierte una enumeración pública en no congelada y observa qué exige el compilador en el módulo consumidor.
- Escribe el calendario completo de una retirada en cuatro fases con las versiones concretas y el texto exacto de cada mensaje.