Cuándo el envoltorio reaparece: las fronteras del aplanamiento
La promesa de coste cero de una clase de valor es condicional y las condiciones son precisas: al usarla como argumento de tipo genérico, al hacerla nulable, al tratarla a través de una interfaz que implementa o al guardarla en una colección, el compilador vuelve a crear la instancia real. Esta lección enumera cada frontera, explica el mecanismo que la hace inevitable y enseña a verificarlo en el bytecode en lugar de suponerlo.
La frase con la que se suele presentar este constructo, abstracción sin coste, es cierta y peligrosa a partes iguales. Es cierta en el caso que motiva la característica, que es el de un valor que se pasa entre funciones y se lee. Es peligrosa porque quien la interioriza sin matices termina construyendo un mapa de identificadores a entidades, mirando una gráfica de memoria que no cuadra y concluyendo que el compilador le ha mentido. No le ha mentido: hay un conjunto reducido y perfectamente enumerable de situaciones en las que la representación plana deja de ser posible y el compilador restituye el objeto. Conocerlas de memoria vale más que cualquier medición, porque permiten predecir el resultado antes de escribir el código, y verificarlas en el bytecode vale todavía más, porque convierte la predicción en un hecho.
- Enumerar con precisión las cuatro fronteras donde el aplanamiento deja de aplicarse.
- Explicar por qué el borrado de tipos y la representación nulable hacen inevitable el envoltorio.
- Anticipar el efecto de implementar una interfaz sobre el punto de llamada, no sobre la declaración.
- Verificar la presencia de instancias reales leyendo el bytecode generado.
Las cuatro fronteras
La regla general que ordena todo lo demás es sencilla de enunciar: el aplanamiento requiere que el punto de uso conozca estáticamente el tipo concreto y que la posición admita un valor primitivo. Cuando cualquiera de las dos condiciones falla, aparece el objeto. Las cuatro fronteras son las cuatro maneras de que falle.
La primera es la genericidad. Un parámetro de tipo se borra en la compilación y su representación en el resultado es el tipo raíz de la jerarquía de objetos, de modo que cualquier valor colocado ahí debe ser un objeto de verdad.
@JvmInline value class IdCuenta(val valor: Long)
fun directo(id: IdCuenta): Long = id.valor // plano: recibe un Long
fun <T> generico(x: T): T = x
fun demo(id: IdCuenta) {
directo(id) // sin instancia
generico(id) // instancia creada para cruzar la frontera generica
}
La segunda es la nulabilidad. Un Long primitivo no tiene ningún patrón de bits disponible para representar la ausencia, así que un tipo de valor nulable sobre un subyacente primitivo debe representarse como una referencia que puede ser nula. Conviene notar el matiz que sí funciona: si el tipo subyacente es a su vez una referencia, como String, el compilador puede aplanar el caso nulable sin envolver, porque la referencia nula ya expresa la ausencia.
@JvmInline value class IdCuenta(val valor: Long)
@JvmInline value class Correo(val texto: String)
fun a(id: IdCuenta?) { } // envuelve: no hay Long nulable primitivo
fun b(c: Correo?) { } // no envuelve: la referencia nula ya sirve
La tercera es la implementación de una interfaz. Aquí está el matiz más sutil de todo el nivel: la declaración de la interfaz no cuesta nada por sí misma, y las llamadas que se hacen sobre el tipo concreto siguen siendo planas. El coste aparece en el punto donde el valor se trata a través del tipo de la interfaz, porque el despacho dinámico necesita un objeto con cabecera sobre el que consultar la implementación.
interface Identificable { val clave: String }
@JvmInline
value class IdCuenta(val valor: Long) : Identificable {
override val clave: String get() = "cuenta:" + valor
}
fun porConcreto(id: IdCuenta): String = id.clave // plano
fun porInterfaz(i: Identificable): String = i.clave // recibe un objeto
La cuarta es la colección. Es en realidad un corolario de la primera, porque las colecciones de la biblioteca estándar son genéricas, pero merece mención propia por ser la vía por la que la mayoría de la gente se encuentra el problema en producción. Guardar un millón de identificadores en una lista crea un millón de objetos, exactamente igual que si nunca se hubiese declarado la clase de valor.
Genéricos
El borrado de tipos deja como representación el tipo raíz. Todo lo que pase por un parámetro de tipo se envuelve, sin excepciones.
Nulables
Solo si el subyacente es primitivo. Con un subyacente de referencia, el caso nulable sigue aplanado y no cuesta nada.
Interfaces
El coste no está en implementarla, sino en cada punto donde el valor se usa a través del tipo de la interfaz.
Colecciones
Son genéricas, luego envuelven. Para volúmenes grandes, la alternativa es un array del tipo subyacente.
Verificarlo en lugar de suponerlo
La regla anterior es fiable, pero el criterio último es el bytecode y conviene tener el hábito de consultarlo. En el entorno de desarrollo habitual existe la orden que muestra el bytecode de Kotlin para el fichero abierto y, desde ahí, la decompilación a Java, que es la forma más legible de leerlo. Desde la línea de órdenes, la herramienta estándar del kit de desarrollo cumple la misma función.
kotlinc Dominio.kt -d salida/
javap -c -p salida/DominioKt.class | head -60
Lo que hay que buscar es concreto. Si en la firma del método aparece el tipo subyacente, el valor viaja plano. Si aparece la clase de valor, hay una instancia. En el cuerpo, la instrucción de creación de objeto seguida de la llamada al constructor delata el envoltorio; la llamada al método estático de acceso al campo delata el desenvoltorio en el otro extremo.
// firma plana: cargar-TC7ZOTs(J)Ljava/lang/String;
// firma con objeto: cargar(LIdCuenta;)Ljava/lang/String;
Hay una consecuencia práctica que se deriva de leer varias veces estas firmas: el aplanamiento no es una propiedad del tipo sino de cada posición concreta donde aparece. La misma clase de valor puede viajar plana en una función, envuelta en la de al lado y otra vez plana tres líneas después. Pensar en términos de posiciones y no de tipos evita la mayoría de las conclusiones erróneas.
flowchart TD
A[Uso de un valor de tipo value class] --> B{La posicion es generica}
B -->|Si| Z[Instancia real]
B -->|No| C{El tipo es nulable}
C -->|Si| D{El subyacente es primitivo}
D -->|Si| Z
D -->|No| Y[Representacion plana]
C -->|No| E{Se accede via interfaz}
E -->|Si| Z
E -->|No| YDiseñar alrededor de las fronteras
Saber dónde está el coste sirve para colocarlo donde no importa, que casi siempre es posible. La primera decisión es distinguir el camino caliente del resto: si un tipo de valor solo se envuelve al guardarlo en la lista de resultados de una consulta que devuelve veinte filas, la conversación ha terminado y no hay nada que optimizar. El problema existe únicamente donde el volumen es grande o la frecuencia alta.
Cuando sí existe, la técnica principal es mantener la colección en la representación subyacente y reconstruir el tipo de dominio en el borde, que es una operación gratuita porque el constructor no hace nada en tiempo de ejecución salvo la validación que se haya escrito.
@JvmInline value class IdCuenta(val valor: Long)
class Indice(private val claves: LongArray) { // sin envoltorios almacenados
operator fun get(posicion: Int): IdCuenta = IdCuenta(claves[posicion])
fun contiene(id: IdCuenta): Boolean = claves.any { it == id.valor }
}
La segunda técnica es preferir las funciones de extensión a las interfaces cuando lo que se busca es comportamiento compartido y no polimorfismo real. Una extensión se resuelve estáticamente y no fuerza en ningún momento la aparición de un objeto, de modo que ofrece la comodidad de la sintaxis de miembro sin la penalización del despacho dinámico.
La tercera es vigilar las funciones de orden superior. Una lambda declarada en línea dentro de una función marcada como incrustable no introduce ninguna frontera, porque su cuerpo se copia en el punto de llamada y el tipo concreto sigue siendo visible allí. Una lambda que se pasa a una función no incrustable, en cambio, se convierte en una instancia cuya interfaz funcional es genérica, y todo valor que la atraviese se envuelve.
@JvmInline value class IdCuenta(val valor: Long)
inline fun conId(id: IdCuenta, bloque: (IdCuenta) -> Unit) = bloque(id) // sin envolver
fun registrar(accion: (IdCuenta) -> Unit) { } // la lambda es generica: envuelve
Comparar dos valores de una clase de valor con el operador de igualdad es gratuito cuando ambos operandos tienen el tipo concreto. Pero si uno de ellos llega tipado como el tipo raíz de objetos, porque venía de una colección o de una posición genérica, la comparación pasa por el método virtual y el otro operando se envuelve para poder invocarla. La misma expresión escrita igual cuesta cosas distintas según el tipo estático de sus partes.
La expresión abstracción sin coste procede de la tradición de C++ y significa algo muy preciso que casi siempre se pierde en la traducción: que usar la abstracción no cuesta más que escribir a mano el código equivalente sin ella. No significa que el resultado sea gratis, sino que la abstracción no añade nada sobre lo que ya costaba el problema. En el caso de una clase de valor guardada en una lista genérica, la comparación correcta no es con un array de primitivos, que es una estructura de datos distinta, sino con lo que habría costado guardar ahí un envoltorio ordinario, y frente a esa referencia el coste sigue siendo cero. La confusión entre ambas lecturas explica la mayoría de las decepciones con esta característica, y también algunas decisiones desafortunadas: gente que abandona los tipos de dominio al descubrir un envoltorio en su perfilador, cuando ese envoltorio existía igualmente en la versión anterior del código y lo único nuevo es que ahora se ve. Hay una lección más general escondida aquí sobre cómo leer las promesas de rendimiento de cualquier lenguaje. Una promesa de esta clase no es una propiedad del tipo declarado, sino de cada punto de uso, y depende de lo que el compilador sabe en ese punto concreto. Es el mismo patrón que se ha visto con inline, que solo elimina el objeto de la lambda si el parámetro no se guarda ni se pasa a otro sitio, y con los smart casts, que solo se aplican si la estabilidad del valor se puede demostrar allí. En los tres casos la garantía es condicional, las condiciones son enunciables y verificables, y la habilidad profesional consiste en llevarlas en la cabeza al escribir en lugar de descubrirlas en el perfilador seis meses después. El compilador no oculta nada de esto: publica el resultado en el bytecode para quien quiera mirarlo, y la diferencia entre creer y saber son treinta segundos de decompilación.
- Escribe una función que reciba tu clase de valor y otra que la reciba como parámetro de tipo genérico; compara ambas firmas en el bytecode.
- Declara dos versiones nulables, una sobre un subyacente primitivo y otra sobre
String, y comprueba que solo la primera envuelve. - Haz que tu clase implemente una interfaz y localiza el punto exacto donde aparece la instrucción de creación de objeto.
- Sustituye una lista de un millón de identificadores por un array del tipo subyacente y mide la diferencia de memoria residente.
- Reescribe un método miembro como función de extensión y verifica que la firma generada sigue siendo plana.