wandres.dev
VALUE CLASSES · abstracción sin coste

value class con @JvmInline: qué genera realmente el compilador

Una clase de valor no es una clase ligera: es una instrucción al compilador para que sustituya el envoltorio por su único campo en la firma de cada función y en cada variable local. Esta lección disecciona el bytecode que se genera, explica el mecanismo de mangling que hace posible la coexistencia de firmas, y recorre las restricciones del constructo entendiéndolas como consecuencias necesarias de la representación elegida y no como limitaciones arbitrarias.

⏱ 24 min

La declaración cabe en una línea y esa brevedad engaña, porque debajo de ella ocurre algo cualitativamente distinto de lo que hace cualquier otra palabra clave del lenguaje. Una data class genera métodos que uno podría haber escrito a mano. Un object genera un patrón que existía antes que el lenguaje. Una clase de valor, en cambio, altera la representación: le dice al compilador que ese tipo existe durante la comprobación estática y que debe desaparecer al emitir el código, sustituido en cada punto posible por el único campo que contiene. Es la primera construcción del lenguaje que se compromete explícitamente con lo que no habrá en el resultado, y entender qué genera exige mirar el bytecode, porque el código fuente no lo cuenta.

🎯 Al terminar esta lección sabrás
  • Declarar una clase de valor y explicar por qué @JvmInline es necesaria en el objetivo JVM.
  • Leer el bytecode generado e identificar dónde desaparece el envoltorio y dónde permanece la clase.
  • Entender el mangling del nombre de las funciones y qué problema de compatibilidad resuelve.
  • Derivar cada restricción del constructo a partir de la representación que impone, en lugar de memorizarla.

La declaración y su traducción

El punto de partida es una declaración mínima. La anotación no es decorativa: en el objetivo JVM el compilador la exige, porque las plataformas de Kotlin no comparten la misma estrategia de representación y la anotación deja constancia de que se ha elegido la de la JVM de forma explícita.

@JvmInline
value class IdCuenta(val valor: Long)

fun cargar(id: IdCuenta): String = "cuenta " + id.valor

fun main() {
    val id = IdCuenta(42L)
    println(cargar(id))
}

Lo que el compilador emite para esto no contiene ninguna asignación de memoria. La función cargar recibe un Long, la variable local id es un Long, y la llamada pasa un Long. El tipo IdCuenta ha existido durante todo el análisis, ha rechazado cualquier intento de pasarle un identificador de otra entidad, y luego se ha evaporado. Aproximadamente esto es lo que se vería si se decompilase el resultado a Java.

// aproximacion de lo generado
public static final String cargar_TC7ZOTs(long valor) {
    return "cuenta " + valor;
}

public static final void main() {
    long id = IdCuenta.constructor_impl(42L);
    System.out.println(cargar_TC7ZOTs(id));
}

Hay dos detalles que merecen atención. El primero es que la clase IdCuenta sí existe en el resultado como clase real, con métodos estáticos que implementan el constructor, la igualdad, el código de dispersión y la representación textual. Existe porque hace falta un lugar donde vivan esos comportamientos y porque, como veremos en la lección siguiente, hay situaciones en las que el envoltorio reaparece y entonces se necesita un objeto de verdad. Lo que desaparece es la instancia en el uso normal, no la declaración.

El segundo detalle es el sufijo del nombre de la función. Ese apéndice ilegible es el mangling, y resuelve un problema que sin él sería insalvable.

Por qué el nombre viene deformado

Supón dos funciones que en Kotlin son claramente distintas y que, tras el aplanamiento, tendrían la misma firma en la JVM.

@JvmInline value class IdCuenta(val valor: Long)
@JvmInline value class IdPedido(val valor: Long)

fun buscar(id: IdCuenta): String = "..."
fun buscar(id: IdPedido): String = "..."

Si el compilador emitiese las dos como un método que recibe un Long, la clase resultante tendría dos métodos con idéntica firma, lo cual el formato de clase de la JVM prohíbe. La solución es añadir al nombre un sufijo derivado de un hash de la firma original en Kotlin, de modo que los dos métodos generados tengan nombres distintos y la ambigüedad desaparezca en el nivel donde importa. El mangling tiene además un efecto deliberado sobre la interoperabilidad: llamar a esa función desde Java resulta incómodo por accidente, lo cual es una forma honesta de señalar que la garantía de tipos que la función asume no se puede sostener desde el otro lado de la frontera. Cuando se quiere ofrecer una función con clases de valor a consumidores de Java, la práctica correcta es exponer una fachada explícita.

@JvmInline value class Centimos(val valor: Long)

fun aplicarDescuento(importe: Centimos): Centimos = Centimos(importe.valor * 9 / 10)

@JvmName("aplicarDescuentoJava")
fun aplicarDescuentoParaJava(importeEnCentimos: Long): Long =
    aplicarDescuento(Centimos(importeEnCentimos)).valor
flowchart TD
A[Codigo fuente con value class] --> B[Fase de analisis]
B --> C[El tipo existe y rechaza mezclas]
C --> D[Fase de generacion]
D --> E{El uso admite representacion plana}
E -->|Si| F[Se emite el campo desnudo y se deforma el nombre]
E -->|No| G[Se emite una instancia real de la clase]

Las restricciones son consecuencias

El constructo impone una lista de reglas que resulta mucho más fácil de recordar si se deriva cada una de la representación elegida en lugar de aprenderla de memoria.

