wandres.dev
NIVEL DIOS: KOTLIN · síntesis y el futuro

Kotlin frente a sus vecinos: Java moderno, Swift y Scala sin fanatismo

Una comparación honesta exige aceptar que los vecinos también han avanzado. Java lleva años incorporando registros, clases selladas, coincidencia de patrones y hilos virtuales; Swift ofrece semántica de valor real y seguridad frente a carreras de datos comprobada por el compilador; Scala tiene un sistema de tipos que Kotlin no pretende alcanzar. Esta lección enumera qué hace Kotlin mejor que cada uno, qué hace peor de forma estructural y no coyuntural, y cómo usar esa comparación para elegir herramienta en lugar de para defender una identidad.

⏱ 27 min

La comparación entre lenguajes suele hacerse mal por dos motivos opuestos. Unos comparan Kotlin con un Java congelado en 2011, lo que produce una superioridad falsa que se derrumba en cuanto alguien enseña código de Java moderno. Otros comparan Kotlin con la versión ideal de cualquier otro lenguaje y con la versión real del propio, que es el deporte favorito de quien acaba de descubrir algo. Hay un tercer error, más sutil, que consiste en comparar lenguajes cuando lo que en realidad se está comparando son ecosistemas, herramientas o culturas de equipo. Buena parte de las quejas sobre un lenguaje son quejas sobre su sistema de construcción, sobre la calidad de sus bibliotecas más usadas o sobre cómo lo escribía el equipo anterior. Separar esas capas antes de opinar es la mitad del trabajo. La comparación útil es la tercera: qué problema resuelve mejor cada uno, dónde el otro tiene una ventaja estructural que no va a desaparecer, y qué implicaciones tiene eso para una decisión concreta con un equipo concreto. Quince años después de su primera aparición pública, Kotlin es lo bastante maduro para soportar ese examen sin necesidad de que salga ganando en todo.

🎯 Al terminar esta lección sabrás
  • Comparar Kotlin con Java moderno reconociendo lo que Java ha recuperado y lo que sigue sin tener.
  • Situar Swift como el pariente más cercano y explicar en qué dos ejes concretos Kotlin queda por detrás.
  • Contrastar la expresividad del sistema de tipos de Scala con el coste que impone a un equipo real.
  • Enumerar las limitaciones estructurales de Kotlin sin atenuarlas y usarlas como criterio de elección.

Java moderno: el vecino se ha movido, y mucho

Durante años el argumento a favor de Kotlin en el servidor fue casi automático porque el contrincante era un lenguaje que llevaba una década quieto. Ese contrincante ya no existe. Java tiene registros, que cubren buena parte del terreno de las clases de datos; tiene clases selladas y coincidencia de patrones en switch, incluida la descomposición de registros, que cubre buena parte del terreno de las jerarquías selladas con when; tiene inferencia local de tipos, bloques de texto y un ritmo de publicación que ya no se mide en lustros. Y tiene, sobre todo, hilos virtuales, que resuelven el problema de la concurrencia con muchas conexiones simultáneas sin cambiar el modelo de programación bloqueante que todo el ecosistema ya conocía.

Conviene ser todavía más concreto, porque el reconocimiento genérico no sirve de nada. Un registro cubre razonablemente el caso de un portador de datos inmutable con igualdad estructural, que es exactamente para lo que la mayoría de la gente usa una clase de datos. Una jerarquía sellada con coincidencia de patrones en switch y descomposición de componentes cubre el caso de modelar un conjunto cerrado de estados y ramificar sobre él, y lo hace con una sintaxis que en algunos aspectos es más potente que el when de Kotlin, porque descompone estructuras anidadas en la propia comparación. Quien defienda Kotlin ignorando esto está discutiendo con un adversario que ya no existe y perderá la discusión en cuanto alguien abra un editor.

