El boxing en detalle: el número que se convierte en objeto
Un `Int` de Kotlin no es una cosa sino dos, y cuál de las dos aparece en el código generado depende del contexto sintáctico exacto en el que se escribe. Esta lección precisa los disparadores del empaquetado, describe la caché de enteros pequeños que la plataforma mantiene por contrato, explica las sorpresas de identidad que esa caché provoca cuando se compara con `===` en lugar de con `==`, y contrapone los arrays especializados como `IntArray` con los arrays de referencias que se les parecen en la sintaxis y no en el coste.
Kotlin no tiene tipos primitivos y tipos envoltorio: tiene un solo tipo Int que el compilador representa de una manera o de otra según dónde aparezca. La promesa es elegante y funciona casi siempre, pero tiene una consecuencia que hay que interiorizar temprano: el coste de un número deja de estar en su declaración y pasa a estar en su uso. La misma variable puede ser cuatro bytes en un registro en una línea y un objeto con cabecera, alineación y puntero en la siguiente, sin que nada en el código fuente cambie de aspecto. Y como si eso fuera poco, la plataforma mantiene una caché de instancias para los valores pequeños que hace que dos enteros iguales sean a veces el mismo objeto y a veces no, lo que convierte cualquier comparación por identidad sobre números en una fuente inagotable de errores que aparecen solo en producción, solo con datos grandes y solo a veces.
- Enumerar con precisión los contextos sintácticos que fuerzan la conversión de un primitivo en objeto.
- Describir el contrato de la caché de enteros pequeños y el rango que la plataforma garantiza.
- Explicar por qué comparar enteros con el operador de identidad produce resultados dependientes del valor.
- Elegir con criterio entre un array especializado y una colección genérica según la huella de memoria que implica cada uno.
Cuándo un número deja de ser un número
El compilador representa un Int como el primitivo de la máquina siempre que puede, y no puede exactamente en cuatro situaciones. La primera es cuando el valor entra en un tipo genérico, porque el borrado de tipos obliga a que todo parámetro de tipo sea una referencia. La segunda es cuando el tipo admite ausencia, porque un primitivo no tiene forma de representar el nulo. La tercera es cuando el valor se guarda en una variable de un supertipo, ya sea Any, Comparable o Number. La cuarta es cuando cruza una frontera de tipo función, que es un caso particular de la primera con la peculiaridad de que ocurre una vez por invocación.
val a: Int = 42 // primitivo
val b: Int? = 42 // objeto
val c: Any = 42 // objeto
val d: List<Int> = listOf(42) // objeto dentro de la lista
val e: IntArray = intArrayOf(42) // primitivo dentro del array
fun aplicar(f: (Int) -> Int) = f(1) // empaqueta el argumento y el resultado
La consecuencia práctica es que la huella de memoria de una colección de números depende de una decisión que no se toma al declarar los números, sino al elegir el contenedor. Una lista de un millón de enteros ocupa el espacio de un millón de punteros más el de un millón de objetos, cada uno con su cabecera de la máquina virtual y su relleno de alineación; el mismo millón de enteros en un array especializado ocupa exactamente cuatro megabytes contiguos. La diferencia no es de un factor pequeño, y además tiene un efecto de segundo orden que suele pesar más que el primero: el array contiguo se recorre a velocidad de memoria secuencial, mientras que la lista de punteros obliga a un salto por elemento hacia una posición que probablemente no esté en la caché.
Es habitual pensar solo en la entrada, pero el desempaquetado tiene su propio coste y su propia trampa. Leer un elemento de un List<Int> significa seguir un puntero y extraer el campo interno, lo que en un bucle apretado es una lectura de memoria que puede fallar en caché. Y si ese elemento se compara con un literal, el compilador inserta la conversión automáticamente, de modo que una expresión aparentemente aritmética puede estar haciendo dos indirecciones antes de sumar. La regla útil es que un primitivo dentro de un genérico paga en los dos sentidos, no solo al guardarse.
Hay un quinto disparador que no es sintáctico sino de plataforma y que conviene mencionar porque rompe la simetría de los cuatro anteriores: los tipos numéricos con signo invertido y las clases de valor construidas sobre primitivos siguen las mismas reglas, pero su desaparición depende además de que la firma en la que aparecen no las obligue a materializarse para distinguirse de otra sobrecarga. En la práctica eso significa que una envoltura pensada para no costar nada puede costar exactamente lo mismo que el envoltorio que pretendía evitar, y que la única forma de saberlo es inspeccionar la firma generada.
@JvmInline
value class Euros(val centimos: Long)
fun cobrar(x: Euros) {} // el parametro es un long primitivo
fun cobrar(x: Euros?) {} // el parametro es un objeto Euros
val cartera: List<Euros> = listOf() // cada elemento es un objeto Euros
La caché de enteros pequeños
La plataforma especifica que la conversión de un entero en objeto, cuando se hace por la vía canónica de Integer.valueOf, debe devolver instancias compartidas para los valores comprendidos entre menos ciento veintiocho y ciento veintisiete, ambos incluidos. No es un detalle de implementación sino una garantía del lenguaje anfitrión, pensada para que el patrón habitual de empaquetar números pequeños no sature el montículo. El mismo contrato existe para los caracteres del rango bajo y para los booleanos, que tienen exactamente dos instancias en todo el sistema.
val x: Int? = 127
val y: Int? = 127
println(x == y) // true: igualdad estructural
println(x === y) // true: la cache devolvio la misma instancia
val p: Int? = 128
val q: Int? = 128
println(p == q) // true
println(p === q) // false: fuera de cache, dos objetos distintos
Ese salto de comportamiento en una frontera arbitraria es el origen de una familia entera de errores. El código se escribe con datos de prueba pequeños, funciona, pasa las pruebas, y falla en producción cuando los identificadores superan el umbral. Lo grave no es la caché, que hace lo que promete: lo grave es usar el operador de identidad sobre valores donde lo que interesa es la igualdad. En Kotlin la distinción es explícita y el compilador incluso advierte en algunos casos, pero la advertencia desaparece en cuanto los tipos se vuelven genéricos y el operador se aplica a dos referencias cuyo origen numérico ya no se ve.
flowchart TD
A[Un Int necesita convertirse en objeto] --> B{El valor cabe en el rango de cache}
B -- Si --> C[Se devuelve la instancia compartida]
C --> D[La comparacion por identidad da verdadero]
B -- No --> E[Se crea una instancia nueva]
E --> F[La comparacion por identidad da falso]
D --> G[Mismo resultado con igualdad estructural]
F --> GConviene añadir que la caché puede ampliarse por configuración de la máquina virtual en su límite superior, de modo que el umbral exacto no es un número en el que apoyarse. Lo único garantizado es el rango mínimo. Cualquier código cuya corrección dependa de si dos enteros iguales son el mismo objeto está mal escrito por construcción, aunque hoy funcione en todas las máquinas donde se ha probado.
Identidad, igualdad y la trampa del tipo opcional
Kotlin separa la igualdad estructural del operador de dos signos y la identidad referencial del de tres, y sobre tipos primitivos no nulos la distinción es irrelevante porque el compilador emite una comparación numérica en ambos casos. La distinción despierta en el momento en que uno de los operandos deja de ser primitivo, y esa transición es silenciosa. Un cambio de firma que convierte un parámetro en opcional, un valor que pasa por un mapa, una lambda genérica en medio: cualquiera de ellos basta para que la comparación deje de mirar números y empiece a mirar punteros.
Compara siempre por valor
Sobre números, el operador de identidad no tiene ningún caso de uso legítimo. Si el tipo puede ser opcional o genérico, la igualdad estructural es la única comparación correcta.
Cuidado con los tipos anchos
Un Long empaquetado y un Int empaquetado con el mismo valor nunca son iguales estructuralmente, porque sus clases difieren. La conversión explícita antes de comparar evita el falso negativo.
Las clases de valor no salvan
Una value class sobre un Int desaparece en muchos contextos, pero vuelve a existir como objeto en cuanto entra en un genérico o se hace opcional. El mismo mapa de disparadores se le aplica entero.
Hay un caso especialmente incómodo que merece la pena aislar, porque combina las dos cosas anteriores. Cuando se compara un valor opcional con un literal, el compilador tiene que decidir si desempaqueta y compara números o si invoca la igualdad estructural sobre objetos; elige lo segundo para respetar la posibilidad del nulo, de modo que la expresión termina llamando a un método sobre una instancia que quizá no exista y que requiere una comprobación previa. El resultado es correcto y ligeramente más caro de lo que aparenta, y en una ruta caliente esa diferencia se acumula.
fun contarCoincidencias(valores: List<Int?>, objetivo: Int): Int {
var n = 0
for (v in valores) if (v == objetivo) n++ // desempaqueta y compara con nulo
return n
}
fun contarRapido(valores: IntArray, objetivo: Int): Int {
var n = 0
for (v in valores) if (v == objetivo) n++ // comparacion numerica pura
return n
}
La consecuencia de diseño que se deduce de todo esto es sencilla de enunciar y difícil de recordar en caliente: los tipos numéricos opcionales deberían evitarse en las estructuras que se recorren mucho, no por elegancia sino porque cada elemento se convierte en un objeto y cada comparación en una llamada. Cuando la ausencia es un estado legítimo del dominio, casi siempre existe una representación mejor que el opcional sobre primitivo, ya sea un valor centinela documentado, un array paralelo de banderas o un tipo suma que codifique los dos casos sin obligar al montículo a participar.
Arrays especializados y colecciones que no lo son
Kotlin ofrece un array especializado por cada tipo primitivo, y su existencia es precisamente la vía de escape del empaquetado. Un IntArray, un LongArray o un DoubleArray se compilan al array primitivo correspondiente de la plataforma, con almacenamiento contiguo y sin ningún objeto por elemento. Su hermano de sintaxis parecida, Array<Int>, es un array de referencias y empaqueta todos sus elementos, lo que lo convierte en la elección equivocada casi siempre que se escribe por descuido.
val rapido = IntArray(1_000_000) // cuatro megabytes contiguos
val lento = Array(1_000_000) { 0 } // un millon de punteros mas un millon de objetos
val listado: List<Int> = List(1_000_000) { 0 } // lo mismo que el anterior, con envoltorio
La librería estándar acompaña a estos tipos con una familia de operadores propios que preservan la especialización, y usarlos es lo que evita que el ahorro se pierda en la primera transformación. Aplicar un operador genérico sobre un array especializado devuelve una lista de objetos y deshace todo el trabajo; aplicar el operador dedicado devuelve otro array especializado. La diferencia se ve mejor en la firma que en el nombre, y por eso conviene comprobarla en lugar de suponerla.
val datos = IntArray(1000) { it }
val a = datos.map { it * 2 } // devuelve List<Int>: mil objetos
val b = IntArray(datos.size) { datos[it] * 2 } // sigue siendo primitivo
val s1 = datos.sum() // acumula en un primitivo
val s2 = datos.toList().sum() // empaqueta mil veces antes de sumar
La contrapartida es que los arrays especializados no son colecciones: no implementan la jerarquía, no tienen igualdad estructural por contenido salvo con las funciones dedicadas, y su tamaño es fijo. Por eso no son un sustituto general sino una herramienta para las rutas donde la huella importa y el perfil lo ha demostrado. Fuera de esas rutas, insistir en ellos degrada la interfaz de las funciones a cambio de nada. Para el caso intermedio, donde se quiere una colección sin empaquetado y con capacidad de crecer, la respuesta no está en la librería estándar sino en las bibliotecas de colecciones primitivas, y la decisión de introducir una dependencia así debería apoyarse en una medición y no en una intuición.
Merece la pena detenerse en lo que el empaquetado revela sobre el diseño del lenguaje, porque entenderlo cambia la forma de leer todo lo demás. Kotlin toma una decisión valiente al eliminar la distinción entre primitivos y envoltorios del nivel del código fuente: en lugar de obligar al programador a escribir dos tipos distintos y a recordar cuál usar en cada sitio, ofrece uno solo y se compromete a elegir la representación óptima por él. Esa decisión compra una enorme cantidad de claridad, porque hace que los genéricos, la nulabilidad y los tipos función se apliquen uniformemente a todo sin casos especiales, y hace posible que la librería estándar tenga una sola versión de cada operador en lugar de nueve. Pero ninguna abstracción sobre una máquina real es gratuita en todos los puntos, y esta paga su factura exactamente donde la máquina distingue lo que el lenguaje unificó: en el montículo. El empaquetado no es un fallo del diseño sino su suelo visible, el punto donde el modelo mental cómodo se apoya en un hardware que no lo comparte. Y aquí está lo importante: reconocer ese suelo es lo que separa a quien usa la abstracción de quien la sufre. Quien no sabe que existe escribe un List<Int> en un bucle de millones de iteraciones, ve que va lento, no entiende por qué el mismo algoritmo es diez veces más rápido en otro lenguaje y concluye que la plataforma es lenta. Quien sabe que existe escribe el mismo List<Int> en el noventa y nueve por ciento de su código con total tranquilidad, porque sabe que allí no importa, y baja al array especializado en el uno por ciento restante con la certeza de estar cambiando exactamente lo que hay que cambiar. La grieta no invalida la abstracción: saber dónde está es lo que permite confiar en ella el resto del tiempo.
- Escribe cuatro declaraciones del mismo número que produzcan representaciones distintas y justifica cada una por su disparador.
- Reproduce la diferencia de identidad entre un valor dentro del rango de caché y otro fuera de él, y explica por qué la igualdad estructural coincide en ambos casos.
- Compara la huella de memoria de un millón de enteros en un array especializado y en una lista, incluyendo cabeceras y punteros en la cuenta.
- Convierte una función que recibe un tipo función sobre enteros en insertable y describe qué asignación desaparece por invocación.
- Busca en tu código una comparación por identidad sobre valores que puedan ser numéricos y decide si es un error latente o un caso correcto.