Escribir código portable: costuras, no condicionales dispersos
La jerarquía real de las directivas de compilación condicional y por qué `canImport` gana casi siempre a `#if os`, el patrón de costura que concentra lo específico de plataforma en una capa fina, las dependencias condicionales del manifiesto y la matriz de integración continua que convierte la portabilidad en una propiedad verificada.
Hay dos maneras de escribir código que funcione en varias plataformas y solo una sobrevive al segundo año. La primera consiste en escribir el programa para el sistema que se tiene delante y luego ir salpicando condicionales de compilación allí donde algo falla; produce resultados rápidos, se degrada de forma silenciosa y acaba en archivos donde cada función tiene tres versiones que nadie ha vuelto a leer. La segunda consiste en decidir de antemano dónde está la frontera entre lo que es del dominio y lo que es del sistema, poner ahí una costura explícita y no permitir que lo específico la cruce hacia dentro. La diferencia entre ambas no es de estilo ni de gusto: es que en la primera el compilador solo revisa la rama que está compilando, y todo lo demás se pudre sin que nadie se entere.
- Ordenar las directivas de compilación condicional por precisión y elegir la que describe una capacidad en lugar de una identidad.
- Aplicar el patrón de costura para concentrar lo específico de plataforma en una capa fina y sustituible.
- Declarar dependencias y ajustes condicionales en el manifiesto sin fragmentar el código fuente.
- Construir una matriz de integración continua que convierta la portabilidad en una propiedad comprobada y no supuesta.
La jerarquía de las directivas
Swift ofrece varias condiciones de compilación y no son intercambiables: cada una responde a una pregunta distinta y elegir la equivocada es el origen de la mayoría del código condicional que envejece mal.
#if os pregunta por la identidad del sistema. Es la más usada y la peor, porque toda condición escrita así hay que revisarla cada vez que aparece un objetivo nuevo; una comprobación de Linux escrita hace tres años simplemente no contempla Android, FreeBSD ni el entorno de WebAssembly.
#if canImport pregunta por la capacidad, y es la que conviene por defecto. Dice lo que de verdad importa —si el módulo del que dependo existe aquí— y sigue siendo cierta cuando ese módulo llega a una plataforma nueva sin que nadie toque el código.
#if arch pregunta por la arquitectura, y sirve para lo aritmético: tamaño de puntero, alineación, orden de bytes. #if compiler y #if swift preguntan por la versión del compilador y del lenguaje, y son las únicas adecuadas para dar soporte a varias versiones del toolchain a la vez.
#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#elseif canImport(Musl)
import Musl
#elseif canImport(WinSDK)
import WinSDK
#endif
Ese bloque es el idioma canónico para llegar a la biblioteca del sistema, y muestra la propiedad que buscamos: no menciona ninguna plataforma por su nombre, y sin embargo cubre todas las que existen hoy y algunas de las que existirán.
Lo que queda dentro de un #if falso no pasa por el comprobador de tipos: el compilador lo analiza lo justo para saber dónde termina el bloque y sigue adelante. Un cambio de firma que rompe la versión de Windows puede pasar la revisión, la integración continua de macOS y el despliegue sin que nadie lo note, hasta que alguien construye en Windows semanas después. Esa asimetría es la razón técnica de fondo para que las directivas sean pocas, cortas y estén en sitios que la integración continua construya.
Costuras, no condicionales dispersos
El patrón que resuelve el problema es viejo y se llama de muchas formas —puertos y adaptadores, inversión de dependencias, costura— pero en Swift toma una forma muy concreta. En el núcleo portable declaras qué necesitas del mundo, sin decir cómo se consigue; cada plataforma aporta un tipo que lo cumple, en su propio archivo; y el ensamblado ocurre una sola vez, en el punto donde se construye la aplicación.
// Nucleo portable: ni una sola directiva, ni un solo import del sistema.
public protocol AlmacenDeSecretos: Sendable {
func guardar(_ dato: Data, para clave: String) throws
func leer(_ clave: String) throws -> Data?
}
public struct Sesion: Sendable {
let almacen: any AlmacenDeSecretos // la plataforma entra por aqui y solo por aqui
}
// AlmacenDarwin.swift — un unico #if, en el nivel superior del archivo
#if canImport(Security)
import Security
public struct AlmacenLlavero: AlmacenDeSecretos { /* ... */ }
#endif
Las reglas que hacen que esto funcione son cuatro y ninguna es opcional. La directiva vive en el nivel superior del archivo, nunca dentro del cuerpo de una función, porque un condicional incrustado en una función obliga a leer tres variantes a la vez para entender una. Todas las ramas exponen exactamente la misma firma, porque en cuanto una plataforma tiene un parámetro de más el resto del programa deja de ser portable sin avisar. El número de archivos con directivas se mantiene contable con los dedos y se sabe cuáles son. Y no se anidan: si necesitas una condición dentro de otra, lo que hace falta es otro archivo.
flowchart TB A[Nucleo de dominio sin directivas] --> B[Protocolos que declaran lo que hace falta] B --> C[Adaptador Darwin] B --> D[Adaptador Linux] B --> E[Adaptador Windows] B --> F[Adaptador WebAssembly] C --> G[Ensamblado unico en el arranque] D --> G E --> G F --> G H[Bateria de pruebas de contrato] --> C H --> D H --> E H --> F style A fill:#a6e3a1,color:#11111b style G fill:#89b4fa,color:#11111b style H fill:#f9e2af,color:#11111b
El manifiesto hace la mitad del trabajo
Buena parte de lo que la gente resuelve con directivas se expresa mejor en el manifiesto del paquete, que puede condicionar dependencias, ajustes del compilador y enlazado por plataforma. La ventaja es que el criterio queda en un solo sitio, declarado, y no repartido por los archivos.
.target(
name: "Nucleo",
dependencies: [
.product(name: "Crypto", package: "swift-crypto",
condition: .when(platforms: [.linux, .windows, .wasi])),
],
swiftSettings: [
.define("SIN_HILOS", .when(platforms: [.wasi])),
.enableUpcomingFeature("StrictConcurrency"),
]
)
Conviene además desmontar un malentendido persistente: el campo platforms de la cabecera del manifiesto no declara en qué sistemas funciona el paquete. Solo fija versiones mínimas de los sistemas de Apple. Un paquete que no mencione Linux puede funcionar allí perfectamente, y uno que declare toda la familia de Apple puede no compilar. La declaración de soporte real no existe como metadato: se demuestra construyendo.
Capacidad, no identidad
canImport sobrevive a la aparición de plataformas nuevas; #if os obliga a revisar cada condición escrita. Prefiere siempre la primera.
Una costura, un archivo
La directiva va arriba del archivo y la firma es idéntica en todas las ramas. Un condicional dentro de una función es deuda con intereses.
Contrato compartido
La misma batería de pruebas debe pasar contra todos los adaptadores. Si no, no tienes una abstracción: tienes cuatro comportamientos con un nombre común.
Probar en todas o no saber nada
Una arquitectura portable sin integración continua multiplataforma es una hipótesis, y las hipótesis en software se pudren rápido. La matriz mínima útil tiene tres ejecutores y un trabajo adicional.
# macos-latest -> swift test
# ubuntu-latest -> swift test y swift build --swift-sdk x86_64-swift-linux-musl
# windows-latest -> swift test
# trabajo aparte -> swift build --swift-sdk wasm32-unknown-wasi
swift test --enable-code-coverage -Xswiftc -warnings-as-errors
Sobre esa base hay tres refinamientos que multiplican el valor. El primero es la batería de contrato: un conjunto de pruebas escrito una sola vez contra el protocolo y ejecutado contra cada adaptador, de modo que la paridad de comportamiento se verifica en lugar de suponerse. El segundo es la prueba diferencial: alimentar la misma entrada en todas las plataformas y comparar las salidas serializadas, que es lo único que detecta las divergencias de formateo, de región y de orden. El tercero es construir también en modo de publicación, porque las optimizaciones cambian el comportamiento de las comprobaciones de desbordamiento y de las aserciones, y hay fallos que solo aparecen ahí.
Una advertencia final sobre una confusión frecuente: @available y #if os no son alternativas. El primero es una comprobación de versión del sistema en tiempo de ejecución y solo tiene sentido en las plataformas de Apple; el segundo es una decisión de compilación. Usar el primero esperando que excluya código en Linux no funciona, porque allí no hay ninguna versión que comprobar.
La compilación condicional parece gratuita porque su coste no se paga en el momento de escribirla, y esa asimetría explica por qué casi todos los proyectos multiplataforma acaban ahogados en ella. Cuando escribes una directiva estás creando una segunda versión del programa que nadie leerá, que el compilador no revisará mientras construyas para el otro objetivo, y cuya corrección depende por completo de que exista una máquina en algún sitio construyéndola de forma periódica. Es decir: no estás escribiendo código, estás contrayendo una obligación de infraestructura. Diez directivas dispersas por el cuerpo de las funciones no producen diez pequeños inconvenientes, producen un espacio de configuraciones que ninguna persona puede tener entera en la cabeza y del que solo una porción minúscula se compila alguna vez. El patrón de costura no es, entonces, una preferencia estética por las abstracciones: es una estrategia deliberada de contención del daño, que acepta que lo específico de plataforma existe y va a existir siempre, y decide concentrarlo en un perímetro pequeño, nombrado y vigilado, en lugar de dejar que se difunda por el tejido del programa. Y hay una consecuencia que rara vez se anticipa y que suele ser el mayor beneficio del ejercicio: una vez que las capacidades del sistema entran por un protocolo, dejan de ser solo portables y pasan a ser también simulables, de modo que las pruebas del núcleo se vuelven rápidas, deterministas y capaces de reproducir fallos del sistema de ficheros o de la red que en la implementación real serían casi imposibles de provocar. La portabilidad, bien hecha, no es un coste que se acepta para llegar a más plataformas; es la disciplina que hace que el programa sea comprobable, y a las plataformas se llega de propina.
Prefiere canImport a #if os porque describe capacidad y no identidad. Pon las directivas en el nivel superior de archivos contados, con firmas idénticas en todas las ramas, y deja el núcleo sin ninguna. Lleva al manifiesto lo que pueda expresarse como dependencia o ajuste condicional. Y recuerda que la rama no compilada no se comprueba: solo una matriz de integración continua con pruebas de contrato convierte la portabilidad en un hecho.
- Cuenta todas las directivas de compilación condicional de tu código y clasifícalas en identidad, capacidad, arquitectura y versión.
- Sustituye cada
#if ospor la comprobación de capacidad equivalente y anota en cuáles no es posible y por qué. - Elige la dependencia de sistema más incrustada en tu núcleo, extráela detrás de un protocolo y mueve la implementación a un archivo por plataforma.
- Escribe una batería de contrato para ese protocolo y ejecútala contra todos los adaptadores, incluido uno simulado.
- Monta la matriz de integración continua con los tres sistemas más un trabajo de WebAssembly y haz que un fallo en cualquiera detenga la fusión.