Reconocido eso, quedan tres diferencias que no son de calendario. La primera es la nulabilidad en el sistema de tipos, que Java no tiene y que no puede añadir sin romper todo lo escrito; las anotaciones y los análisis externos ayudan, pero son opcionales, no componen y nadie garantiza que estén. La segunda es que el modelo de concurrencia de Kotlin no es solo una forma de no bloquear hilos: la cancelación estructurada, el contexto que viaja con la corrutina y Flow como abstracción de flujo con contrapresión no tienen equivalente directo, y además funcionan igual en las cuatro plataformas de destino, cosa que los hilos virtuales por definición no pueden ofrecer porque son una característica de la máquina virtual. La tercera es la capacidad de construir vocabularios: las lambdas con receptor permiten a una biblioteca definir una gramática interna que en Java se aproxima con encadenamientos de constructores y no queda igual. A esas tres se puede añadir una cuarta menos vistosa y muy rentable en el día a día: las funciones de extensión permiten organizar el código por caso de uso en lugar de por propietario del tipo, y eso reduce de forma medible la tentación de crear clases de utilidades con nombres genéricos donde acaba cayendo todo.

// Cancelacion estructurada: si algo falla, se cancela lo demas y nadie queda vivo.
suspend fun panel(id: Id): Panel = coroutineScope {
    val perfil = async { servicioPerfil.cargar(id) }
    val pedidos = async { servicioPedidos.recientes(id) }
    Panel(perfil.await(), pedidos.await())
}

Y queda una cuarta diferencia que no es del lenguaje sino de su relación con el ecosistema, y que en la práctica pesa mucho: Kotlin puede permitirse quitar cosas. Java arrastra por diseño todo lo que alguna vez fue público, incluidas construcciones que hoy nadie defendería, porque su compromiso de compatibilidad es prácticamente absoluto. Kotlin tiene un ciclo de deprecación real que le permite retirar errores de juventud con varios años de aviso y migración asistida. Esa capacidad de corregirse es invisible en una comparación de sintaxis y decisiva en un horizonte de una década.

Dicho esto, hay una asimetría que juega a favor de Java y que ningún argumento técnico compensa: es el idioma de la plataforma. Las bibliotecas se publican pensando en él, las herramientas de diagnóstico lo entienden primero y la documentación de cualquier tecnología del entorno viene escrita en su sintaxis. Kotlin habla ese idioma con una fluidez notable, pero sigue siendo un invitado muy bien integrado, y esa condición se nota en los bordes: en un mensaje de error de una biblioteca ajena, en una anotación que espera un constructor sin argumentos, en un procesador que no entiende del todo lo que generaste.

📝
Los hilos virtuales cambiaron el argumento, no lo cancelaron

Si el problema es únicamente atender muchas peticiones sin gastar un hilo por cada una, los hilos virtuales lo resuelven y el argumento de las corrutinas pierde fuerza en el servidor. Si el problema incluye cancelar un árbol de trabajo, propagar contexto, componer flujos con contrapresión o ejecutar lo mismo fuera de la máquina virtual, el argumento sigue en pie.

Swift: el pariente cercano y el espejo incómodo

Swift es el lenguaje que más se parece a Kotlin en intenciones: nulabilidad en el tipo, inferencia agresiva, sintaxis pensada para leerse, cierres al final de la llamada, concurrencia estructurada. Precisamente por eso las diferencias son las que más enseñan.

Swift gana claramente en dos ejes. El primero es la semántica de valor: sus estructuras son tipos de valor de verdad, con copia y con copia perezosa al escribir, lo que hace que la inmutabilidad sea el camino por defecto en lugar de una disciplina. Kotlin tiene clases de valor, pero son un envoltorio que el compilador borra solo en ciertos contextos y que vuelve a existir en cuanto aparece como genérico, como anulable o como supertipo; es una optimización útil, no un modelo de memoria distinto. El segundo eje es la seguridad frente a carreras de datos comprobada en compilación: el modelo de aislamiento por actores y de comprobación estricta de la concurrencia convierte una clase entera de errores en errores de compilación. Kotlin depende de la disciplina, de la inmutabilidad y de la confinación por despachador, y no impide compilar código que comparte estado mutable entre corrutinas.

La comparación es útil precisamente porque el punto de partida era casi el mismo. Ambos lenguajes decidieron que la ausencia de valor vive en el tipo, y ambos llegaron a un operador de encadenamiento seguro y a un operador para aportar un valor por defecto. A partir de ahí divergen en lo que decidieron proteger a continuación. Swift siguió por el camino de la propiedad de los datos: tipos de valor, semántica de copia y, más tarde, aislamiento comprobado. Kotlin siguió por el camino de la interoperabilidad y la portabilidad, y aceptó vivir en un mundo de referencias compartidas porque es el mundo de la plataforma que quería habitar. Ninguna de las dos rutas es una versión peor de la otra: son consecuencias de qué había alrededor.

