wandres.dev
SWIFT MULTIPLATAFORMA · Linux, Windows, Wasm

Qué es realmente portable: el mapa por capas

La biblioteca estándar es la única capa con identidad semántica garantizada; `Foundation` ofrece hoy un núcleo común reescrito en Swift pero con fronteras móviles; y los frameworks de Apple no son portables ni lo pretenden. Un mapa por capas para saber qué puedes prometer antes de escribir la primera línea.

⏱ 18 min

Decir que Swift es multiplataforma es una afirmación verdadera y casi inútil, porque mete en la misma frase tres cosas que viajan a velocidades muy distintas: el lenguaje, su biblioteca estándar y el conjunto de bibliotecas con las que de verdad se escriben programas. El compilador acepta la misma gramática en macOS, en Linux, en Windows y en WebAssembly; la biblioteca estándar ofrece los mismos tipos con la misma semántica y las mismas garantías; a partir de ahí, cada escalón hacia arriba añade una condición, un matiz o una ausencia. La pregunta útil no es si Swift es portable, sino qué proporción de tu programa vive en la capa que sí lo es —y esa proporción no la decide el lenguaje, la decides tú cada vez que eliges de qué dependes.

🎯 Al terminar esta lección sabrás
  • Separar las tres capas de la pila —lenguaje y biblioteca estándar, bibliotecas núcleo, SDK de Apple— y saber qué garantiza cada una.
  • Explicar qué significa hoy Foundation fuera de Darwin y dónde persisten las divergencias reales.
  • Reconocer por qué los frameworks de Apple no plantean un problema de esfuerzo sino de licencia y de acoplamiento al sistema.
  • Sustituir la pregunta por la plataforma por la pregunta por la capacidad al evaluar una dependencia.

La capa que no se discute

La biblioteca estándar se compila desde las mismas fuentes para todos los objetivos y esa es la razón de fondo de su fiabilidad: no hay una versión de Linux y otra de macOS que alguien tenga que mantener sincronizadas. Int, String, Array, Dictionary, Set, Optional, Result, los protocolos de secuencia y colección, el sistema de genéricos, el conteo de referencias y el modelo de concurrencia estructurada son literalmente el mismo código en todas partes.

El caso de String es el más instructivo porque durante años fue el contraejemplo. La biblioteca estándar dependía de la ICU del sistema para segmentar grafemas y normalizar, de modo que la misma cadena podía tener distinta longitud según la versión de ICU instalada. Desde que la estándar incorpora sus propias tablas Unicode, la segmentación es idéntica en todos los objetivos y deja de depender del sistema operativo. Es el patrón que conviene interiorizar: la portabilidad se consigue absorbiendo la dependencia, no confiando en que el sistema la ofrezca igual.

Conviene notar dónde traza exactamente la frontera esta capa, porque el corte no siempre coincide con la intuición. Codable, Encodable y Decodable son protocolos de la biblioteca estándar y su síntesis la hace el compilador, de modo que un modelo conformante es portable sin más; en cambio JSONEncoder y PropertyListEncoder viven en Foundation, y el segundo no tiene sentido fuera del mundo de Apple. Del mismo modo, Sendable, los actores y el aislamiento son del lenguaje, mientras que el ejecutor concreto que ejecuta las tareas lo pone el sistema.

Alrededor de esa base existe una franja de paquetes que solo dependen de la biblioteca estándar y que, en la práctica, heredan su portabilidad completa: colecciones especializadas, algoritmos, tipos numéricos, análisis de argumentos, registro estructurado y algoritmos asíncronos. Escribir el núcleo contra ese conjunto es la decisión con mejor relación entre esfuerzo y movilidad que puedes tomar en un proyecto multiplataforma.

// Un modulo de dominio que compila en macOS, Linux, Windows y WebAssembly
// sin una sola directiva de compilacion condicional.
struct Pedido: Codable, Sendable, Hashable {
    var id: UUID          // este si viene de Foundation
    var lineas: [Linea]
    var total: Decimal
}

