Skip to main content

Type Erasure

⏱ Dedicación recomendada: 0 minutos
Esto considera el contenido visible y relevante, e ignora texto colapsado o marcado como opcional.


r8vnhill/generics-kt

El uso de genéricos permite que nuestras funciones y estructuras de datos sean más reutilizables, expresivas y seguras. Podemos escribir código que funciona para múltiples tipos sin duplicación, y el compilador nos ayuda a prevenir errores.

Sin embargo, cuando usamos Kotlin, que se ejecuta sobre la JVM, nos enfrentamos a una limitación heredada de Java: el type erasure. Esta característica implica que, en tiempo de ejecución, una instancia de una clase genérica no conserva información sobre los argumentos de tipo utilizados para crearla. Como resultado, se pierde la capacidad de inspeccionar o razonar sobre esos tipos en tiempo de ejecución, lo que puede derivar en comportamientos inesperados, advertencias del compilador y restricciones al utilizar reflexión o lógica basada en tipos.

En esta lección aprenderás:

  • Por qué la JVM borra los tipos genéricos.
  • Cómo esto afecta al código Kotlin.
  • Qué soluciones ofrece Kotlin, como las funciones inline con tipos reified, para sortear estas limitaciones.

Al comprender el type erasure, estarás mejor preparadx para escribir funciones genéricas más seguras y potentes, especialmente al construir bibliotecas que aprovechen las ventajas del sistema de tipos de Kotlin sin caer en sus trampas ocultas.

🏛️ Origen del Type Erasure

El type erasure surge con la incorporación de los genéricos en Java 5 (2004), cuyo objetivo era permitir un código más seguro y reutilizable. Antes de su introducción, las colecciones y otros tipos no especificaban qué tipo de elementos contenían, lo que obligaba a realizar casts manuales y propensos a errores como el clásico ClassCastException.

Para mantener la compatibilidad hacia atrás, Java implementó los genéricos como una característica del compilador, no de la JVM. Esto significa que la información de tipo se utiliza durante la compilación para realizar chequeos de tipo y luego se descarta: en tiempo de ejecución, la JVM no conoce los argumentos de tipo usados. El bytecode generado es prácticamente el mismo que el del código sin genéricos.

Como resultado, el type erasure quedó incorporado en el diseño de la JVM, y todos los lenguajes que se ejecutan sobre ella —incluido Kotlin— heredan esta restricción.

Uso de memoria

El borrado de tipos también tiene un beneficio práctico: reduce el uso de memoria, ya que no se guarda información de tipos genéricos en tiempo de ejecución.

💡 Ejemplo en Java

Antes de la introducción de generics, una lista de enteros en Java se escribía así:

List numbers = new ArrayList();
numbers.add(1);
// Usamos instanceof para asegurarnos de que el cast sea seguro
if (numbers.get(0) instanceof Integer) {
Integer num = (Integer) numbers.get(0);
System.out.println(num);
}

El uso de generics en Java 5 mejoró la seguridad del tipo en tiempo de compilación:

List<Integer> numbers = new ArrayList<>();
numbers.add(1);
Integer num = numbers.get(0); // No requiere cast explícito
System.out.println(num);

A pesar de esta mejora en seguridad, el type erasure implica que, en tiempo de ejecución, el tipo List<Integer> es borrado y tratado simplemente como List, lo que impide conocer el tipo Integer durante la ejecución. La JVM solo ve una List genérica, sin detalles sobre el tipo exacto de sus elementos.

🧪 Ejemplo de Type Erasure

Considera el siguiente código en Kotlin:

val list: List<String> = listOf("Kotlin", "Java")

En tiempo de compilación, el compilador sabe que list es de tipo List<String>. Sin embargo, en tiempo de ejecución, la JVM solo sabe que es una List, sin información sobre el tipo de sus elementos.

Esto puede causar problemas si intentamos acceder al tipo genérico en tiempo de ejecución:

fun <T> printType(item: T) =
println(item::class)

Si llamamos a printType("Hello"), la función podrá imprimir el tipo String. Sin embargo, si intentamos obtener el tipo genérico T en sí, nos encontraremos con limitaciones debido al type erasure.

