wandres.dev
INLINE Y REIFIED · el coste de abstraer

reified: recuperar el tipo que la máquina borró

En la máquina virtual de Java los parámetros de tipo desaparecen durante la compilación, y por eso una función genérica corriente no puede preguntar de qué tipo es su propio parámetro ni obtener su clase. Esta lección reconstruye el motivo histórico del borrado, muestra los rodeos clásicos con tokens de clase, explica cómo la inserción del cuerpo en el punto de llamada permite sustituir el parámetro por el tipo concreto, y delimita con precisión hasta dónde llega esa recuperación y dónde el borrado sigue mandando.

⏱ 22 min

La palabra reified tiene fama de magia, y esa fama es exactamente el problema. Quien la usa sin entenderla la percibe como un permiso caprichoso que el compilador concede a algunas funciones y niega a otras, y acaba escribiendo la marca de inserción por superstición, para que le deje hacer una comprobación de tipo. La realidad es que no hay nada mágico aquí, y que la palabra clave no añade ninguna capacidad nueva a la máquina: se limita a aprovechar que, gracias a la inserción, el código de la función acaba viviendo en un punto donde el tipo concreto todavía se conoce. Entender eso convierte tres cosas aparentemente sin relación en una sola: por qué la plataforma borra los tipos, por qué medio ecosistema Java pasa objetos de clase como argumentos extra, y por qué Kotlin puede escribir una conversión segura genérica en una línea mientras Java necesita una fábrica.

🎯 Al terminar esta lección sabrás
  • Explicar qué es el borrado de tipos, por qué se adoptó y qué información sobrevive realmente en el código compilado.
  • Reconocer los errores del compilador que delatan un parámetro de tipo borrado y traducirlos a su causa.
  • Describir la sustitución que hace la inserción para que un parámetro de tipo pase a ser un tipo concreto.
  • Delimitar qué preguntas sigue sin poder responder un parámetro conservado y qué herramienta cubre el resto.

El borrado y sus consecuencias

Cuando los genéricos llegaron a la plataforma Java, la decisión de diseño dominante fue la compatibilidad: el código antiguo debía seguir funcionando y las clases genéricas debían ser las mismas clases de siempre. La solución fue borrar los argumentos de tipo durante la compilación, sustituyéndolos por su cota superior, que en ausencia de restricciones es la referencia común a todos los objetos. En tiempo de ejecución no existen dos clases distintas para una lista de textos y una lista de enteros: existe una sola clase de lista, y las conversiones necesarias las inserta el compilador en los puntos de lectura.

val textos: List<String> = listOf("a", "b")
val numeros: List<Int> = listOf(1, 2)

// En tiempo de ejecucion ambas referencias apuntan a la misma clase
println(textos.javaClass == numeros.javaClass)   // true

De ahí se derivan las dos negativas que todo programador de Kotlin encuentra tarde o temprano. Dentro de una función genérica corriente no se puede preguntar si un valor es del tipo del parámetro, porque en el código generado ese parámetro ya no existe y la comprobación no tendría contra qué comparar. Y tampoco se puede obtener la clase asociada al parámetro, por el mismo motivo. Los mensajes correspondientes hablan de comprobar una instancia de un tipo borrado y de no poder usar un parámetro de tipo como referencia de clase.

fun <T> filtrarIngenuo(entrada: List<Any>): List<T> {
    // Error: no se puede comprobar instancia de un tipo borrado
    return entrada.filter { it is T } as List<T>
}
⚠️
La conversión no comprobada es una promesa que nadie verifica

El rodeo habitual consiste en silenciar el problema con una conversión y una supresión de advertencias. Funciona en el sentido de que compila, pero traslada el fallo desde el punto donde estaba el error hasta un punto arbitrario y posterior, cuando alguien lea de la colección y reciba un tipo que no esperaba. La advertencia de conversión no comprobada no es ruido: señala exactamente el lugar donde el compilador dejó de poder garantizar el sistema de tipos y donde el programa pasó a depender de que el programador tuviera razón.

El ecosistema Java resolvió esto durante dos décadas con una convención explícita: si la función necesita conocer el tipo, se le pasa un objeto de clase como argumento adicional. Es el motivo por el que tantas interfaces de serialización, de inyección o de acceso a datos tienen firmas que reciben una clase junto con los datos. La convención funciona, pero contamina todas las llamadas y no sirve para tipos con argumentos propios, porque el objeto de clase también está borrado.

