wandres.dev
VALUE CLASSES · abstracción sin coste

Los tipos sin signo y Result: leer las clases de valor de la stdlib

Los enteros sin signo, la duración y el resultado encapsulado no son tipos primitivos del lenguaje: son clases de valor escritas en la propia biblioteca estándar, y todo su comportamiento aparentemente extraño se deduce de esa implementación. Esta lección abre las tres, explica por qué la igualdad de dos fallos se comporta como se comporta y por qué el tipo UUID estabilizado en Kotlin 2.4 no pudo ser una clase de valor.

⏱ 24 min

Existe una manera de estudiar una característica del lenguaje que rinde mucho más que leer su documentación, y consiste en encontrar el sitio donde la biblioteca estándar la usa en serio y leer ese código. En el caso de las clases de valor el material es abundante y está a un salto de definición de distancia: los enteros sin signo son clases de valor, la duración es una clase de valor, el resultado encapsulado es una clase de valor. Los tres se comportan de maneras que a mucha gente le resultan sorprendentes, y en los tres casos la sorpresa se disuelve en cuanto se mira la declaración. No hay magia del compilador reservada para la biblioteca: hay exactamente el mismo constructo que tú puedes escribir, con las mismas restricciones y las mismas fronteras de aplanamiento, aplicado con una atención al detalle que merece la pena imitar.

🎯 Al terminar esta lección sabrás
  • Explicar la representación de los enteros sin signo y por qué sus arrays no penalizan.
  • Leer la declaración del resultado encapsulado y deducir de ella su comportamiento en igualdad y anidamiento.
  • Identificar la trampa de capturar toda excepción en un contexto de corrutinas.
  • Reconocer por qué el tipo UUID de la biblioteca no pudo declararse como clase de valor.

Enteros sin signo: la reinterpretación de unos bits

Un entero sin signo de 32 bits no es un tipo nuevo de la máquina virtual, que no dispone de ninguno. Es un entero con signo corriente al que se le da otra lectura, y toda la aritmética que lo distingue vive en funciones que la biblioteca implementa por encima del primitivo.

// forma esencial de la declaracion en la biblioteca estandar
@JvmInline
public value class UInt internal constructor(internal val data: Int) : Comparable<UInt> {

    public operator fun plus(other: UInt): UInt = UInt(this.data + other.data)

    public operator fun div(other: UInt): UInt = uintDivide(this, other)

    override fun toString(): String = toLong().toString()
}

De aquí se deducen varios comportamientos sin necesidad de consultar nada. La suma es la misma instrucción de la máquina que la de los enteros con signo, porque el complemento a dos hace que sumar y restar sean idénticos en ambas lecturas; la división y la comparación, en cambio, requieren funciones propias, porque ahí sí divergen. El desbordamiento envuelve en silencio igual que en el tipo con signo, de modo que restar uno a cero produce el valor máximo y ninguna excepción. Y la conversión a texto pasa por un entero de 64 bits, que es el truco habitual para imprimir el valor sin signo correcto.

Hay un detalle de diseño que conviene subrayar porque contradice lo estudiado en la lección anterior. Los arrays de enteros sin signo no pagan envoltorio, y no lo pagan porque el tipo de array es a su vez una clase de valor que envuelve el array primitivo con signo.

@JvmInline
public value class UIntArray internal constructor(internal val storage: IntArray)

Es una idea que merece guardarse para el diseño propio: cuando una colección de tu tipo de dominio se va a manejar en volumen, la salida no es una lista genérica sino una clase de valor que envuelve el array del tipo subyacente y expone la interfaz que quieras. El coste es un envoltorio total, no uno por elemento.

Hay una consecuencia de todo esto que suele pasar inadvertida y que conviene tener presente en la frontera con Java. Como los enteros sin signo son clases de valor, las funciones que los reciben llevan el nombre deformado y en el resultado aparece el entero con signo correspondiente, con lo cual un consumidor de Java ve una firma que acepta valores negativos sin ninguna barrera. La garantía de no negatividad existe únicamente dentro de Kotlin, y en una interfaz publicada hacia el otro lado hay que sostenerla con una validación explícita o con una fachada.

ℹ️
La duración sigue el mismo patrón

El tipo que representa un intervalo temporal en la biblioteca es también una clase de valor sobre un entero de 64 bits, y usa un bit para codificar si el resto de la magnitud son nanosegundos o milisegundos. Toda la aritmética temporal cómoda que ofrece se ejecuta sin una sola asignación de memoria.