⚠️ Limitaciones debido al Type Erasure

Una consecuencia del type erasure es que no podemos, por ejemplo, comprobar el tipo genérico en tiempo de ejecución:

fun <T> checkType(list: List<T>) {
if (list is List<String>) {
println("Es una lista de Strings")
}
}

El código anterior dará un warning en tiempo de compilación: "Unchecked cast: List<T> to List<String>". Esto se debe a que en tiempo de ejecución, la JVM no puede saber si list es una List<String> o cualquier otra lista genérica.

ℹ️ ¿Cómo funcionan los chequeos de tipo con genéricos en Kotlin?

En Kotlin, no puedes usar un tipo genérico sin especificar sus argumentos. Pero si lo que quieres es comprobar si un valor es una lista (independientemente del tipo de sus elementos), puedes usar la sintaxis especial de proyección estrella:

if (value is List<*>) { ... }

Esto permite verificar si un valor es una instancia de List, sin conocer el tipo de sus elementos (equivalente a List<?> en Java).

Ahora bien, los castings con tipos genéricos aún son posibles, como en:

val intList = c as? List<Int>

Pero debido al type erasure, el tipo de los elementos no puede ser verificado en tiempo de ejecución, por lo que el compilador emite una advertencia de "unchecked cast". Aun así, el código compila y puede ejecutarse sin errores... hasta que ocurre un error en tiempo de ejecución.

Ejemplo:

fun printSum(c: Collection<*>) {
val intList = c as? List<Int>
?: throw IllegalArgumentException("List is expected")
println(intList.sum())
}

printSum(listOf(1, 2, 3)) // ✅ Funciona
printSum(setOf(1, 2, 3)) // ❌ Lanza IllegalArgumentException
printSum(listOf("a", "b", "c")) // 💥 Lanza ClassCastException en tiempo de ejecución
  • El primer caso funciona como se espera.
  • El segundo lanza una IllegalArgumentException porque el valor no es una List.
  • El tercero pasa el cast, pero falla al ejecutar .sum() porque intenta usar un String como Number, lo que lanza una ClassCastException.

🔧 Funciones Inline y Reificación de Tipos

Kotlin ofrece una forma de superar algunas de las limitaciones del type erasure mediante el uso de funciones inline y reificación de tipos (type reification).

⚡ Funciones Inline

Las funciones inline en Kotlin son funciones cuyo cuerpo se inserta (o "se inyecta") en el lugar donde se llama a la función durante la compilación. Esto puede mejorar el rendimiento al eliminar la sobrecarga de llamadas a funciones, pero también tiene otros usos.

🧬 Reificación de Tipos

La reificación de tipos permite que los tipos genéricos estén disponibles en tiempo de ejecución en funciones inline. Para usarla, debemos marcar el parámetro de tipo con la palabra clave reified.

📝 Sintaxis de una Función Inline con Tipo Reificado

inline fun <reified T> myFunction() {
// Aquí podemos acceder al tipo T en tiempo de ejecución
}

⚙️ ¿Cómo funciona y qué considerar?

Cuando marcas una función como inline y usas un parámetro de tipo reified, el compilador inserta el código de la función directamente en cada lugar donde se llama, y reemplaza el tipo genérico T por el tipo concreto utilizado. Esto permite acceder al tipo en tiempo de ejecución, algo normalmente imposible en la JVM debido al type erasure.

Sin embargo, esta técnica tiene un costo: si la función es muy grande, el hecho de que su código se copie en cada llamada puede generar un binario más pesado y afectar el rendimiento. Para evitar esto, es buena práctica extraer el código que no depende del tipo reificado a funciones auxiliares que no sean inline. Así se mantiene el beneficio del acceso al tipo sin comprometer el tamaño o eficiencia del programa.

🧰 Ejemplo Práctico: Filtrar una Lista por Tipo

Supongamos que tenemos una lista heterogénea y queremos filtrar los elementos de un cierto tipo.

🚫 Sin Type Reification

