Language modes: romper sin partir el ecosistema
El mecanismo por el que Swift introduce cambios incompatibles sin obligar a nadie a migrar de golpe: banderas de funcionalidad futura, modos de lenguaje por módulo, compilación condicional con `hasFeature` y las banderas experimentales que permiten probar lo que todavía no existe.
Todo lenguaje longevo se enfrenta a la misma trampa: las decisiones que hoy sabe equivocadas solo pueden corregirse rompiendo código que alguien ya escribió, y romperlo a la vez para todos es la forma más fiable de partir una comunidad en dos. Otros lenguajes han pagado ese precio con divisiones que duraron una década. Swift eligió otra salida, y es probablemente su aportación más original a la ingeniería de lenguajes: hizo del cambio incompatible una unidad discreta, nombrada, activable módulo a módulo y verificable por el propio programa. El resultado es que en un mismo binario conviven módulos compilados bajo reglas distintas, que una migración puede durar meses sin bloquear a nadie y que una funcionalidad puede probarse en producción antes de existir oficialmente.
- Explicar por qué un cambio incompatible necesita un mecanismo de adopción gradual y no solo una versión mayor.
- Activar banderas de funcionalidad futura por objetivo y entender cuándo dejan de ser necesarias.
- Fijar el modo de lenguaje de un módulo y razonar sobre la interoperación entre modos distintos.
- Usar
hasFeaturey las banderas experimentales para escribir código que se adelanta al lenguaje.
El problema de romper sin partir
Un cambio incompatible tiene dos costes distintos que conviene no confundir. El primero es el trabajo de migrar, que es finito y en general mecánico. El segundo es el bloqueo: si adoptar la versión nueva exige migrarlo todo a la vez, cualquier dependencia que no haya migrado detiene a todos sus dependientes, y esa cadena se propaga hasta que el ecosistema se congela en la versión antigua.
La solución clásica —una versión mayor que rompe y una migración global— falla precisamente por ese segundo coste. Swift la usó una vez, en la versión 3, cuando todavía podía permitírselo, y decidió no volver a hacerlo. El mecanismo que puso en su lugar descansa en tres piezas que se combinan: las funcionalidades incompatibles se nombran una a una, se pueden activar de forma independiente, y el modo de lenguaje es una propiedad del módulo y no del programa.
Las tres son necesarias. Nombrarlas permite hablar de un cambio concreto en lugar de una versión entera. Activarlas por separado convierte una migración monolítica en una secuencia de trabajos pequeños y verificables. Y que el modo sea del módulo rompe la cadena de bloqueo: una biblioteca compilada con reglas antiguas se importa sin problema desde un módulo compilado con las nuevas.
Conviene separar con cuidado tres cosas que se confunden a diario. La versión del compilador es la herramienta que tienes instalada y avanza sola. El modo de lenguaje es el conjunto de reglas bajo las que se compila un módulo concreto y lo eliges tú. Y la versión mínima de sistema es lo que exigen ciertas funcionalidades que dependen de bibliotecas distribuidas con el sistema operativo. Un compilador nuevo en modo antiguo es la combinación normal durante una migración larga; un compilador nuevo con una funcionalidad que exige un sistema reciente es un problema de despliegue distinto que ninguna bandera resuelve.
Activa las banderas primero en los módulos que no dependen de nadie de tu propio código y sube desde ahí. Al revés, cada aviso nuevo se mezcla con los que vienen de dependencias todavía sin migrar y el ruido hace la tarea desagradable e imprecisa.
Banderas de funcionalidad futura
Una funcionalidad futura es un cambio ya aprobado por el proceso de evolución que rompe compatibilidad de fuente, y que por eso no se activa hasta el siguiente modo de lenguaje. Cada una tiene un identificador propio y puede habilitarse antes con una bandera del compilador.
// Package.swift
.target(
name: "Dominio",
swiftSettings: [
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("InternalImportsByDefault"),
.swiftLanguageMode(.v5)
]
)
Desde la línea de órdenes es -enable-upcoming-feature seguido del identificador. El detalle que importa: activar una bandera nunca es un salto al vacío, porque la funcionalidad ya pasó revisión y su comportamiento está especificado. Lo único que adelantas es el momento en que su incompatibilidad te afecta.
Cuando llega el modo de lenguaje que las incluye, esas banderas quedan activadas por defecto y la opción se vuelve inocua en lugar de errónea. Eso permite mantener un paquete que compile con varias versiones del compilador sin condicionales por todas partes: las banderas de funcionalidades que aún no existen se ignoran con un aviso, no con un error.
La estrategia práctica que se ha impuesto es tratar cada bandera como una tarea independiente. Activas una, resuelves los avisos que provoca, integras el cambio y pasas a la siguiente. Cada paso es pequeño, revisable y reversible, y el estado del proyecto es siempre coherente.
flowchart LR A[Idea en revision] -->|bandera experimental| B[Se puede probar en snapshots] B --> C[Propuesta aceptada] C -->|bandera de funcionalidad futura| D[Adopcion voluntaria por modulo] D --> E[Siguiente modo de lenguaje] E --> F[Activada por defecto para todos] style A fill:#f9e2af,color:#11111b style D fill:#89b4fa,color:#11111b style F fill:#a6e3a1,color:#11111b
Modos de lenguaje
El modo de lenguaje es la reunión de todas las funcionalidades incompatibles vigentes en un momento dado. Un mismo compilador entiende varios modos, y esa es la clave del asunto: no necesitas un compilador antiguo para compilar código antiguo, sino el compilador nuevo puesto en modo antiguo. Así el código que no ha migrado sigue recibiendo mejoras de rendimiento, correcciones y diagnósticos mejores mientras conserva las reglas bajo las que se escribió.
// Un modulo migrado y otro que todavia no lo esta,
// en el mismo paquete y en el mismo binario final
.target(name: "Nucleo", swiftSettings: [.swiftLanguageMode(.v6)]),
.target(name: "Heredado", swiftSettings: [.swiftLanguageMode(.v5)])
El modo del que más se habla es el de Swift 6, porque su cambio principal —la comprobación estricta de la concurrencia, que convierte en errores lo que antes eran avisos— es el más exigente que ha introducido el lenguaje. Y precisamente por eso el mecanismo tuvo que existir antes: sin adopción gradual, ese cambio habría dejado fuera a una parte enorme del ecosistema durante años.
Al cruzar la frontera entre modos hay una asimetría que conviene tener presente. Un módulo estricto que importa uno permisivo no puede confiar en las garantías del otro, así que el compilador reduce el rigor de sus comprobaciones sobre lo importado. La migración solo es real de dentro hacia fuera: mientras tus dependencias no adopten el modo, tu módulo lo tiene activado pero no está verificado del todo. Vale la pena saberlo para no confundir una compilación limpia con una garantía completa.
Las versiones recientes añaden además ajustes que no son exactamente funcionalidades sino políticas por defecto, como fijar el aislamiento predeterminado de un módulo al actor principal. Se configuran del mismo modo y merecen la misma disciplina: uno cada vez, con su revisión.
// Politica por defecto en lugar de anotacion repetida
.target(
name: "Interfaz",
swiftSettings: [
.swiftLanguageMode(.v6),
.defaultIsolation(MainActor.self)
]
)
// Con esto el modulo entero vive en el actor principal salvo
// lo que marques explicitamente como no aislado, que es justo
// el reparto que quiere una capa de presentacion.
El gestor de paquetes ha ido incorporando además herramientas de migración asistida que aplican automáticamente los cambios mecánicos que exige activar una funcionalidad concreta. No sustituyen al criterio —los cambios interesantes siguen siendo manuales— pero eliminan la parte tediosa, que en un proyecto grande es la mayoría del trabajo y la fuente principal de errores por fatiga.
Lo experimental
Por debajo de todo lo anterior hay un tercer nivel, y es el que permite participar de verdad en la evolución del lenguaje. Las funcionalidades en discusión —no aprobadas, a veces ni siquiera propuestas formalmente— se implementan tras una bandera experimental y se distribuyen en las instantáneas de desarrollo de la cadena de herramientas.
Activar una es tan sencillo como -enable-experimental-feature con su nombre, pero las condiciones son distintas: exigen normalmente una instantánea de desarrollo, no una versión estable; el diseño puede cambiar entre dos instantáneas consecutivas; y el código escrito contra ellas no debe llegar a producción bajo ninguna circunstancia. Su propósito es otro: permitir que quien va a revisar una propuesta pueda ejecutarla en lugar de imaginarla.
Para escribir código que funcione con y sin la funcionalidad, el lenguaje ofrece directivas de compilación condicional que preguntan por su disponibilidad en lugar de por la versión del compilador.
#if hasFeature(ExistentialAny)
func procesar(_ elementos: [any Identificable]) { }
#else
func procesar(_ elementos: [Identificable]) { }
#endif
#if hasAttribute(retroactive)
extension Fecha: @retroactive Comparable { }
#endif
Preguntar por la funcionalidad y no por la versión es más preciso, porque una bandera puede estar activada o no dentro de una misma versión del compilador. Es también el patrón que permite a un paquete público adoptar cosas nuevas sin abandonar a quien todavía usa cadenas de herramientas anteriores.
El modo es del módulo
Módulos con reglas distintas conviven en un mismo binario. Ahí se rompe la cadena de bloqueo del ecosistema.
Dos niveles distintos
Futura significa aprobada y adelantable. Experimental significa en discusión y sujeta a cambio sin aviso.
Pregunta por la funcionalidad
hasFeature y hasAttribute son más precisos que comprobar la versión del compilador.
Una bandera cada vez
Cada activación es una tarea independiente, revisable y reversible. Todas juntas es una tarde perdida.
Lo que Swift resolvió con los modos de lenguaje es un problema que la ingeniería de software sabe plantear pero rara vez sabe resolver: cómo cambiar la semántica de un sistema del que dependen terceros que no controlas y que no van a moverse cuando tú te muevas. La respuesta habitual es versionar la interfaz —un número mayor, una ruta distinta, un espacio de nombres nuevo— y confiar en que el ecosistema migre en bloque; pero esa respuesta trata el cambio como un evento cuando en realidad es un proceso, y es justo la diferencia entre ambas cosas la que produce las escisiones históricas que han costado a otros lenguajes una década de comunidad dividida. Las tres propiedades del mecanismo de Swift son, vistas de cerca, tres decisiones sobre granularidad. Nombrar cada incompatibilidad por separado convierte una versión monolítica en un conjunto de cambios independientes y hace posible razonar sobre cada uno con precisión, escribir su migración y probarla; sin nombres, un salto de versión es una caja negra que solo puede aceptarse o rechazarse entera. Hacer del modo una propiedad del módulo desacopla el calendario de cada participante del calendario de sus dependencias, que es la única forma conocida de impedir que la unidad de migración sea el ecosistema completo. Y permitir que el programa pregunte por la disponibilidad de una funcionalidad en lugar de por la versión del compilador reconoce que el espacio de configuraciones posibles ya no es una recta ordenada sino un retículo, donde dos compilaciones con la misma versión pueden diferir en reglas. El coste de todo esto es real y merece nombrarse: el lenguaje deja de tener una semántica única y pasa a tener una familia de semánticas indexada por configuración, lo que multiplica la superficie de prueba del compilador, complica la enseñanza y crea combinaciones que nadie ha ejecutado nunca. Que Swift aceptara ese coste indica cuál era la alternativa. La lección transferible aparece en cuanto mantienes algo con dependientes: cuando tengas que cambiar el significado de un contrato, no publiques una versión nueva y esperes; nombra el cambio, hazlo activable por unidad, dale a tus usuarios una forma de preguntar si está activo y deja que cada uno lo adopte cuando pueda verificarlo. La migración deja de ser un acantilado y se convierte en una rampa, y las rampas sí se recorren.
Swift introduce cambios incompatibles nombrando cada uno, permitiendo activarlo por separado con -enable-upcoming-feature y haciendo del modo de lenguaje una propiedad del módulo, de modo que reglas distintas conviven en un mismo binario. Las banderas experimentales cubren lo que aún está en discusión y exigen instantáneas de desarrollo. hasFeature y hasAttribute permiten escribir código válido en ambos lados de la frontera.
- Activa una sola funcionalidad futura en el objetivo más aislado de un paquete y arregla todos los avisos que produzca.
- Repite con una segunda y anota cuántos de los cambios fueron mecánicos y cuántos exigieron pensar.
- Configura dos objetivos del mismo paquete con modos de lenguaje distintos y comprueba que enlazan.
- Escribe una función con dos implementaciones separadas por
hasFeaturey compílala con y sin la bandera. - Descarga una instantánea de desarrollo, activa una funcionalidad experimental en discusión y escribe qué opinión llevarías a su hilo de revisión.