El resultado encapsulado y su rareza

La declaración del tipo que representa un resultado que puede haber fallado es más interesante todavía, porque su campo no es un primitivo sino el tipo raíz de objetos, y toda su semántica descansa en un truco de representación.

// forma esencial de la declaracion en la biblioteca estandar
@JvmInline
public value class Result<out T> internal constructor(internal val value: Any?) {

    public val isSuccess: Boolean get() = value !is Failure

    internal class Failure(val exception: Throwable)
}

El éxito se guarda tal cual, sin envolver: si el resultado es correcto, el campo contiene directamente el valor. El fallo se guarda dentro de una instancia de una clase interna que existe únicamente para ser distinguible de cualquier valor legítimo. La comprobación de éxito es, por tanto, una comprobación de tipo sobre el campo, y esa decisión explica todo lo demás.

De ahí se deduce, por ejemplo, que el camino feliz no asigna memoria alguna mientras el valor no cruce una frontera de aplanamiento, y que el camino de error asigna un objeto pequeño además de la excepción. Se deduce también que dos fallos construidos con excepciones distintas nunca son iguales, aunque el mensaje coincida, porque la igualdad delega en el campo y las excepciones se comparan por identidad. Y se deduce el comportamiento del anidamiento, que resulta menos intuitivo de lo que parece: un resultado correcto cuyo valor es a su vez un resultado guarda en el campo el valor interno aplanado, con las consecuencias que uno esperaría de aplanar dos capas en una sola.

val a = Result.failure<Int>(IllegalStateException("roto"))
val b = Result.failure<Int>(IllegalStateException("roto"))
println(a == b)                      // false: identidad de la excepcion

val c = runCatching { 7 }
println(c.getOrNull())               // 7, sin ningun objeto creado

Queda la rareza más costosa en la práctica, que no procede de la representación sino de la elección de qué capturar. La función que envuelve un bloque en un resultado captura cualquier lanzable, y en un contexto de corrutinas eso incluye la excepción que implementa la cancelación. Capturarla y no relanzarla rompe la cancelación cooperativa y produce trabajo que sigue ejecutándose después de que su ámbito haya sido cancelado, que es un fallo desagradable de diagnosticar.

suspend fun cargar(): Result<Datos> = runCatching { peticion() }
    .onFailure { if (it is CancellationException) throw it }   // relanzar siempre

Merece la pena señalar una segunda decisión de la biblioteca que suele leerse como capricho y tiene una razón sólida detrás: durante mucho tiempo no se permitió usar este tipo como tipo de retorno declarado de una función pública, y hoy que se permite conviene seguir pensándoselo. El motivo es que un resultado encapsulado es un mecanismo para transportar el fallo a través de una frontera concreta, no un sustituto general de las excepciones ni un tipo de error del dominio. Cuando lo que se quiere modelar es que una operación puede fracasar de tres maneras conocidas, la herramienta adecuada es una jerarquía sellada, que nombra cada fallo, obliga a tratarlos todos y no arrastra la ambigüedad de un lanzable genérico. El resultado encapsulado brilla en el borde, envolviendo código ajeno que lanza; hacia dentro, casi siempre hay un modelo mejor.

⚠️
La ambigüedad del valor ausente

Un resultado sobre un tipo nulable arrastra una ambigüedad real: la función que devuelve el valor o nulo entrega nulo tanto si hubo fallo como si el éxito contenía nulo. En ese caso hay que consultar explícitamente el indicador de éxito, o evitar la combinación desde el diseño, que suele ser mejor idea.

flowchart TD
A[Result como value class sobre el tipo raiz] --> B{El campo es una instancia de Failure}
B -->|No| C[Exito: el valor viaja sin envolver]
B -->|Si| D[Fallo: la excepcion vive dentro de Failure]
C --> E[Igualdad estructural del valor]
D --> F[Igualdad por identidad de la excepcion]

Lo que la biblioteca enseña sobre los límites

El contraejemplo más instructivo del conjunto es el tipo UUID que Kotlin 2.4 estabilizó en la biblioteca estándar. Un identificador universal ocupa 128 bits, es decir, dos enteros de 64, y esa aritmética lo excluye del constructo: una clase de valor admite un campo y solo uno. La biblioteca lo declara por tanto como una clase ordinaria con dos campos, y el coste correspondiente forma parte del trato.