Intentemos implementar una función que filtre elementos de un cierto tipo:

fun <T> filterByType(list: List<Any>): List<T> {
for (it in list) {
if (it is T) {
// Error: Cannot check for instance of erased type: T
}
}
}

Este código no compilará, ya que el type erasure impide que podamos comprobar si it is T.

✅ Con Type Reification

Utilizando una función inline con tipo reificado, podemos lograr nuestro objetivo:

inline fun <reified T> filterByType(list: List<Any>): List<T> {
val result = mutableListOf<T>()
for (it in list) {
if (it is T) {
result += it
}
}
}

Ahora podemos usar la función:

val mixedList: List<Any> = listOf(1, "Kotlin", 2.5, "Java")
val strings: List<String> = filterByType(mixedList)
println(strings) // Output: [Kotlin, Java]

🧪 Ejemplo Avanzado: Crear Instancias de Tipos Genéricos

Otro uso de la reificación de tipos es crear instancias de tipos genéricos:

inline fun <reified T: Any> createInstance() =
T::class.java.getDeclaredConstructor().newInstance()

Ahora podemos crear instancias de cualquier tipo que tenga un constructor sin argumentos:

class MyClass {
init {
println("MyClass creada")
}
}

fun main() {
val instance: MyClass = createInstance()
// Output: MyClass creada
}

🎯 Conclusiones

El type erasure es una consecuencia histórica del diseño de los genéricos en la JVM. Aunque permitió la compatibilidad hacia atrás en Java, también introdujo limitaciones reales para quienes desarrollamos con lenguajes que se ejecutan sobre ella, como Kotlin.

En esta lección aprendiste que:

  • El type erasure elimina la información sobre los tipos genéricos en tiempo de ejecución, lo que restringe el uso de reflexión, validaciones de tipo y creación dinámica de instancias genéricas.
  • Kotlin hereda estas restricciones de Java, pero ofrece mecanismos que permiten sortearlas en contextos específicos.
  • Las funciones inline con tipos reified constituyen una solución eficaz que restaura parcialmente la capacidad de acceder a los tipos en tiempo de ejecución.

Estas herramientas no solo permiten resolver casos concretos —como filtrar listas por tipo o crear instancias de clases genéricas—, sino que abren la puerta a diseñar bibliotecas más expresivas, seguras y componibles.

🔑 Puntos clave

  • Type erasure borra los tipos genéricos en tiempo de ejecución, lo que impide su inspección directa o validación dinámica.
  • No es posible hacer comprobaciones como is T ni crear instancias de T sin técnicas adicionales.
  • Las funciones inline con tipos reified permiten recuperar información de tipo en tiempo de ejecución dentro de funciones genéricas.
  • Kotlin amplía las capacidades de la JVM, haciendo el trabajo con genéricos más flexible que en Java.

🧰 ¿Qué nos llevamos?

Más allá de las limitaciones técnicas, comprender el type erasure nos permite escribir código genérico más consciente, sólido y predecible. En lugar de frustrarnos por lo que la JVM no nos permite hacer, podemos diseñar funciones y APIs que operen con claridad dentro de sus reglas, e incluso las superen cuando el lenguaje lo permite.

La reificación de tipos en Kotlin no es solo una herramienta técnica: es una invitación a pensar en cómo usamos los genéricos, en qué contextos necesitamos la información de tipo, y cómo estructurar nuestro código para aprovechar lo mejor de ambos mundos —la eficiencia del type erasure y la expresividad del tipo reificado—.

En resumen, entender las limitaciones nos hace mejores diseñadores de bibliotecas: nos obliga a pensar, a justificar nuestras elecciones y a buscar soluciones idiomáticas que mantengan el equilibrio entre rendimiento, expresividad y robustez.

📖 Referencias

🔥 Recomendadas

  • 📚 Generics. (2017). En Dmitry Jemerov & Svetlana Isakova, Kotlin in action (pp. 223–253). Manning Publications Co.

🔹 Adicionales

  • 📚 Generics. (2018). En Joshua Bloch, Effective Java (Third edition, pp. 117–155). Addison-Wesley.