fun <T> deserializar(json: String, tipo: Class<T>): T { /* ... */ }

// Cada llamada arrastra el token, redundante con el tipo declarado
val usuario: Usuario = deserializar(texto, Usuario::class.java)

Cómo la inserción devuelve el tipo

La clave está en observar dónde sí se conoce el tipo. En el punto de llamada, el compilador sabe perfectamente que el argumento de tipo es Usuario, porque o bien está escrito o bien lo ha inferido del contexto. La información no se pierde en la llamada: se pierde en la frontera, al entrar en el cuerpo de una función que se compila una sola vez y debe servir para todos los argumentos de tipo posibles. Si esa frontera desaparece, el problema desaparece con ella.

Eso es exactamente lo que hace la inserción. Al copiar el cuerpo de la función dentro de cada punto de llamada, cada copia queda asociada a un argumento de tipo concreto, y el compilador puede sustituir el parámetro por ese tipo en todas las posiciones donde aparezca. El modificador reified es la petición de esa sustitución, y por eso solo puede aplicarse a parámetros de tipo de funciones insertables: sin copia no hay especialización posible.

inline fun <reified T> List<Any>.soloDe(): List<T> = filter { it is T }.map { it as T }

// En el punto de llamada la copia se especializa asi:
// entrada.filter { it is Usuario }.map { it as Usuario }
val usuarios: List<Usuario> = mezcla.soloDe()
flowchart LR
A[Punto de llamada: el tipo concreto se conoce] --> B[Se copia el cuerpo de la funcion]
B --> C[Cada aparicion de T se sustituye por el tipo real]
C --> D[La comprobacion is y la referencia de clase ya son validas]
E[Funcion generica sin insercion] --> F[Un solo cuerpo compilado para todos los tipos]
F --> G[T se borra a la cota superior]

El efecto sobre las firmas es inmediato y explica la diferencia estética entre las interfaces de Kotlin y las de Java. El objeto de clase deja de ser necesario como argumento, porque el tipo viaja en el argumento de tipo y se materializa dentro. La llamada pierde la redundancia y el compilador recupera la capacidad de inferir.

inline fun <reified T> deserializar(json: String): T =
    analizador.leer(json, T::class.java)

val usuario: Usuario = deserializar(texto)          // el tipo se infiere del destino
val pedidos = deserializar<List<Pedido>>(otroTexto) // o se escribe explicito

Dónde el borrado sigue mandando

La recuperación es real pero no es total, y confundir su alcance produce errores sutiles. Lo primero que hay que fijar es que el tipo sustituido es el que el compilador conocía estáticamente en el punto de llamada, no el tipo dinámico de nada. Si en ese punto el argumento de tipo era a su vez un parámetro de tipo no conservado, la sustitución no puede hacerse y el compilador lo rechaza con un mensaje que pide usar una clase en su lugar. La propiedad se propaga hacia arriba: para llamar a una función con parámetro conservado desde otra genérica, esa otra también debe conservarlo, y por tanto también debe ser insertable.

inline fun <reified T> crearLista(): List<T> = emptyList()

fun <U> intermediaria(): List<U> = crearLista()          // error: U no esta conservado

inline fun <reified U> intermediaria2(): List<U> = crearLista()   // valido
⚙️

Lo que sí se puede

Comprobar con is, convertir con as y as?, obtener la referencia de clase, crear un arreglo del tipo y llamar a otras funciones con parámetros conservados.

🚫

Lo que no se puede

Instanciar el tipo con un constructor, usarlo como receptor de miembros estáticos o pasarlo a una función que espere un parámetro conservado desde un contexto que no lo conserve.

🧬

Lo que solo se recupera a medias

Los argumentos internos de un tipo genérico. Un List<String> conservado permite comprobar que algo es una lista, pero no que sus elementos sean textos.

Esa tercera tarjeta es la trampa que hay que ver venir. Cuando el tipo conservado es a su vez genérico, la comprobación que el compilador puede generar solo alcanza a la clase externa, porque lo que hay dentro sigue borrado en tiempo de ejecución. El resultado es una comprobación que compila, no advierte de nada y devuelve verdadero para cualquier lista, sea cual sea su contenido.

inline fun <reified T> esDelTipo(valor: Any): Boolean = valor is T

esDelTipo<List<String>>(listOf(1, 2, 3))   // true: solo se comprueba que es una lista

