wandres.dev
GENÉRICOS Y VARIANZA · in, out y las estrellas

Borrado de tipos: qué sobrevive al runtime

Los argumentos de tipo desaparecen al compilar, pero no todos ni de la misma manera, y la distinción entre lo que se borra y lo que permanece explica la mitad de los errores y de las bibliotecas de este terreno. Esta lección estudia qué queda en el bytecode, qué preguntas admite el operador is sobre un genérico, por qué las conversiones sin comprobar son un aviso y no un error, cómo se conserva la información de tipo mediante firmas y tokens, y qué resuelve exactamente reified y a qué precio.

⏱ 24 min

Todo lo estudiado hasta aquí ocurre en un mundo que deja de existir en cuanto termina la compilación. La varianza, las restricciones, las proyecciones y las estrellas son afirmaciones que el compilador comprueba y después borra, y el objeto que llega a la máquina virtual no conserva ningún recuerdo del argumento de tipo con el que fue construido. Una lista de cadenas y una lista de enteros son, en ejecución, exactamente el mismo tipo de cosa. Esta pérdida se llama borrado de tipos, tiene una razón histórica concreta y produce un conjunto de fenómenos que parecen inconexos hasta que se ve la causa común: comprobaciones prohibidas, conversiones que solo avisan, sobrecargas que chocan sin motivo aparente y una palabra clave, reified, que parece devolver mágicamente lo perdido. No hay magia. Hay una diferencia precisa entre la información de tipo de un objeto, que se borra, y la de una declaración, que no.

🎯 Al terminar esta lección sabrás
  • Explicar qué información de tipo desaparece en la compilación y cuál permanece en los metadatos de las declaraciones.
  • Predecir qué comprobaciones con is acepta el compilador sobre un tipo genérico y por qué rechaza las demás.
  • Interpretar el aviso de conversión sin comprobar y decidir cuándo suprimirlo es legítimo.
  • Aplicar reified en funciones en línea, y enunciar sus límites y las alternativas cuando no basta.

Lo que se borra y lo que permanece

En la máquina virtual de Java no existen los tipos genéricos. Existió la decisión, tomada al introducir los genéricos en 2004, de implementarlos sin tocar el formato de las clases ni el conjunto de instrucciones, de modo que el código nuevo pudiera convivir con el existente sin recompilarlo. La consecuencia es que el compilador comprueba los argumentos de tipo, inserta las conversiones necesarias y después los elimina, dejando en el bytecode una única clase para todas las instanciaciones posibles.

val cadenas = listOf("a", "b")
val enteros = listOf(1, 2)

println(cadenas.javaClass == enteros.javaClass)   // true
println(cadenas::class)                           // la misma clase para ambas

Ahora bien, la afirmación habitual de que los genéricos se borran del todo es falsa, y la matización es la clave de esta lección. Lo que se borra es la información asociada a la instancia: un objeto en el montón no lleva consigo el argumento de tipo con el que se creó. Lo que no se borra es la información asociada a la declaración: la firma genérica de un método, el tipo genérico de un campo y el supertipo genérico de una clase quedan registrados en los metadatos del archivo de clase, porque el compilador los necesita para verificar código que se compile después contra esa biblioteca.

class ListaDeCadenas : ArrayList<String>()   // el argumento String queda en los metadatos

println(ListaDeCadenas::class.java.genericSuperclass)   // java.util.ArrayList<java.lang.String>

De esta asimetría salen consecuencias que a primera vista no parecen relacionadas. Dos funciones que solo se diferencien en el argumento de tipo de un parámetro producen la misma firma en el bytecode y chocan entre sí, aunque en Kotlin sean claramente distintas.

fun procesar(datos: List<String>) { }
// fun procesar(datos: List<Int>) { }        // error: declaraciones con la misma firma JVM

@JvmName("procesarEnteros")
fun procesarEnteros(datos: List<Int>) { }    // la salida habitual es renombrar en la JVM

is con genéricos: qué se te permite preguntar

Si el objeto no lleva el argumento de tipo, no hay forma de preguntárselo, y el compilador prefiere prohibir la pregunta antes que responderla mal. Por eso una comprobación de tipo que mencione un argumento concreto se rechaza de plano, mientras que la misma comprobación con una proyección estrella se acepta sin reservas: la estrella pregunta solo por la clase, que sí sobrevive.

fun clasificar(valor: Any): String = when (valor) {
    // is List<String> -> "cadenas"   // error: no se puede comprobar un tipo borrado
    is List<*> -> "una lista de algo"
    is Map<*, *> -> "un mapa de algo"
    else -> "otra cosa"
}

