Tipos que no coinciden
El diccionario oculto de tipos mapeados, la aritmética de cajas y primitivos, los arrays especializados, la ilusión de las colecciones de solo lectura vista desde el otro lado, y las propiedades convertidas en pares de métodos. Cuatro desajustes que explican casi todas las sorpresas de la interoperabilidad.
La nulabilidad es el desajuste más famoso entre Kotlin y la JVM, pero no es el único ni el más frecuente en el día a día. Hay un segundo grupo de discrepancias que no tienen que ver con lo que un valor puede ser sino con qué tipo es exactamente, y que se manifiestan de forma desconcertante precisamente porque casi siempre funcionan: escribes String, compila, funciona en Java, y un día descubres que tu jerarquía de colecciones era una ficción, que dos enteros iguales no eran idénticos o que la lista inmutable que devolviste acabó modificada por un consumidor Java. Ninguna de esas cosas es un fallo del lenguaje. Todas son consecuencias predecibles de un diccionario de correspondencias que existe, está documentado y que a partir de esta lección deberías tener en la cabeza.
- Reproducir de memoria las correspondencias entre los tipos de Kotlin y los de la plataforma.
- Predecir cuándo un número viaja como primitivo y cuándo se envuelve en un objeto, y qué consecuencias tiene.
- Explicar por qué las colecciones de solo lectura no son inmutables y qué hacer al respecto en una API pública.
- Traducir cualquier declaración de propiedad Kotlin a los miembros que verá un consumidor Java.
El diccionario oculto
Algunos tipos de Kotlin no existen realmente: son nombres propios para tipos de la plataforma, sustituidos en el momento de compilar. El compilador los llama tipos mapeados y su lista es corta pero conviene tenerla completa, porque de ella se deriva más de lo que parece.
Raíz y texto
Any es la cara Kotlin de la clase raíz de Java, y String es literalmente la misma clase, no una envoltura. Por eso no hay conversión ni coste al cruzar.
Números
Int, Long, Double y compañía se convierten en primitivos cuando pueden y en sus cajas cuando no. La decisión no la tomas tú, la toma el contexto.
Colecciones
List, Set, Map y sus variantes mutables apuntan todas a las interfaces del paquete de utilidades de Java. La distinción entre leer y mutar solo existe en el compilador.
Vacío y ausencia
Unit se convierte en el retorno vacío de la plataforma, y Nothing no tiene contrapartida real: aparece en las firmas como la clase testigo de vacío.
La consecuencia inmediata de este diccionario es que la interoperabilidad con estos tipos es de coste cero: pasar una cadena o una lista de Kotlin a Java no envuelve, no copia y no convierte nada, porque al nivel del bytecode nunca hubo dos tipos distintos. La consecuencia diferida es que todas las peculiaridades de los tipos de Java siguen ahí, agazapadas debajo de un nombre más limpio, y aparecen en cuanto rascas.
Cajas, primitivos y arrays
El caso de los números es el ejemplo canónico de peculiaridad heredada. Un Int no nulo en posición ordinaria viaja como primitivo, lo que significa cero asignación de memoria y aritmética directa. Pero un Int?, o un Int usado como argumento de tipo genérico, no puede ser primitivo porque las cajas son lo único que la JVM sabe guardar en una variable de tipo, y entonces aparece la envoltura.
val a: Int = 1000
val b: Int? = 1000
val c: Int? = 1000
println(b == c) // true: igualdad estructural sobre el valor
println(b === c) // false en general: son dos objetos distintos
El resultado sorprendente de la tercera línea no es un capricho de Kotlin, es la caché de enteros pequeños de la biblioteca estándar de Java asomando entre las tablas: por debajo de cierto umbral, las cajas se reutilizan y la identidad coincide; por encima, no. La regla profesional que se deriva es breve y no admite excepciones: sobre valores numéricos se compara siempre con igualdad estructural, y la identidad referencial se reserva para cuando comparas identidad de verdad.
Los arrays son el segundo frente y tienen una particularidad que Kotlin resuelve con tipos dedicados.
val objetos: Array<Int> = arrayOf(1, 2, 3) // Integer[] en la JVM
val nativos: IntArray = intArrayOf(1, 2, 3) // int[] en la JVM
Un Array<Int> es un array de cajas, con una indirección y una asignación por elemento; un IntArray es un array primitivo auténtico. La diferencia de rendimiento en un bucle caliente es de un orden de magnitud, y esa es la razón de que existan IntArray, LongArray, DoubleArray y el resto de la familia en lugar de una sola clase genérica. Hay además una divergencia de reglas: los arrays de Java son covariantes, de modo que un array de cadenas es aceptable donde se espera un array de objetos, con la comprobación desplazada a la ejecución; los de Kotlin son invariantes, lo que elimina esa clase entera de fallos a costa de necesitar proyecciones explícitas al cruzar.
Pasar un array a un parámetro variádico con el operador de propagación no reenvía el mismo array: el compilador copia su contenido, porque el método receptor podría modificarlo y la semántica de Kotlin no lo permite. En un bucle sobre arrays grandes esa copia silenciosa aparece en el perfilador antes de lo que uno espera.
La ilusión de la inmutabilidad
Aquí está la sorpresa que más cuesta digerir. En Kotlin, List y MutableList son dos interfaces distintas y el compilador impide llamar a métodos de mutación sobre la primera. En el bytecode, ambas son exactamente la misma interfaz de Java, y esa interfaz declara los métodos de mutación.
fun expuesta(): List<String> = mutableListOf("a", "b")
// Desde Java, la vista de solo lectura no existe
List<String> lista = Ejemplo.expuesta();
lista.add("c"); // compila y, dependiendo de la implementacion, funciona
El compilador de Kotlin sí sabe la diferencia y por eso rechaza el equivalente Kotlin de esa línea, pero el compilador de Java no tiene forma de saberla: los métodos de mutación están en la interfaz que ve. La palabra correcta para describir lo que ofrece List es de solo lectura, no inmutable, y la distinción no es un tecnicismo: significa que la lista puede seguir cambiando bajo tus pies si alguien más conserva una referencia mutable, y significa que la garantía se evapora al cruzar la frontera.
flowchart TD A[MutableList en Kotlin] --> B[Interfaz List de Java en el bytecode] C[List de solo lectura en Kotlin] --> B B --> D[Consumidor Kotlin] B --> E[Consumidor Java] D --> F[El compilador impide mutar la vista de solo lectura] E --> G[Ve todos los metodos de mutacion disponibles] G --> H[Envolver o copiar antes de exponer] style F fill:#a6e3a1,color:#11111b style G fill:#f38ba8,color:#11111b
Hay una segunda cara del mismo desajuste que muerde en la dirección contraria. Una colección devuelta por Java llega a Kotlin como tipo de plataforma y el compilador te deja declararla de solo lectura, pero esa declaración es tuya, no del origen: si la biblioteca conserva la referencia y sigue modificándola, tu vista cambia bajo tus pies sin que nada lo impida.
val nombres: List<String> = apiJava.obtenerNombres() // decision tuya
val copia: List<String> = apiJava.obtenerNombres().toList() // decision segura
La defensa en una API pública es explícita y barata: devolver una copia, o envolver la colección con las utilidades de solo lectura de la biblioteca estándar de Java, que producen una vista cuyos métodos de mutación lanzan excepción. Elegir una u otra depende de si te preocupa que alguien mute a través de la referencia original o solo de impedirlo desde fuera; lo que no es una opción defendible es publicar una colección interna y confiar en que el tipo Kotlin la proteja.
Propiedades vistas como métodos
El último desajuste es el más cotidiano y el que peor se comunica entre equipos mixtos. En Kotlin una propiedad es una unidad conceptual; en la JVM es un campo privado más uno o dos métodos, y el nombre de esos métodos sigue reglas que conviene conocer al detalle.
class Sesion(
val id: String, // getId
var activa: Boolean, // isActiva y setActiva
@JvmField val token: String, // campo publico token, sin accesores
) {
val derivada: Int get() = id.length // getDerivada, sin campo
lateinit var cliente: Cliente // campo mas accesores
}
const val LIMITE = 100 // campo estatico final, resuelto en compilacion
Traducido al lado Java, ese fragmento produce una superficie considerablemente más ancha de lo que sugiere su tamaño en Kotlin.
public final String getId();
public final boolean isActiva();
public final void setActiva(boolean);
public final String token; // campo, sin accesores
public final int getDerivada();
public final Cliente getCliente();
public final void setCliente(Cliente);
public static final int LIMITE = 100;
La regla del prefijo booleano es la que más confusión genera: una propiedad cuyo nombre ya empieza por el prefijo interrogativo conserva ese nombre en el getter en lugar de recibir el prefijo de lectura, porque así lo dicta la convención de componentes de Java. El resto se deriva con naturalidad: una propiedad calculada no tiene campo de respaldo porque no hay nada que almacenar, una propiedad de inicialización tardía sí lo tiene y además lo expone con la visibilidad del setter, y una propiedad delegada guarda el delegado en un campo con sufijo y traduce cada lectura en una llamada al método de obtención de valor.
Kotlin puede emitir registros de la plataforma marcando una clase de datos con la anotación correspondiente, y en dirección contraria lee los registros Java exponiendo sus componentes como propiedades. Es una de las pocas zonas donde los dos modelos convergieron de verdad en lugar de traducirse, y merece la pena aprovecharla en modelos de datos compartidos entre equipos.
Lo que has visto en esta lección tiene una estructura común que va mucho más allá de estos cuatro casos. Kotlin construyó sobre la JVM un conjunto de abstracciones mejores que las de la plataforma: separó leer de mutar en las colecciones, hizo invariantes los arrays, unificó campo y accesores en la noción de propiedad y ocultó la aritmética de cajas detrás de un tipo numérico único. Todas esas mejoras son reales y todas cumplen su promesa dentro del dominio donde el compilador manda. Ninguna sobrevive intacta al cruzar hacia un mundo que no las conoce, porque el bytecode solo puede expresar lo que la plataforma sabe decir, y lo que la plataforma no sabe decir se pierde. Esto es una instancia particular de algo que vas a encontrar en cada capa de cada sistema que construyas en tu vida profesional: una abstracción que mejora la capa inferior lo hace añadiendo restricciones que solo su propio guardián verifica, y en el instante en que un actor entra sin pasar por ese guardián, las restricciones dejan de existir mientras que la representación subyacente sigue ahí, intacta y accesible. Lo verás idéntico en un ORM cuyas invariantes desaparecen ante una consulta directa a la base de datos, en un tipo validado que se serializa a un JSON que cualquiera puede fabricar a mano, en un microservicio cuya lógica de dominio protege un estado que otro servicio escribe por debajo, en un sistema de permisos de la aplicación sobre un almacén que también acepta escrituras administrativas. La conclusión no es desconfiar de las abstracciones, que sería quedarse programando en el nivel más bajo para siempre y renunciar a todo lo que se ha ganado. Es saber siempre dos cosas de cada abstracción que uses: quién la vigila y por dónde se puede entrar sin saludarle. Cuando conoces esas dos respuestas, puedes decidir con criterio si en una frontera concreta hace falta una copia defensiva, una validación explícita o nada en absoluto, y esa decisión informada es exactamente la diferencia entre un sistema que aguanta y uno que sorprende.
- Declara un
Inty unInt?en la misma clase, decompila y localiza en qué firma aparece el primitivo y en cuál la caja. - Compara dos valores numéricos idénticos por identidad referencial, primero con un número pequeño y después con uno grande, y explica el resultado.
- Mide en un bucle de un millón de iteraciones la diferencia entre recorrer un
Array<Int>y unIntArray. - Devuelve una lista de solo lectura desde Kotlin, modifícala desde Java y después protege la misma función con una envoltura no modificable.
- Escribe una clase con propiedades de los cinco tipos de la sección final e inventaría con
javaptodos los miembros que ve un consumidor Java.