func validar(_ pedido: Pedido) -> [Incidencia] { /* solo stdlib */ }

Aun así, la garantía no es que todo sea idéntico, sino que todo esté especificado. Hay tres cosas que varían legítimamente y conviene no confundirlas con fallos.

💡
Lo que la estándar no promete y nunca prometió

El ancho de Int sigue al puntero de la plataforma: sesenta y cuatro bits en los objetivos habituales, treinta y dos en wasm32 y en ARM de treinta y dos bits, con las implicaciones obvias sobre desbordamiento y sobre serialización binaria. El orden de iteración de Dictionary y Set depende de una semilla aleatoria por proceso, así que no es estable ni siquiera entre dos ejecuciones en la misma máquina. Y el modelo de concurrencia, aunque idéntico en su superficie, corre sobre ejecutores distintos: un grupo de hilos cooperativo en los sistemas con hilos, un ejecutor de un solo hilo atado al bucle de eventos en WebAssembly.

Foundation, o el terreno con matices

Durante casi una década Foundation fuera de Darwin fue una reimplementación paralela: swift-corelibs-foundation, escrita sobre un CoreFoundation en C, con la vocación de imitar el comportamiento de Apple y la realidad de divergir en los bordes. Convivían dos bases de código, dos conjuntos de errores y una lista larga de métodos que abortaban en tiempo de ejecución con un aviso de no implementado.

El proyecto swift-foundation cambió el planteamiento: reescribir el núcleo en Swift puro y hacer que todas las plataformas, Darwin incluida, consuman esa misma implementación. Los tipos de valor centrales —Data, Date, UUID, URL, AttributedString, JSONEncoder, el formateo y el análisis— pasan a tener una sola versión. El paquete se parte además en dos módulos, y esa partición es la primera decisión de diseño que te afecta.

import FoundationEssentials        // sin internacionalizacion, sin ICU, mucho mas ligero

#if canImport(FoundationNetworking)
import FoundationNetworking        // URLSession vive aqui en Linux y en Windows
#endif
#if canImport(FoundationXML)
import FoundationXML               // XMLParser, idem
#endif

Foundation no está sola en esta capa. Las bibliotecas núcleo incluyen también Dispatch, XCTest y el marco de pruebas moderno, y las tres se distribuyen con el toolchain en todas las plataformas. Su portabilidad es real pero no gratuita: Dispatch existe fuera de Darwin como una reimplementación con la misma API, y hay detalles de integración con el bucle de eventos que cambian de sistema a sistema y que se estudian en la lección siguiente.

Lo que queda fuera del núcleo común es exactamente lo que depende del sistema anfitrión. FileManager se topa con que macOS monta por defecto un sistema de ficheros insensible a mayúsculas y Linux no, de modo que el mismo código de apertura funciona en el portátil y falla en el servidor. URLSession en Linux y Windows se apoya en libcurl en lugar de en la pila de red del sistema: no hay sesiones en segundo plano, la resolución de proxies difiere y la política de certificados es otra. Bundle no encuentra paquetes de aplicación donde no existe el concepto, y los recursos de un paquete se localizan mediante Bundle.module. Y sobre todo, no hay runtime de Objective-C: @objc, la observación de claves, el archivado compatible con Apple y el puenteado sin coste con los tipos de CoreFoundation simplemente no están.

⚠️
Importar Foundation entero es una decisión, no un reflejo

En un binario de servidor o de línea de órdenes, arrastrar internacionalización y red cuando solo querías Data y Date infla el tiempo de enlazado y el tamaño del ejecutable. Empieza siempre por FoundationEssentials y sube de módulo solo cuando el compilador te lo exija; en WebAssembly esa diferencia se mide en megabytes servidos por la red.