// Kotlin: la inmutabilidad es una disciplina que el compilador solo apoya en parte.
data class Config(val reintentos: Int, val destinos: List<String>)

val a = Config(3, listOf("uno"))
val b = a.copy(reintentos = 5)   // copia explicita, y la lista sigue compartida

A cambio, Kotlin gana en apertura. Corre en cuatro backends con un ecosistema real en el mayor de ellos, interopera con una de las plataformas más grandes del mundo sin fricción, y su modelo de concurrencia es más maleable: el contexto es explícito y componible, y el usuario puede construir sus propios ámbitos y despachadores en lugar de aceptar un modelo cerrado. Y hay una diferencia menos técnica pero decisiva en la práctica: la trayectoria de compatibilidad de Kotlin ha sido notablemente menos dolorosa.

Hay además un detalle sobre el modelo de concurrencia que suele decidirse mal por desconocimiento. El aislamiento por actores de Swift protege muy bien un tipo de problema —el estado mutable compartido dentro de un proceso— y encaja de maravilla con interfaces de usuario, donde hay un hilo principal privilegiado y un conjunto acotado de estados. Las corrutinas de Kotlin protegen mejor otro tipo de problema —árboles de trabajo con cancelación, tiempos límite y composición de fuentes de datos— y encajan mejor con servidores y canalizaciones. Cuando un equipo compara ambos modelos suele estar comparando cuál se parece más a su problema sin decirlo, y explicitar eso acorta mucho la discusión.

flowchart TD
K[Kotlin] --> A[Interoperabilidad amplia y cuatro backends]
K --> B[Concurrencia componible con contexto explicito]
S[Swift] --> C[Semantica de valor real]
S --> D[Ausencia de carreras comprobada en compilacion]
J[Java moderno] --> E[Hilos virtuales y ecosistema enorme]
SC[Scala] --> F[Tipos de orden superior y clases de tipos]

Scala: expresividad contra coste de equipo

Scala es el vecino que gana en potencia bruta del sistema de tipos y lo hace sin discusión posible. Tiene tipos de orden superior, que permiten abstraer sobre constructores de tipos y expresar cosas que en Kotlin sencillamente no se pueden escribir. Tiene abstracciones contextuales maduras que sostienen clases de tipos reales. Tiene tipos opacos, tipos unión e intersección, y un sistema de metaprogramación de verdad. Quien haya escrito código de dominio con esas herramientas conoce una sensación que Kotlin no ofrece: la de codificar en el tipo una regla de negocio y no volver a pensar en ella.

La ausencia de tipos de orden superior es probablemente la limitación más citada y conviene entender qué significa exactamente, porque suele explicarse mal. No se trata de que falten genéricos, que Kotlin tiene con varianza incluida, sino de que no se puede escribir una función genérica sobre el contenedor mismo: no hay forma de expresar algo que valga a la vez para cualquier tipo que se comporte como un contenedor mapeable, sea lista, secuencia, resultado o valor diferido. En la práctica esto se manifiesta como código casi idéntico repetido para cada contenedor, y como bibliotecas que ofrecen la misma operación cuatro veces con cuatro nombres.

// Cuatro firmas que dicen exactamente lo mismo sobre cuatro contenedores.
fun <A, B> List<A>.transformar(f: (A) -> B): List<B> = map(f)
fun <A, B> Sequence<A>.transformar(f: (A) -> B): Sequence<B> = map(f)
fun <A, B> Result<A>.transformar(f: (A) -> B): Result<B> = map(f)
fun <A, B> Deferred<A>.transformar(f: (A) -> B): Deferred<B> = TODO()

Es un coste real y conviene medirlo con honestidad antes de convertirlo en argumento decisivo: en la mayoría de las bases de código de aplicación esa repetición aparece en unas pocas decenas de sitios y se resuelve con generación o con paciencia, mientras que en una biblioteca de abstracciones genéricas puede ser el problema central. Como casi todo en esta lección, la respuesta depende de qué clase de código escribes tú.