Un único campo, declarado en el constructor primario y necesariamente inmutable. Es la restricción fundamental y la razón es aritmética: la representación aplanada consiste en sustituir el envoltorio por su contenido, y eso solo tiene sentido si el contenido es una cosa. Dos campos exigirían pasar dos argumentos donde la firma declara uno, que es precisamente el problema que resuelven los tipos de valor auténticos del proyecto Valhalla y que las clases de valor de Kotlin no intentan abordar. La inmutabilidad viene del mismo sitio: si el envoltorio no existe en tiempo de ejecución, no hay ningún lugar donde escribir.

Sin herencia en ninguna de las dos direcciones. La clase no puede heredar de otra ni ser heredada, y por tanto es implícitamente final. Una jerarquía requiere despacho dinámico, el despacho dinámico requiere una cabecera de objeto donde consultar el tipo, y una cabecera de objeto es exactamente lo que aquí no hay. Sí puede implementar interfaces, pero con una consecuencia que la lección siguiente examina en detalle.

Sin campos con estado más allá del propio valor. Se pueden declarar propiedades, siempre que sean calculadas a partir del campo y no tengan campo de respaldo; se pueden declarar funciones miembro, bloques de inicialización y un companion object. Lo que no cabe es guardar nada adicional, porque no hay dónde.

@JvmInline
value class Correo(val texto: String) {

    init {
        require("@" in texto) { "correo invalido" }
    }

    val dominio: String get() = texto.substringAfter('@')   // calculada: sin campo

    fun enmascarar(): String = texto.first() + "***@" + dominio

    companion object {
        fun deConfianza(t: String): Correo? = if ("@" in t) Correo(t) else null
    }
}

Conviene añadir una regla que no se deduce de la representación sino de la interoperabilidad: el nombre del campo importa. Como la clase generada expone métodos estáticos construidos alrededor de ese nombre y la propiedad de acceso se genera a partir de él, cambiarlo es un cambio incompatible en la interfaz binaria aunque el tipo siga siendo el mismo. En una biblioteca publicada eso significa que el nombre del único campo forma parte del contrato público con la misma seriedad que el nombre de la clase, algo que sorprende a quien está acostumbrado a tratar los campos privados como detalle interno.

@JvmInline value class Milisegundos(val valor: Long)

// renombrar el campo a "ms" rompe a los consumidores ya compilados
// aunque el codigo fuente que los produjo siga compilando sin cambios

El bloque init merece un comentario aparte porque es la puerta de entrada a los tipos con invariante. La validación se ejecuta en la construcción, lo cual significa que la existencia de un valor de tipo Correo es prueba de que el texto pasó la comprobación. Esa es una propiedad enorme y se obtiene sin ningún objeto en el montón, aunque conviene recordar que el envoltorio no impide construir un valor inválido por vías que esquiven el constructor, como la deserialización mediante reflexión.

💡
Constructor privado y fábrica pública

El patrón más robusto para un tipo con invariante combina un constructor privado con una fábrica en el companion object que devuelva un tipo nulable o un Result. Así el único camino hasta una instancia pasa por la validación, y quien la obtiene tiene una garantía real y no una convención.

ℹ️
La igualdad es estructural y barata

El compilador genera equals y hashCode que delegan en el campo, de modo que dos valores con el mismo contenido son iguales. Cuando la representación está aplanada, comparar dos identificadores es comparar dos enteros largos: no hay llamada virtual, no hay desreferencia y el comparador se reduce a una única instrucción.

Es la primera vez que el lenguaje te deja hablar de lo que no debe existir en el resultado

Merece la pena situar este constructo en la trayectoria general del lenguaje, porque cambia la naturaleza de lo que una declaración puede expresar. Casi todo lo que se escribe en un lenguaje de alto nivel describe qué debe hacer el programa, y la relación con el código generado es una consecuencia que el compilador administra sin consultarnos. Una clase de valor invierte esa relación: lo esencial de la declaración no es el comportamiento, que es trivial, sino la forma del resultado. Se está afirmando que este tipo debe existir para la comprobación y no debe existir para la ejecución. Es la misma categoría de afirmación que hace inline sobre las funciones de orden superior, y no es casualidad que ambos constructos compartan la sensación incómoda de estar hablando con el compilador en lugar de con la máquina abstracta. Esa incomodidad es informativa. Un lenguaje que solo permite describir comportamiento obliga a elegir entre modelar bien y ejecutar rápido cada vez que ambas cosas tiran en direcciones opuestas, y esa elección se resuelve casi siempre a favor del rendimiento porque el rendimiento se mide y el modelado no. Un lenguaje que permite describir también la representación disuelve el dilema en los casos en que la abstracción era, en el fondo, información para el compilador y no estructura necesaria en memoria. Ahí está el interés real de las clases de valor y también el origen de sus restricciones: un único campo, sin herencia, sin estado adicional. Todas ellas son el precio de la afirmación que se está haciendo, y verlas como limitaciones arbitrarias es no haber entendido qué se pidió. Quien las lee como consecuencias sabe además predecir sin consultar la documentación qué otras cosas serán imposibles, que es la diferencia entre conocer una herramienta y conocer su fundamento.

⚔️ Mira debajo
  1. Declara una clase de valor sobre Long, compila y decompila el resultado; localiza el nombre deformado de la función que la recibe.
  2. Escribe dos funciones con el mismo nombre que reciban dos clases de valor distintas sobre el mismo tipo subyacente y comprueba que compilan.
  3. Añade un init con una precondición y verifica en qué método estático de la clase generada acaba esa comprobación.
  4. Intenta declarar un segundo campo, luego una herencia, y anota el mensaje exacto del compilador en cada caso.
  5. Expón una función con clase de valor a un consumidor de Java mediante una fachada y compara la ergonomía de ambas versiones.