flowchart TB
A[Lenguaje y biblioteca estandar] --> B[Identica en todos los objetivos]
C[Bibliotecas nucleo como Foundation y Dispatch] --> D[Nucleo comun reescrito en Swift]
C --> E[Modulos aparte y comportamiento distinto en los bordes]
F[Frameworks del SDK de Apple] --> G[Solo Darwin, binarios cerrados]
B --> H[Aqui vive tu logica de dominio]
E --> H
G --> I[Aqui vive solo la capa de presentacion]
style B fill:#a6e3a1,color:#11111b
style E fill:#f9e2af,color:#11111b
style G fill:#f38ba8,color:#11111b

Los frameworks de Apple no son Swift

SwiftUI, UIKit, AppKit, Core Data, CloudKit, Combine, CryptoKit, Metal y AVFoundation no son bibliotecas de Swift que casualmente solo se hayan compilado para Darwin: son componentes del sistema operativo, distribuidos como binarios cerrados dentro del SDK, acoplados al enlazador dinámico, al runtime de Objective-C y a demonios del sistema con los que hablan por canales privados. No existe un esfuerzo comunitario que pueda portarlos, y aunque existiera el código, la semántica de la mitad de sus operaciones está definida por servicios que solo corren en el sistema de Apple.

La consecuencia práctica es que la frontera de portabilidad de tu programa coincide casi siempre con la frontera de la presentación. Todo lo que sea modelo, reglas de negocio, análisis, persistencia y protocolo puede vivir en la capa portable; todo lo que sea ventana, vista, notificación o sensor no.

Ahora bien, hay que distinguir entre los frameworks que no tienen sustituto y los que sí lo tienen, porque la respuesta correcta es distinta en cada caso. Para criptografía existe swift-crypto, que reproduce deliberadamente la API de CryptoKit sobre BoringSSL, de manera que el mismo código fuente compila en las dos partes. Para persistencia, un envoltorio sobre SQLite cubre casi todo lo que Core Data cubría, a costa de escribir a mano lo que antes era automático. Para observabilidad, la familia de paquetes del grupo de trabajo de servidor está diseñada desde el principio para ser neutral.

El caso sin sustituto directo es Combine, y su ausencia es más interesante de lo que parece: no existe una reimplementación abierta porque el propio lenguaje absorbió el problema. AsyncSequence, AsyncStream y los algoritmos asíncronos cubren el terreno funcional sin imitar la forma, y el resultado es código portable por construcción. Es un buen recordatorio de que a veces la respuesta a un framework que no se puede portar no es buscar un clon, sino comprobar si el lenguaje ya resolvió el problema por otra vía.

🧱

Sustituir, no portar

swift-crypto reproduce la API de CryptoKit sobre BoringSSL, swift-log y swift-metrics cubren la observabilidad, y AsyncSequence ocupa el hueco funcional de Combine sin imitar su forma.

🚪

La frontera es la vista

Si tu lógica de dominio importa un framework de interfaz, el problema no es la plataforma: es que la arquitectura dejó filtrar la presentación hacia dentro.

⚖️

Tres niveles de paridad

Que compile no implica que se comporte igual, y que se comporte igual no implica que rinda igual. Son tres preguntas separadas y hay que hacer las tres.

El criterio: capacidad, no plataforma

La pregunta correcta ante una dependencia no es en qué sistema operativo estoy, sino qué capacidad tengo disponible aquí. Esa diferencia se traduce en una preferencia concreta y muy repetible: canImport describe una capacidad y sobrevive a que un módulo aparezca en una plataforma nueva; os describe una identidad y envejece mal, porque cada objetivo añadido obliga a revisar todas las condiciones escritas.

Conviene además desactivar una trampa clásica de SwiftPM. El campo platforms de un manifiesto no declara en qué sistemas funciona el paquete: solo fija las versiones mínimas de los sistemas de Apple. Un paquete sin Linux en esa lista puede funcionar perfectamente en Linux, y uno que la incluya toda puede no compilar allí. La única evidencia válida de que un paquete es portable es que su integración continua lo construya y lo pruebe en el objetivo que te interesa.