Hay una excepción que conviene entender bien porque parece contradecir la regla. Cuando el tipo estático del valor ya garantiza el argumento, la comprobación deja de ser una pregunta al runtime y pasa a ser una tautología decidible en compilación, y el compilador la acepta sin generar ninguna verificación. No es que el argumento haya sobrevivido: es que nunca hizo falta consultarlo.

fun contar(numeros: List<Int>) {
    if (numeros is List<Int>) {   // aceptado: el tipo estatico ya lo asegura
        println(numeros.sum())
    }
}

La conversión explícita es el otro lado del mismo hecho. Escribir as List<String> no es un error sino un aviso, porque la conversión se ejecuta pero solo comprueba la clase, no el argumento. El resultado es que una lista de enteros puede pasar por una lista de cadenas sin ninguna queja, y el fallo se manifestará mucho después, en el punto donde alguien lea un elemento y la conversión implícita insertada por el compilador estalle.

@Suppress("UNCHECKED_CAST")
fun <T> comoLista(valor: Any): List<T> = valor as List<T>

val enteros: List<Int> = listOf(1, 2, 3)
val falsas: List<String> = comoLista(enteros)   // pasa sin protestar
// falsas.first().length                        // ClassCastException aqui, no antes
⚠️
Suprimir el aviso es aceptar una obligación de prueba

Silenciar la advertencia de conversión sin comprobar es legítimo exactamente cuando puedes demostrar el argumento por otra vía: porque acabas de construir la colección, porque el valor viene de un mapa cuya clave codifica el tipo, o porque el llamador lo garantiza por contrato documentado. Fuera de esos casos, la supresión traslada un fallo desde el punto donde se produce hasta un punto arbitrario y posterior del programa, que es la peor transformación posible que se le puede aplicar a un error.

flowchart TB
A[Informacion de tipo generica] --> B[Ligada a la instancia]
A --> C[Ligada a la declaracion]
B --> B1[Se borra al compilar]
B1 --> B2[is con argumento concreto prohibido]
B1 --> B3[as con argumento concreto solo avisa]
C --> C1[Queda en los metadatos de la clase]
C1 --> C2[Supertipo generico legible por reflexion]
C1 --> C3[Base del truco del token de tipo]

Conservar la información por otras vías

Que la firma de una declaración sobreviva abre una puerta que las bibliotecas de serialización llevan dos décadas usando. Si el argumento de tipo se escribe en la declaración de un supertipo, permanece en los metadatos, y basta con crear un objeto anónimo que herede de una clase genérica para que el tipo completo quede grabado y sea recuperable por reflexión. Es el truco del token de tipo, conocido por su aparición en las bibliotecas de JSON del mundo Java, y no es un rodeo sucio sino la explotación correcta de la parte del sistema que no se borra.

import java.lang.reflect.ParameterizedType

abstract class TokenDeTipo<T> {
    val tipo = (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0]
}

val token = object : TokenDeTipo<List<String>>() {}
println(token.tipo)   // java.util.List<java.lang.String>

Kotlin ofrece además una vía propia y mucho más limpia. La función typeOf de la reflexión devuelve un KType completo, con sus argumentos de tipo y su nulabilidad, y es el mecanismo sobre el que se apoyan las bibliotecas modernas de serialización para resolver el formato correcto sin que el usuario escriba tokens a mano. La diferencia con T::class es importante y suele pasarse por alto: la clase no conserva ni los argumentos ni la nulabilidad, el tipo sí.

inline fun <reified T> firma(): String = "${T::class.simpleName} vs ${typeOf<T>()}"

firma<List<String>>()   // List vs kotlin.collections.List<kotlin.String>
firma<String?>()        // String vs kotlin.String?

reified y hasta dónde llega

La palabra reified solo puede aparecer en funciones marcadas como inline, y esa restricción no es arbitraria: es el mecanismo entero. Una función en línea se copia en el punto de llamada, y en ese punto el argumento de tipo es conocido, así que el compilador puede sustituirlo por el tipo concreto antes de generar el código. El resultado es que dentro del cuerpo el parámetro se comporta como si fuera un tipo real, con comprobaciones y accesos a la clase incluidos.

inline fun <reified T> Iterable<*>.filtrarPorTipo(): List<T> =
    filterIsInstance<T>()

val mezcla: List<Any> = listOf(1, "a", 2, "b")
val soloTextos: List<String> = mezcla.filtrarPorTipo()