Para las situaciones en que sí hace falta el tipo completo con sus argumentos existe una herramienta distinta, que también depende de la conservación pero produce una descripción estructurada del tipo en lugar de una comprobación. Esa descripción es lo que consumen las bibliotecas de serialización serias, y es la razón por la que pueden distinguir una lista de textos de una lista de enteros allí donde una simple referencia de clase no llega.

inline fun <reified T> descripcion(): String = typeOf<T>().toString()

descripcion<List<String>>()   // incluye el argumento interno, no solo la clase

Los patrones que hacen rentable la marca

Con los límites claros, quedan tres familias de uso donde la conservación del tipo es la forma correcta y no un adorno. La primera es el filtrado y la conversión sobre colecciones heterogéneas, que la librería estándar resuelve con una función cuya firma no recibe nada y devuelve exactamente lo que el destino pida.

val mezcla: List<Any> = listOf(1, "dos", 3.0, "cuatro")
val soloTextos: List<String> = mezcla.filterIsInstance<String>()

La segunda es la construcción de arreglos genéricos, imposible sin el tipo porque la máquina exige conocer la clase de los elementos en el momento de reservar la memoria. La tercera, y la más visible en el ecosistema, es la fachada sobre bibliotecas escritas en Java que reciben un objeto de clase: se envuelve la llamada en una función con parámetro conservado y desaparece el argumento redundante de todas las llamadas del proyecto.

inline fun <reified T> Bundle.obtener(clave: String): T? = get(clave) as? T

inline fun <reified T : Activity> Context.abrir() =
    startActivity(Intent(this, T::class.java))

Ese último ejemplo condensa el nivel entero en dos líneas. La marca de inserción está por el parámetro conservado y no por el rendimiento; el cuerpo es mínimo, así que la duplicación es despreciable; el tipo se materializa como referencia de clase en cada punto de llamada; y la firma resultante comunica la intención mejor que cualquier comentario. Es la forma canónica que conviene reconocer al leer código ajeno y reproducir al escribir el propio.

Conservar un tipo no derrota al borrado: lo esquiva moviendo la pregunta al único sitio donde tiene respuesta

Conviene enunciar con precisión lo que este mecanismo es y lo que no es, porque la formulación popular lo describe mal y esa descripción produce expectativas que el lenguaje no puede cumplir. Kotlin no ha añadido genéricos conservados a la máquina virtual, no genera una clase por cada argumento de tipo y no ha derogado el borrado; sobre esa plataforma sigue habiendo una sola clase de lista y sigue sin haber ninguna forma de preguntarle a un objeto cuáles eran sus argumentos de tipo. Lo que hace es infinitamente más modesto y bastante más elegante: observa que la información que falta dentro del cuerpo de la función sí está disponible en el punto de llamada, y usa la inserción para llevar el cuerpo hasta donde la información está, en vez de intentar llevar la información hasta el cuerpo. Es una inversión de la dirección del problema, y de ella se derivan todas sus propiedades, incluidas las incómodas. Explica por qué la marca de inserción es obligatoria, porque sin copia no hay punto de llamada donde especializar. Explica por qué la propiedad se contagia hacia arriba en la cadena de llamadas, porque cada eslabón necesita a su vez conocer el tipo estáticamente. Explica por qué no se puede invocar un constructor, porque saber qué tipo es no equivale a tener acceso a cómo se construye. Y explica por qué lo interno de un tipo genérico sigue perdido, porque la sustitución produce código que la máquina tiene que poder ejecutar, y la máquina sigue sin saber nada de argumentos de tipo. Quien interioriza que aquí no hay más que una sustitución textual dirigida por el compilador deja de sorprenderse con las limitaciones y empieza a predecirlas, que es la diferencia entre usar la palabra clave y entender por qué existe.

⚔️ Recupera el tipo y encuentra sus límites
  1. Escribe una función genérica corriente que intente comprobar el tipo de su parámetro, lee el error y explícalo en términos de cuántos cuerpos se compilan.
  2. Convierte esa función en insertable con el parámetro conservado y describe qué queda escrito en cada punto de llamada.
  3. Sustituye una firma que reciba un objeto de clase por otra con parámetro conservado, y razona qué gana y qué pierde quien la consume.
  4. Comprueba con un parámetro conservado genérico si un valor es una lista de textos, observa el resultado y explica por qué no es el esperado.
  5. Intenta llamar a una función con parámetro conservado desde otra función genérica sin conservar. Traduce el error y encuentra las dos formas de arreglarlo.