La obsesión por los primitivos: dos enteros que nadie distingue
Un identificador de usuario y un identificador de pedido son ambos enteros de 64 bits, y para el compilador eso significa que son el mismo tipo y se pueden intercambiar en una llamada sin que nada proteste. Esta lección diagnostica el problema clásico de la obsesión por los primitivos en su forma más costosa, la de los identificadores y las magnitudes, mide qué se paga al envolver cada uno en una clase ordinaria y plantea la tensión que el resto del nivel viene a resolver.
Hay una categoría de error que ninguna prueba unitaria bien escrita detecta, que ninguna revisión atenta encuentra de forma fiable y que el compilador, con todo su aparato de inferencia y exhaustividad, deja pasar sin un solo aviso. Ocurre cuando dos conceptos del dominio que jamás deberían mezclarse comparten representación en la máquina y, por tanto, comparten tipo en el programa. Un identificador de cliente y un identificador de factura son ambos enteros largos. Una cantidad en euros y una cantidad en céntimos son ambas enteros. Una distancia en metros y una distancia en pies son ambas reales de doble precisión. En cada uno de esos pares hay una barrera conceptual que todo el equipo respeta mentalmente y que el sistema de tipos ignora por completo, porque el sistema de tipos no ve conceptos: ve representaciones. Este nivel trata de cómo cerrar esa brecha sin pagar por ello, pero antes conviene entender exactamente qué se está pagando ahora.
- Diagnosticar la obsesión por los primitivos como un fallo de modelado, no como una cuestión de estilo.
- Reconocer las tres familias donde más daño hace: identificadores, unidades físicas y magnitudes monetarias.
- Cuantificar el coste real de envolver cada primitivo en una clase ordinaria de la JVM.
- Formular con precisión qué propiedad haría falta para que el envoltorio saliera gratis.
El error que el compilador no puede ver
Considera una firma perfectamente razonable, escrita por alguien competente y aceptada en revisión sin un comentario.
fun transferir(origen: Long, destino: Long, importe: Long) { /* ... */ }
fun main() {
val cuentaOrigen = 100_234L
val cuentaDestino = 998_001L
val centimos = 5_000L
transferir(cuentaDestino, cuentaOrigen, centimos) // compila, y esta al reves
}
Los tres parámetros son del mismo tipo, de modo que las seis permutaciones posibles de los argumentos compilan sin error. Cinco de ellas son incorrectas. El lenguaje no dispone de ninguna información que le permita distinguirlas, porque desde su punto de vista se le están pasando tres enteros de 64 bits a una función que pide tres enteros de 64 bits, y el contrato se cumple.
Los paliativos habituales existen y son honestos, pero ninguno es una solución. Los parámetros nombrados obligan a escribir el nombre en la llamada y eliminan buena parte del riesgo, aunque solo cuando quien llama decide usarlos y nada obliga a ello. Una convención de nomenclatura disciplinada ayuda a leer, pero no impide asignar idPedido a una variable llamada idUsuario. Las pruebas cubren el camino que alguien pensó en cubrir, no la permutación que nadie imaginó. Todas estas medidas dependen de la atención humana, que es exactamente el recurso que el sistema de tipos existe para no consumir.
Conviene además reparar en la forma en que estos fallos se manifiestan, porque es lo que los hace caros. Un error de tipos se detecta en la compilación y cuesta segundos. Un error de lógica se detecta en las pruebas y cuesta minutos. Un intercambio de identificadores no se detecta en ninguna de las dos fases: produce un programa que se ejecuta sin incidencias, escribe datos coherentes en la base de datos y devuelve respuestas con el formato correcto. Se descubre semanas después, cuando alguien de negocio pregunta por qué un cliente aparece asociado a un pedido que no hizo, y para entonces los datos incorrectos ya se han propagado a los informes, a los sistemas que consumen los eventos y a las copias de seguridad. El coste de la corrección incluye la migración de los registros afectados, que suele ser la parte más laboriosa y la que nadie presupuestó.
El fenómeno tiene un nombre antiguo en la literatura de diseño, obsesión por los primitivos, y se suele presentar como un olor de código menor, casi estético. Esa clasificación le hace un flaco favor, porque no es una cuestión de gusto: es una decisión sobre dónde se comprueba una regla del dominio. La regla existe siempre, alguien la conoce y en algún momento se aplica; la única variable es si se aplica en cada compilación o si se aplica cuando una persona atenta lee la línea correcta el día adecuado.
El daño no se reparte de forma uniforme. Hay tres familias donde el fenómeno resulta especialmente destructivo y merece la pena identificarlas por separado.
Identificadores
Toda entidad del dominio tiene un identificador y casi todos comparten representación. Una base de datos con veinte tablas produce veinte conceptos distintos que el programa representa con un único tipo.
Unidades
Metros y pies, segundos y milisegundos, grados y radianes. La confusión de unidades ha destruido sondas espaciales; en una aplicación de negocio produce facturas mal calculadas que nadie relaciona con su causa.
Magnitudes
Euros y céntimos, importe bruto e importe neto, porcentaje expresado como fracción o como número entre cero y cien. Todos son el mismo tipo numérico y ninguno significa lo mismo.
Envolver: la solución correcta y su factura
La respuesta de modelado es evidente y lleva décadas descrita en la literatura: si dos conceptos no deben mezclarse, dales tipos distintos. En Kotlin eso se escribe en una línea.
data class IdCuenta(val valor: Long)
data class Centimos(val valor: Long)
fun transferir(origen: IdCuenta, destino: IdCuenta, importe: Centimos) { /* ... */ }
Ahora la permutación errónea del importe deja de compilar y la del origen con el destino sigue siendo posible, pero se ha reducido de seis casos a dos y el que queda es el único que el nombre del parámetro puede resolver. El código gana además una propiedad que no se aprecia hasta que se busca: cada aparición de IdCuenta en la base de código es un lugar donde el dominio dijo algo, y esos lugares se pueden localizar, contar y refactorizar como una unidad.
Merece la pena insistir en ese segundo beneficio porque suele quedar eclipsado por el primero y resulta más duradero. Un tipo propio convierte un concepto difuso en una entidad que las herramientas pueden manipular: se puede pedir al editor todos sus usos con exactitud, se puede cambiar su representación interna sin tocar a los llamantes, se puede añadir una validación en un único punto y saber que rige en todo el sistema. Con un entero largo suelto no existe ninguna de esas operaciones, porque no hay nada que señalar salvo una convención de nombres que la herramienta no entiende.
El problema es lo que ocurre por debajo. Una clase ordinaria en la JVM es un objeto en el montón, y eso implica una cabecera de objeto de doce o dieciséis bytes según la configuración de punteros comprimidos, una asignación de memoria por cada instancia, una indirección de puntero en cada lectura del campo y presión sobre el recolector de basura proporcional al número de envoltorios vivos. Para un identificador que se crea una vez y vive en una entidad de larga duración esto es irrelevante. Para un identificador que atraviesa un bucle que procesa un millón de filas, deja de serlo.
// primitivo: un array contiguo de 8 bytes por elemento
val crudos: LongArray = LongArray(1_000_000)
// envuelto: un array de punteros mas un millon de objetos dispersos en el monton
val envueltos: Array<IdCuenta> = Array(1_000_000) { IdCuenta(it.toLong()) }
La segunda versión no es un poco más cara: consume del orden de cuatro veces más memoria y destruye la localidad de caché que hacía rápido al recorrido, porque los objetos que el array referencia están esparcidos por el montón y cada acceso es un fallo de caché en potencia. En un servicio que procesa lotes, esa diferencia es visible en las gráficas.
Existe una segunda factura, menos citada y a menudo más molesta, que es la de ergonomía. Envolver un primitivo obliga a abrirlo cada vez que se necesita el valor real, y ese punto se convierte en un ritual repetido a lo largo de todo el código. La aritmética deja de funcionar directamente, la comparación exige delegar en el campo si el envoltorio no la define, la serialización produce un objeto anidado donde antes había un número, y la interoperabilidad con bibliotecas que esperan primitivos requiere conversiones en cada frontera. Nada de esto es insalvable, pero suma fricción, y la fricción es la razón real por la que muchos equipos que conocen perfectamente la teoría del modelado terminan escribiendo firmas con cinco enteros largos.
data class Centimos(val valor: Long)
fun total(lineas: List<Centimos>): Centimos =
Centimos(lineas.sumOf { it.valor }) // abrir, operar, volver a envolver
La primera idea de mucha gente al enfrentarse a esto es declarar un alias de tipo, que da un nombre nuevo sin crear un tipo nuevo. El alias mejora la lectura y no cuesta absolutamente nada, pero no aporta ninguna seguridad: es literalmente el mismo tipo con otro nombre, de modo que un identificador de cuenta sigue siendo asignable a un identificador de pedido y la llamada errónea del primer ejemplo continúa compilando sin un aviso.
La intuición dice que lo caro es la asignación de memoria. En la JVM moderna, con asignación por puntero de bump en la región joven, crear el objeto es sorprendentemente barato. Lo caro es la indirección y la pérdida de localidad en los recorridos largos, y eso no aparece en un microbenchmark que crea un envoltorio y lee su campo: aparece en el sistema real, repartido en forma de latencia que nadie atribuye a su causa.
La tensión que hay que resolver
De un lado, el modelado quiere tipos distintos para conceptos distintos y los quiere en todas partes, no solo donde el rendimiento sobre. Del otro, la ejecución quiere el primitivo desnudo, sin cabecera y sin indirección. Durante mucho tiempo esas dos aspiraciones fueron incompatibles y el resultado práctico fue un compromiso pobre: se envolvía en las capas altas, donde el coste no importaba, y se pasaban primitivos en el núcleo caliente, que es precisamente donde los errores son más difíciles de diagnosticar.
flowchart TD
A[Concepto del dominio] --> B{Se envuelve en una clase}
B -->|No| C[Tipo primitivo compartido]
C --> D[Rapido pero intercambiable por error]
B -->|Si| E[Objeto en el monton]
E --> F[Seguro pero con cabecera e indireccion]
D --> G[Objetivo: seguridad del tipo con la representacion del primitivo]
F --> GEse compromiso tiene además una propiedad desagradable que conviene enunciar: no es estable. Las capas altas y el núcleo caliente no están separados por una frontera que alguien haya dibujado, sino por una intuición sobre qué se ejecuta mucho, y esa intuición envejece mal. Un método que hoy se llama una vez por petición se llamará dentro de un año dentro de un bucle sobre el catálogo entero, porque alguien reutilizó la función correcta para un caso nuevo. Cuando eso ocurre, el tipo de dominio que protegía la capa alta se convierte en el envoltorio que aparece en el perfilador, y la reacción típica es retirarlo, con lo cual el sistema pierde la garantía justo en el momento en que empieza a manejar volumen. Un modelado cuyo coste depende de dónde se use es un modelado que se erosiona solo.
Lo que haría falta es una construcción que existiera durante la comprobación de tipos y desapareciera durante la generación de código: un tipo que el compilador tratara como distinto de Long en toda la fase de análisis y que, al emitir el bytecode, sustituyera por el Long que lleva dentro. No un objeto ligero, ni un objeto reutilizado desde una caché, sino la ausencia total del objeto. Esa es exactamente la promesa de value class con @JvmInline, y el resto del nivel se dedica a estudiar hasta dónde llega la promesa, en qué situaciones concretas se rompe y cómo diseñar para permanecer dentro del terreno donde se cumple.
La idea no es original de Kotlin y conviene situarla, porque saber de dónde viene ayuda a anticipar sus límites. Haskell ofrece desde hace décadas una declaración de tipo con un único constructor y un único campo que el compilador elimina por completo. Rust obtiene un efecto equivalente con las estructuras de un solo campo, que su modelo de representación aplana sin necesidad de anotación alguna. Scala experimentó con clases de valor sujetas a restricciones muy parecidas a las que estudiaremos aquí, y por las mismas razones. La familia entera responde al mismo diagnóstico y llega a soluciones que se parecen mucho, lo cual sugiere que las restricciones no son deficiencias de una implementación concreta sino consecuencias del problema.
El proyecto Valhalla persigue en la propia máquina virtual un objetivo más ambicioso: tipos de valor auténticos con varios campos, sin identidad y aplanados incluso dentro de arrays y de posiciones genéricas. Cuando llegue, buena parte de las fronteras que estudiaremos en la tercera lección se desplazarán. Nada de lo que aprendas aquí quedará obsoleto por ello, porque el criterio de modelado es independiente del mecanismo, pero conviene saber que el terreno se está moviendo.
Conviene detenerse en por qué este problema resulta tan persistente pese a ser tan fácil de enunciar. La razón es que la obsesión por los primitivos no se manifiesta como un error, sino como una ausencia de estructura, y las ausencias no aparecen en los informes de incidencias ni en los paneles de métricas. Nadie abre un ticket que diga que el tipo de un parámetro es demasiado general; se abre un ticket que dice que una transferencia fue a la cuenta equivocada, y la investigación termina en una línea concreta que se corrige, sin que nadie observe que la línea era corregible únicamente porque el lenguaje permitía escribirla. Hay una consecuencia más profunda. Cada vez que se elige un primitivo para representar un concepto, se toma la decisión de que ese conocimiento del dominio viva fuera del programa: en el nombre de la variable, en un comentario, en la cabeza del ingeniero que lleva tres años en el equipo, en un documento de arquitectura que se actualizó por última vez hace dieciocho meses. Todos esos soportes tienen la misma propiedad indeseable, que es la de no verificarse nunca. El compilador, en cambio, verifica en cada construcción, para cada persona que llega al equipo, sin cansarse y sin excepciones. Trasladar conocimiento desde los soportes que no se verifican hacia el que se verifica siempre es probablemente la actividad de ingeniería con mejor relación entre esfuerzo y retorno que existe, y es lo que hace un tipo de dominio. La pregunta interesante nunca fue si merece la pena distinguir un identificador de usuario de un identificador de pedido: eso es evidente en cuanto se formula. La pregunta era cuánto cuesta hacerlo, porque un coste alto convierte la práctica correcta en un lujo que solo se permite en las capas donde no duele. Lo que cambia con las clases de valor no es la teoría del modelado, que ya estaba escrita; es el precio, y el precio es lo que determina si una práctica se adopta de verdad o se cita en las presentaciones.
- Elige la firma más larga de tu código y cuenta cuántos de sus parámetros comparten tipo; calcula el número de permutaciones erróneas que compilan.
- Localiza tres conceptos del dominio representados hoy con
LongoStringy escribe qué invariante incumple el tipo actual. - Busca en tu historial de incidencias una que se explique por una confusión de identificadores o de unidades y anota cuánto tardó en diagnosticarse.
- Mide la memoria de un array de un millón de enteros largos frente a la de un array de un millón de envoltorios y compara el resultado con tu intuición previa.
- Enumera los lugares donde hoy renuncias a envolver por miedo al coste y guarda la lista para revisarla al terminar el nivel.