Los límites son igual de importantes que la capacidad. Un parámetro reificado no permite invocar un constructor, porque el tipo concreto puede no tener ninguno accesible y el compilador no tiene forma de garantizarlo. No puede pasarse a un parámetro de tipo no reificado, porque en esa función ya no habría sustitución. No existe desde Java, porque quien llama a una función en línea desde Java no la inserta y por tanto no hay punto de sustitución. Y no puede usarse en clases ni en propiedades, sino solo en funciones, porque solo las funciones se insertan.

inline fun <reified T> fabricar(): T =
    // T()                              // prohibido: no hay constructor garantizado
    T::class.java.getDeclaredConstructor().newInstance()   // via reflexion, sin garantia

fun <T> intermediaria(lista: List<Any>): List<T> =
    // lista.filtrarPorTipo()           // prohibido: T aqui no esta reificado
    TODO()
🔷

Lo que se borra

El argumento de tipo del objeto. Ninguna instancia sabe con qué T fue creada, y ninguna pregunta al runtime puede recuperarlo.

🗂️

Lo que permanece

Las firmas genéricas de clases, campos y métodos, guardadas en los metadatos para que el compilador siguiente pueda verificarlas.

Lo que reifica

El argumento en el punto de llamada de una función en línea, sustituido antes de generar código. No recupera nada del pasado: adelanta lo que ya se sabía.

El borrado no es un defecto de implementación, es la factura de la compatibilidad, y explica el reparto de trabajo entre el compilador y la máquina

Cuesta no leer el borrado de tipos como una chapuza, sobre todo viniendo de lenguajes que sí conservan la información en ejecución, y sin embargo la decisión fue deliberada y su alternativa tenía un coste que nadie quiso pagar. En 2004 existían millones de líneas compiladas contra colecciones sin genéricos, y cualquier representación que añadiera el argumento de tipo al objeto habría convertido el código nuevo y el viejo en mundos incompatibles: una List antigua no sería una List nueva, y la migración habría exigido recompilar el ecosistema entero de golpe. El borrado compró compatibilidad hacia atrás y hacia delante a cambio de renunciar a preguntar en ejecución, y esa renuncia es exactamente lo que hoy se paga en cada is prohibido y en cada aviso de conversión. Conviene comparar con la otra rama del árbol, porque ilumina el precio de la decisión contraria: la plataforma que conservó los genéricos en ejecución lo hizo generando código especializado para cada instanciación, lo que da comprobaciones exactas y valores primitivos sin envolver, y cuesta tiempo de compilación, tamaño del binario y una barrera dura para cargar tipos dinámicamente. No hay una respuesta correcta, hay dos puntos distintos en el mismo intercambio entre lo que sabe el compilador y lo que sabe la máquina. Lo interesante es que Kotlin, atado a una plataforma que ya eligió, encontró una tercera vía que no estaba disponible para Java: como una función en línea se copia en el punto de llamada, y en ese punto el argumento de tipo todavía existe, la información puede materializarse en el código generado sin que la máquina virtual sepa nada. reified no devuelve lo perdido, sino que impide que se pierda, adelantando la sustitución al único instante en que la información sigue estando ahí. Esa idea, la de convertir conocimiento del compilador en código concreto, es la misma que sostiene las funciones en línea, las macros de otros lenguajes y la generación de serializadores en tiempo de compilación, y conocerla cambia el criterio de diseño: cuando una API necesita saber el tipo en ejecución, la pregunta correcta no es cómo recuperarlo del objeto, sino en qué punto del programa dejó de estar disponible y si se puede capturar antes. Casi siempre se puede, y casi siempre la respuesta es una función en línea, un token grabado en una declaración o un parámetro explícito. Pedirle al runtime lo que el compilador ya sabía y tiró es empezar la conversación por el final.

⚔️ Comprueba qué queda y qué se fue
  1. Compara las clases en ejecución de una lista de cadenas y una de enteros e imprime el resultado. Después declara dos funciones que solo se diferencien en el argumento de tipo y lee el error de firma.
  2. Escribe un when sobre Any que distinga listas y mapas usando proyecciones estrella, e intenta después usar un argumento concreto para ver el mensaje del compilador.
  3. Provoca una excepción de conversión con una conversión sin comprobar y anota en qué línea aparece frente a en qué línea estaba el error real.
  4. Implementa el token de tipo con un objeto anónimo y recupera el argumento por reflexión. Explica por qué funciona ahí y no funcionaría con una instancia corriente.
  5. Escribe una función en línea con un parámetro reificado que filtre por tipo, e intenta llamarla desde otra función genérica sin reificar. Justifica el error apelando al momento de la sustitución.