Con eso en la mano, evaluar una dependencia se reduce a tres preguntas encadenadas que hay que hacer en orden y no confundir entre sí. La primera es de disponibilidad: ¿existe el módulo en mi objetivo y compila? La segunda es de paridad de comportamiento: ¿produce los mismos resultados ante las mismas entradas, incluidos los casos límite de región, zona horaria y sistema de ficheros? Y la tercera es de paridad de rendimiento: ¿el coste es comparable, o hay una implementación de referencia optimizada y otra escrita para salir del paso? Muchos equipos hacen solo la primera, la dan por concluyente y descubren las otras dos en producción.

ℹ️
Un orden de lectura para el resto del nivel

Las cuatro lecciones siguientes recorren este mapa de arriba abajo: primero Linux, que es donde la capa núcleo se pone a prueba de verdad; después Windows, donde cambia hasta la interfaz binaria; luego WebAssembly, que retira los hilos y la red y obliga a declarar todo lo que dabas por hecho; y por último las técnicas para escribir código que sobreviva a las tres sin llenarse de condicionales.

La portabilidad no es una propiedad del lenguaje sino una arquitectura de dependencias

Cuesta poco convencerse de que la portabilidad es algo que un lenguaje tiene o no tiene, y esa creencia es precisamente la que produce programas imposibles de mover. Swift no es portable ni deja de serlo: ofrece una capa cuya semántica está congelada por especificación y compilada desde una sola fuente, y encima de ella un gradiente en el que cada dependencia que añades acerca o aleja tu código de esa base. Lo que decide el resultado no es una propiedad del compilador sino la topología de tus importaciones, y esa topología la escribes tú, línea a línea, casi siempre sin darte cuenta. De ahí que el trabajo de portar rara vez consista en resolver incompatibilidades técnicas y casi siempre en deshacer decisiones de acoplamiento que se tomaron sin considerarlas decisiones. El programa que importó SwiftUI en su capa de modelo no tiene un problema de plataforma: tiene un problema de diseño que la plataforma se ha limitado a revelar. Hay además una asimetría que conviene nombrar, porque explica por qué tantos equipos descubren el problema tarde: la capa portable es invisible cuando funciona y la capa acoplada es cómoda mientras solo compilas para un objetivo, de modo que el coste de la dependencia no se paga en el momento de contraerla sino meses después, cuando alguien pregunta si esto correría en un servidor. La disciplina útil, entonces, no es evitar los frameworks del sistema —son excelentes y hay que usarlos— sino saber en todo momento en qué capa está cada archivo que escribes, y tratar cada importación que cruza hacia abajo como lo que es: una hipoteca sobre la movilidad futura del programa entero.

📝
Lo esencial

La biblioteca estándar es idéntica en todos los objetivos salvo el ancho de Int, el orden de las tablas hash y el ejecutor de concurrencia. Foundation tiene ya un núcleo común escrito en Swift, pero fuera de Darwin la red y el XML viven en módulos aparte y no hay runtime de Objective-C. Los frameworks de Apple no son portables por construcción. Y la evidencia de que un paquete sirve en tu plataforma es su integración continua, nunca su campo platforms.

⚔️ Dibuja el mapa de tu propio proyecto
  1. Lista todas las importaciones de tu código y clasifica cada una en biblioteca estándar, biblioteca núcleo o SDK de Apple.
  2. Marca los archivos donde una importación del SDK aparece por debajo de la capa de presentación y explica qué la trajo hasta allí.
  3. Sustituye un import Foundation por import FoundationEssentials en el módulo de dominio y anota qué símbolos deja de encontrar el compilador.
  4. Escribe un programa mínimo que imprima el tamaño de Int y el orden de iteración de un diccionario, ejecútalo dos veces y explica el resultado.
  5. Elige tres dependencias externas y busca en su integración continua la prueba de que se construyen en Linux; anota cuáles no la tienen.