El coste está en otro sitio y es real. La superficie del lenguaje es mucho mayor, lo que significa que dos equipos pueden escribir dialectos incompatibles de estilo dentro del mismo lenguaje y que revisar código ajeno es más caro. Los tiempos de compilación crecen con la sofisticación de los tipos, y la abstracción que ahorra veinte líneas puede costar diez segundos en cada compilación durante años. Y la curva de incorporación es larga en un mercado donde la rotación es alta.

Hay un matiz que suele perderse en esta comparación y que es importante para no caricaturizar: el coste de Scala no es intrínseco al lenguaje sino a cómo lo usa cada equipo. Existe una forma disciplinada de escribir Scala que se parece bastante a Kotlin y que no paga casi nada de ese coste. El problema es que el lenguaje no la impone, y lo que un lenguaje permite acaba apareciendo en algún módulo escrito por alguien con prisa o con ganas de lucirse. Kotlin restringe deliberadamente para que el peor código posible dentro del lenguaje siga siendo legible, y esa es una propiedad que solo se aprecia cuando uno hereda sistemas ajenos.

ℹ️
La expresividad se juzga en el codigo que no escribiste tu

Al comparar sistemas de tipos, la pregunta habitual es qué puedo expresar. La pregunta profesional es qué tendré que entender cuando alguien más lo exprese. Las dos respuestas apuntan en direcciones opuestas y elegir lenguaje consiste, en buena medida, en decidir a cuál de las dos das más peso en tu contexto.

Kotlin tomó una decisión distinta y coherente: renunciar a la parte alta de la expresividad para que el lenguaje quepa entero en la cabeza de una persona normal en un plazo razonable. Los parámetros de contexto estabilizados en la versión 2.4 son el ejemplo perfecto de esa filosofía aplicada: recogen una parte del valor de las abstracciones contextuales, con nombres explícitos y resolución explicable, y dejan fuera deliberadamente la parte que se vuelve difícil de leer.

Dónde pierde Kotlin, sin excusas

🔱

Sin abstraccion sobre constructores de tipos

No hay tipos de orden superior ni clases de tipos de primera clase. Ciertas abstracciones se codifican a mano en cada tipo concreto o simplemente no se escriben.

🧊

Sin semantica de valor real

Las clases de valor se borran solo en ciertos contextos. No hay un modelo de copia ni de propiedad como el de Swift, y el que llegue a la máquina virtual será prestado.

🧵

Sin garantia de ausencia de carreras

El compilador no impide compartir estado mutable entre corrutinas. La seguridad depende de convenciones y revisión, no de comprobaciones.

🧩

Coincidencia de patrones limitada

El when es potente como expresión pero no descompone estructuras al comparar. Java ya lo hace con registros y la diferencia se nota al modelar dominios ricos.

Que la coincidencia de patrones sea limitada tiene consecuencias muy concretas al modelar dominios ricos, y merece un ejemplo. En Kotlin, ramificar sobre una jerarquía sellada obliga a comprobar el tipo y después acceder a los campos, lo que en estructuras anidadas produce comprobaciones encadenadas o desestructurados intermedios. Un lenguaje con descomposición en el patrón permite expresar la forma completa del caso en una sola línea, incluida la condición sobre los campos internos.

// Kotlin: el tipo se comprueba, los campos se leen despues.
fun resumir(e: Evento): String = when (e) {
    is Compra -> if (e.importe > 1000) "compra grande" else "compra"
    is Devolucion -> "devolucion de ${e.pedido.identificador}"
}

A esa lista habría que añadir dos cosas más, para ser completos. La metaprogramación de Kotlin es generación de código, no transformación de programas: resuelve la mayoría de los casos prácticos con menos misterio, pero hay problemas que en un lenguaje con macros se resuelven en veinte líneas y aquí requieren un procesador entero. Y la madurez de los backends no es uniforme: la máquina virtual está en otra liga que el binario nativo y que WebAssembly en cuanto a bibliotecas disponibles y herramientas de diagnóstico, y planificar como si lo fueran es la forma más habitual de que un proyecto multiplataforma se tuerza a mitad de camino.