import kotlin.uuid.Uuid

val id: Uuid = Uuid.random()
val texto: String = id.toHexDashString()
val vuelta: Uuid = Uuid.parse(texto)

Vale la pena observar además cómo resuelve la biblioteca el problema de la mezcla en este caso, porque es exactamente el que abría el nivel. Un identificador universal no dice de qué es identificador, de modo que dos referencias a entidades distintas siguen siendo intercambiables. La solución no está en el tipo de la biblioteca, que no puede saber nada del dominio de nadie, sino en el uso: una clase de valor propia que envuelva el identificador universal recupera la distinción sin coste adicional, porque el campo es una referencia y por tanto se aplana.

import kotlin.uuid.Uuid

@JvmInline value class RefPedido(val id: Uuid)
@JvmInline value class RefEnvio(val id: Uuid)

La lección de diseño que se extrae es doble. Por un lado, el límite de un campo es duro y no hay manera de rodearlo mientras la plataforma no ofrezca tipos de valor auténticos con varios campos, que es la promesa a largo plazo del proyecto Valhalla. Por otro, la biblioteca resuelve la tensión donde le corresponde: ofrece el tipo rico como clase ordinaria para el uso general y ofrece la conversión a texto y a par de enteros largos para quien necesite una representación plana en el camino caliente. Ese es exactamente el patrón que conviene copiar cuando un tipo de dominio propio no cabe en un campo.

💡
Lee la biblioteca con el editor, no con el navegador

La orden de ir a la declaración lleva desde cualquier uso hasta el fuente de la biblioteca, y ese fuente está escrito para ser leído. Media hora recorriendo las declaraciones de los tipos estudiados aquí enseña más sobre las clases de valor que cualquier tutorial, porque muestra el constructo aplicado a problemas reales y con las concesiones que los problemas reales imponen.

La biblioteca estándar no es una caja negra: es el mejor manual de estilo que tiene el lenguaje

Hay un hábito profesional que separa con bastante nitidez a quien conoce un lenguaje de quien lo domina, y consiste en tratar su biblioteca estándar como código fuente disponible en lugar de como una interfaz documentada. La diferencia de rendimiento entre ambas actitudes es enorme y no se debe a la información concreta que se obtiene, sino a la clase de preguntas que uno aprende a hacerse. Quien lee la documentación del resultado encapsulado aprende que la igualdad de dos fallos es por identidad, si el redactor se acordó de escribirlo. Quien lee la declaración ve que el campo es del tipo raíz, que el fallo vive dentro de una clase interna y que la igualdad delega en el campo, y a partir de ahí deduce ese comportamiento y otros diez que nadie documentó, incluido lo que ocurrirá con un caso que todavía no se le ha presentado. La primera forma de conocimiento es una lista que se memoriza y se olvida; la segunda es un modelo del que se derivan respuestas. Hay además una dimensión de humildad técnica en esta práctica. Las declaraciones que se han abierto en esta lección están llenas de concesiones: un bit robado a la magnitud para distinguir la escala temporal, una clase interna que existe solo para ser distinguible, un tipo de array que envuelve otro array para evitar un envoltorio por elemento, un identificador universal que renuncia al constructo porque no cabe en un campo. Ninguna de esas decisiones es elegante en abstracto y todas son correctas en su contexto. Ver a los autores del lenguaje negociando con las mismas restricciones con las que uno negocia, y resolviendo con el mismo tipo de compromiso, cura de una superstición extendida y paralizante: la de creer que existe en alguna parte una solución limpia que uno no encuentra por falta de talento, cuando lo que hay es un conjunto de restricciones y un juicio sobre cuál de ellas duele menos ceder.

⚔️ Abre la biblioteca
  1. Ve a la declaración de UInt en tu editor y localiza qué operadores delegan en funciones propias y cuáles reutilizan la instrucción con signo.
  2. Comprueba experimentalmente que restar uno a cero en un entero sin signo produce el valor máximo y explica por qué desde la representación.
  3. Escribe dos fallos con excepciones distintas del mismo tipo y mensaje, compáralos, y justifica el resultado leyendo la declaración.
  4. Busca en tu código todas las llamadas que envuelven un bloque en un resultado dentro de una corrutina y añade el relanzamiento de la cancelación.
  5. Modela un identificador de 128 bits de las dos maneras posibles y contrasta el coste con el del tipo UUID de la biblioteca.