Swift y Kotlin: dos herederos con el mismo encargo
Historias paralelas, encargos idénticos: sustituir a un lenguaje heredado sin romper su ecosistema. Cómo resuelven la ausencia de valor, en qué se parecen y en qué no las corrutinas y async/await, y qué revela su ergonomía sobre el sustrato que cada uno pisa.
De todos los lenguajes contemporáneos, Kotlin es el que más se parece a Swift, y no por casualidad. Ambos nacieron para relevar a un lenguaje veterano en una plataforma controlada por una sola empresa, ambos tenían prohibido romper la interoperabilidad con el código heredado, y ambos llegaron con la misma lista de deseos: nulos en el sistema de tipos, inferencia agresiva, funciones de primera clase, sintaxis para el caso común y ninguna ceremonia. El resultado es un parecido tan alto que leer Kotlin desde Swift casi no requiere traducción. Las diferencias interesantes están debajo, en el sustrato que cada uno pisa, y afloran justo en los dos lugares donde más se les compara: la ausencia de valor y la concurrencia.
- Situar ambos lenguajes en su contexto histórico y entender qué restricciones heredaron.
- Contrastar el opcional como enumerado con el tipo anulable con tipado de flujo.
- Comparar corrutinas y
async/awaitcomo dos implementaciones de la misma idea de concurrencia estructurada. - Identificar qué diferencias de ergonomía provienen del lenguaje y cuáles del sustrato.
El mismo encargo, dos décadas distintas
Kotlin se anunció en 2011, alcanzó su versión estable en 2016 y fue bendecido por Google como lenguaje oficial de Android en 2017. Swift se anunció en 2014, se liberó como código abierto en 2015 y sustituyó a Objective-C sin decreto formal, por simple desplazamiento. Las condiciones de contorno eran gemelas: convivir en el mismo proceso con millones de líneas heredadas, exponer y consumir sus tipos sin puentes costosos, y ofrecer una mejora tan evidente que la migración se justificase sola.
Esa restricción explica sus mayores fealdades. Kotlin arrastra el borrado de tipos genéricos de la JVM, la distinción entre tipos primitivos y objetos y una interoperabilidad con Java donde cualquier valor procedente del otro lado es un tipo de plataforma cuya nulidad el compilador no puede afirmar. Swift arrastró durante años el puenteado con Objective-C, los tipos implícitamente desenvueltos que ese puenteado hacía necesarios y una biblioteca estándar partida entre lo nativo y lo importado. Ninguno de los dos diseñó en el vacío, y casi cada rareza de ambos tiene un antepasado identificable.
En Kotlin, un data class es una referencia con equals y copy generados. En Swift, un struct es un valor con copia real y Equatable sintetizado. Escriben casi igual y significan cosas distintas: el primero comparte identidad al asignarse, el segundo no. Es la diferencia más consecuente entre ambos lenguajes y la que menos se menciona.
La ausencia de valor: enumerado frente a bandera
Swift modela la ausencia con un tipo de datos ordinario. Optional es un enumerado con dos casos, la interrogación es azúcar sintáctico, y todo lo que puedes hacer con un enumerado lo puedes hacer con él: emparejarlo, mapearlo, encadenarlo, guardarlo dentro de otro opcional.
let nombre: String? = usuario?.perfil?.nombre
let saludo = nombre.map { "Hola, \($0)" } ?? "Hola"
if let n = nombre, n.count > 2 { registrar(n) }
guard let n = nombre else { return }
Kotlin modela la ausencia como una propiedad del tipo, no como un envoltorio. Un String? no contiene un String: es un String que además admite nulo, y el compilador usa tipado de flujo para reducirlo automáticamente en las ramas donde ya comprobaste que no lo es.
val nombre: String? = usuario?.perfil?.nombre
val saludo = nombre?.let { "Hola, $it" } ?: "Hola"
if (nombre != null && nombre.length > 2) registrar(nombre) // conversion inteligente
La consecuencia teórica es que en Kotlin el anidamiento se aplana: un String?? no existe, porque la nulidad es idempotente. En Swift sí existe, y a veces importa —un diccionario que devuelve un opcional cuyo valor es a su vez opcional distingue entre clave ausente y valor nulo, distinción que Kotlin no puede expresar sin envolver a mano—. A cambio, Swift paga con ceremonia: Optional no es transparente y las cadenas largas obligan a decidir entre encadenamiento, map, flatMap o desenvoltura. Ambos ofrecen su vía de escape para forzar: ! en Swift, !! en Kotlin, y en los dos ecosistemas la cultura los trata como confesión de que algo no se modeló bien.
Corrutinas frente a async/await
Aquí la deuda es explícita y va en una sola dirección: la concurrencia estructurada nació como concepto en el mundo de las corrutinas, Kotlin la implementó como biblioteca con CoroutineScope y Job, y Swift la adoptó como parte del lenguaje. Las funciones se marcan igual —suspend allí, async aquí—, ambas se compilan a una máquina de estados y ninguna bloquea un hilo mientras espera.
suspend fun cargar(id: Int): Perfil = coroutineScope {
val datos = async { api.datos(id) }
val avatar = async { api.avatar(id) }
Perfil(datos.await(), avatar.await()) // si uno falla, cancela al otro
}
func cargar(id: Int) async throws -> Perfil {
async let datos = api.datos(id)
async let avatar = api.avatar(id)
return try await Perfil(datos, avatar) // si uno falla, cancela al otro
}
Las diferencias empiezan en cómo se decide dónde corre el trabajo. Kotlin lo hace explícito con despachadores que el programador elige y compone en el contexto de la corrutina, un modelo flexible y propenso al error de elegir mal. Swift lo hace implícito con el aislamiento: el compilador deduce el ejecutor a partir del actor al que pertenece la función, y a partir de Swift 6 verifica estáticamente que ningún valor no Sendable cruce un dominio. Kotlin no tiene equivalente: puede compartir estado mutable entre corrutinas sin queja alguna del compilador, y su defensa es cultural —usar tipos inmutables, Mutex, StateFlow— más que formal. Es la diferencia más importante entre los dos modelos y la única en la que Swift está claramente por delante.
Si el código dice explícitamente en qué hilo corre, estás mirando el modelo de despachadores. Si el código dice a qué actor pertenece el estado y deja que el ejecutor se deduzca, estás mirando el modelo de aislamiento. Mezclar ambos hábitos —anotar aislamiento y además forzar la cola— es el error más frecuente de quien llega a Swift desde Kotlin.
El resto es simetría: Flow y AsyncSequence describen la misma idea de secuencia perezosa en el tiempo, con la salvedad de que el ecosistema de Flow es mucho más rico en operadores; los canales existen a ambos lados; y ambos padecen la misma queja recurrente sobre el color de las funciones, esa partición del universo entre lo que puede esperar y lo que no.
Ergonomía, y lo que hay debajo
En la superficie, el intercambio es casi mecánico. Las funciones de extensión de Kotlin corresponden a las extensiones de Swift; las clases selladas a los enumerados con valores asociados; los argumentos con nombre y valores por defecto existen en ambos; la clausura final fuera del paréntesis es idéntica; los constructores de tipo seguro de Compose y los result builders de SwiftUI resuelven el mismo problema con técnicas distintas —funciones en línea con receptor frente a transformación sintáctica del compilador—.
La correspondencia se ve mejor en el caso que más se usa: modelar un conjunto cerrado de alternativas y despacharlo de forma exhaustiva.
sealed class Respuesta {
data class Exito(val datos: List<Item>) : Respuesta()
data class Fallo(val codigo: Int) : Respuesta()
object Vacia : Respuesta()
}
val texto = when (r) {
is Respuesta.Exito -> "${r.datos.size} elementos"
is Respuesta.Fallo -> "error ${r.codigo}"
Respuesta.Vacia -> "sin datos"
}
enum Respuesta {
case exito([Item])
case fallo(codigo: Int)
case vacia
}
let texto = switch r {
case .exito(let datos): "\(datos.count) elementos"
case .fallo(let codigo): "error \(codigo)"
case .vacia: "sin datos"
}
La diferencia visible es la ceremonia: la clase sellada necesita un tipo por caso y una comprobación de tipo para desmontarlo, mientras que el enumerado trata los casos como valores del mismo tipo. La diferencia invisible es que los tres casos de Kotlin son tres objetos distintos en el montón y el de Swift es un valor que cabe en registros.
Donde deja de ser mecánico es en el sustrato. Kotlin ejecuta sobre un recolector de basura, así que la identidad compartida es gratis y la copia es cara; Swift ejecuta sobre ARC y semántica de valor, así que la copia es barata y la identidad compartida exige disciplina. Kotlin borra sus genéricos en tiempo de ejecución y necesita reified con funciones en línea para recuperar el tipo; Swift conserva la información de tipos y elige entre especializar o usar tablas de testigos. Esas dos diferencias, invisibles en un fragmento de diez líneas, deciden cómo se diseña una biblioteca entera en cada lenguaje.
flowchart LR O[Objective-C] --> S[Swift en 2014] J[Java] --> K[Kotlin en 2011] S --> N1[Opcional como enumerado] K --> N2[Tipo anulable con tipado de flujo] S --> C1[async await en el lenguaje] K --> C2[Corrutinas como biblioteca] C1 --> E[Concurrencia estructurada] C2 --> E C1 --> D[Sendable verificado por el compilador] style E fill:#a6e3a1,color:#11111b style D fill:#89b4fa,color:#11111b
Nulo aplanado o anidado
En Kotlin la nulidad no se apila; en Swift sí, y esa capacidad tiene usos reales aunque cueste ceremonia.
Despachador o aislamiento
Kotlin te deja elegir el hilo; Swift lo deduce del actor y verifica lo que cruza la frontera.
Valor frente a referencia
El struct y el data class se escriben igual y no significan lo mismo. Es la brecha de fondo.
El parecido entre Swift y Kotlin es la mejor evidencia empírica disponible de que el diseño de lenguajes de aplicación ha alcanzado un consenso: dados los mismos requisitos —seguridad frente a nulos, inferencia, funciones de primera clase, interoperabilidad con un heredado, concurrencia estructurada—, dos equipos que no se coordinaron llegaron a soluciones casi isomorfas. Ese consenso es un dato científico sobre el espacio de diseño, no una coincidencia cultural: sugiere que en esa región del espacio hay un óptimo local ancho y estable, y que quien diseñe hoy un lenguaje de aplicación acabará razonablemente cerca de él aunque parta de otro lado. Pero lo verdaderamente instructivo son las diferencias que sobrevivieron a la convergencia, porque ninguna de ellas es una decisión de gusto: todas se derivan del sustrato. Kotlin no eligió el borrado de tipos, lo heredó de una máquina virtual que ya existía; no eligió que sus objetos tengan identidad compartida, lo impuso un modelo de memoria con recolector; no eligió que su concurrencia carezca de verificación estática, lo determinó el hecho de ejecutar sobre una plataforma donde cualquier biblioteca Java puede tocar memoria compartida y ningún compilador de Kotlin puede impedirlo. Swift, simétricamente, no eligió la semántica de valor por elegancia sino porque ARC sin copias hace ingobernable el estado compartido, y no habría podido introducir Sendable si su ecosistema no estuviese razonablemente cerrado. La lección transferible, y la que vale más allá de estos dos lenguajes, es que la sintaxis de un lenguaje es negociable, sus abstracciones son en gran medida convergentes, y lo único que resulta verdaderamente irreversible es el modelo de ejecución que hay debajo: elegir una plataforma es elegir un conjunto de teoremas que podrás demostrar y otro que te estará vedado para siempre, y ninguna cantidad de azúcar sintáctico cambiará esa frontera.
Ambos lenguajes resolvieron el mismo encargo con resultados casi isomorfos. Swift modela la ausencia como enumerado, lo que permite anidarla; Kotlin la modela como propiedad del tipo, lo que la aplana y habilita conversiones inteligentes. Las corrutinas y async/await implementan la misma concurrencia estructurada, pero solo Swift verifica estáticamente qué cruza los dominios de aislamiento. Las diferencias restantes —genéricos, identidad, copia— vienen del sustrato, no del lenguaje.
- Traduce a
Kotlinuna función deSwiftque devuelva un opcional de opcional y documenta qué información se pierde. - Implementa el mismo modelo de dominio como
enumcon valores asociados y como clase sellada, y compara la exhaustividad de cadaswitch. - Escribe la misma carga concurrente de dos recursos en ambos lenguajes y provoca un fallo en uno para observar la cancelación.
- Comparte un contador mutable entre dos corrutinas de
Kotliny entre dos tareas deSwift6, y anota cuál de los dos compiladores te detiene. - Explica por qué una biblioteca genérica se diseña distinto sobre borrado de tipos que sobre genéricos conservados en ejecución.