Hay una última desventaja que no aparece en ninguna comparación de características y que en muchos equipos es la que más pesa: Kotlin es un lenguaje con un único responsable principal de su implementación. Eso ha sido enormemente positivo en velocidad y coherencia de diseño, y la existencia de una fundación con un comité de lenguaje mitiga el riesgo de gobernanza, pero sigue siendo una concentración que no tienen ni Java ni Swift ni Scala en el mismo grado. No es un argumento para descartarlo; es un factor que merece figurar en la evaluación de cualquier decisión con horizonte de diez años, junto al tamaño del mercado laboral y la disponibilidad de personas con experiencia real.

💡
Convierte la comparacion en una lista de riesgos, no en un marcador

En una decisión real no hay ganador. Hay una lista de fallos posibles ordenada por lo que costaría cada uno en tu contexto, y a su lado qué lenguaje hace imposible cada fallo y a cambio de qué. Presentada así, la conversación deja de ser identitaria y empieza a ser útil, y además queda escrita para quien tenga que revisarla dentro de tres años.

Elegir un lenguaje es elegir de que clase de errores quieres protegerte y a cambio de que otros estas dispuesto a convivir

La comparación honesta entre lenguajes deja de ser una discusión de gustos en cuanto se reformula como una cuestión de qué errores quieres que sean imposibles, porque ningún lenguaje puede hacer imposibles todos y cada uno elige un subconjunto distinto pagándolo con complejidad en otro sitio. Kotlin eligió hacer imposible el acceso a un valor ausente y eligió hacer barata la concurrencia con estructura, y pagó por ello aceptando un sistema de tipos que se detiene antes de la abstracción sobre constructores de tipos y un modelo de memoria que sigue siendo el de las referencias compartidas. Swift eligió hacer imposible la carrera de datos y hacer natural la semántica de valor, y paga con un modelo de concurrencia más cerrado, con una superficie de anotaciones que crece y con un ecosistema muy concentrado en una plataforma. Scala eligió hacer expresable casi cualquier invariante en el tipo, y paga con compilaciones lentas, con dialectos internos y con una barrera de entrada que en muchos equipos nunca se amortiza. Java eligió no romper nunca nada, y paga arrastrando durante décadas decisiones que todo el mundo sabe equivocadas y añadiendo lo nuevo alrededor en lugar de en el centro. Ninguna de estas cuatro elecciones es un error; son perfiles distintos de riesgo, y la pregunta profesional correcta nunca es cuál es mejor sino cuál de estos perfiles se parece más al de mi problema, mi equipo y mi horizonte temporal. Un sistema donde el fallo caro es un valor nulo inesperado en producción y donde hay que interoperar con veinte años de código existente tiene una respuesta bastante clara. Un sistema donde el fallo caro es una corrupción de datos por concurrencia tiene otra. Un sistema donde el fallo caro es que una regla de negocio se aplique mal en un caso de cada mil quizá justifique codificarla en el tipo aunque cueste compilaciones lentas. La madurez profesional consiste en poder hacer ese razonamiento con el lenguaje que uno más quiere en la lista de candidatos y a veces no elegirlo, y consiste también en conocer tan bien sus límites que ninguna comparación te sorprenda: cuando alguien te enseñe algo que Kotlin no puede hacer, la respuesta útil no es una excusa sino saber ya por qué se decidió que no lo hiciera y qué se obtuvo a cambio.

⚔️ Compara con un caso concreto en la mano
  1. Toma un módulo real de tu sistema y reescribe su parte central mentalmente en Java moderno. Anota qué se pierde de verdad y qué solo se pierde en comodidad.
  2. Localiza en tu código una invariante que hoy sostiene una convención de equipo. Escribe cómo la haría imposible el compilador en Swift y qué coste tendría replicarlo en Kotlin.
  3. Elige una abstracción tuya que se repita casi idéntica sobre tres tipos distintos. Explica por qué no puedes unificarla y qué haría falta en el sistema de tipos para conseguirlo.
  4. Busca la última vez que alguien de tu equipo compartió estado mutable entre corrutinas. Diseña la comprobación automática que lo habría detectado, dado que el compilador no lo hace.
  5. Escribe media página defendiendo el uso de otro lenguaje para un proyecto concreto tuyo. Si no eres capaz de escribirla de forma convincente, todavía no conoces los límites del que usas.