Borrado de tipos en Java
Cómo Java implementa los generics mediante el borrado de tipos, qué se elimina en tiempo de ejecución y sus consecuencias.
El borrado de tipos es la forma en que Java implementa los generics: los parámetros de tipo existen mientras el compilador está en ejecución y luego son descartados antes de que se genere el bytecode. Un List<String> y un List<Integer> llegan a la JVM como un simple List — intercambiables en tiempo de ejecución. El sistema de tipos en tiempo de compilación garantiza la seguridad; en tiempo de ejecución es el mismo List que tenía el lenguaje en 1995. Esta decisión de diseño es el hecho más importante sobre los generics de Java, y explica cada restricción extraña que encontrarás en el próximo capítulo.
Este capítulo cubre con qué reemplaza el borrado a los parámetros de tipo, por qué Java lo eligió frente a los generics reificados, las consecuencias en tiempo de ejecución (getClass, instanceof, new T()), los métodos puente generados por el compilador que mantienen el funcionamiento de la sobreescritura, y por qué el borrado bloquea ciertas sobrecargas. Si eres nuevo en los generics, comienza con Java Generics primero.
Por qué Java lo hizo así
Cuando se añadieron los generics en Java 5, la biblioteca estándar ya tenía una década de antigüedad. Cada List, Map y Comparator existente no era genérico, y todos los programas del mundo los usaban como tipos crudos. El requisito estricto de Sun era la compatibilidad binaria hacia atrás: el código compilado contra la biblioteca estándar anterior a la versión 5 tenía que seguir funcionando en la nueva sin recompilación.
Había dos diseños sobre la mesa:
- Generics reificados — mantener la información de tipo en tiempo de ejecución, como hizo C# eventualmente. Más rápidos, más expresivos, pero requieren que todos los archivos de clase existentes en el mundo sean re-emitidos.
- Generics con borrado — eliminar la información de tipo en tiempo de compilación, dejar la forma del bytecode sin cambios. Más lentos por llamada (casts adicionales), menos expresivos (sin
new T()), pero todos los JAR antiguos siguen funcionando sin modificaciones.
Sun eligió el borrado. El precio pragmático por la actualización se pagó en flexibilidad del lenguaje a largo plazo. No es el diseño que nadie elegiría partiendo de cero — pero es el diseño que tiene Java, y entenderlo hace que todo lo demás sobre los generics encaje.
Lo que hace realmente el borrado
Cuando el compilador ve un tipo genérico, hace dos cosas:
- Borra cada parámetro de tipo a su límite más a la izquierda — o a
Objectsi no hay límite. - Inserta casts en cada lugar donde se lee un valor genérico, para que los valores en tiempo de ejecución lleguen a los slots correctos.
Considera esta clase genérica:
public class Box<T extends Number> {
private T value;
public Box(T value) { this.value = value; }
public T get() { return value; }
public void set(T value) { this.value = value; }
}Después del borrado, el bytecode se parece aproximadamente a esto (en equivalente de código fuente Java):
public class Box {
private Number value;
public Box(Number value) { this.value = value; }
public Number get() { return value; }
public void set(Number value) { this.value = value; }
}La T desaparece. Se convirtió en Number porque ese era el límite. Si no hubiera habido límite, se habría convertido en Object.
Y en el sitio de llamada:
Box<Integer> b = new Box<>(42);
int x = b.get(); // sourcese convierte (después del borrado) en:
Box b = new Box(42);
int x = (Integer) b.get(); // compiler inserted the castEl cast era invisible en el código fuente. El compilador lo añade porque sabe que el tipo a nivel de fuente era Integer, aunque el tipo a nivel de bytecode sea Number (u Object).
Lo que eso significa en tiempo de ejecución
Varias consecuencias se derivan directamente del borrado, y son la fuente de cada momento de "pero creía que podía…" que tiene un desarrollador con los generics de Java:
Box<Integer> a = new Box<>(1);
Box<Double> b = new Box<>(1.0);
a.getClass() == b.getClass(); // true — both are Box.classNo existe Box<Integer>.class ni Box<Double>.class en tiempo de ejecución — solo existe Box.class. Las dos instancias son la misma clase, porque la JVM literalmente no puede distinguirlas.
No puedes preguntar "¿es esto un instanceof Box<Integer>":
if (obj instanceof Box<Integer>) { ... } // ❌ does not compile
if (obj instanceof Box<?>) { ... } // ✓ — wildcard is allowed
if (obj instanceof Box) { ... } // ✓ — raw form worksNo puedes escribir new T():
public class Factory<T> {
public T create() { return new T(); } // ❌ — no T at runtime
}No puedes capturar un tipo de excepción genérico:
try { ... }
catch (MyException<String> e) { ... } // ❌Todos estos errores de compilación se remontan al mismo hecho: la JVM no tiene el parámetro de tipo en el momento en que ese código necesitaría ejecutarse. El compilador se niega a emitir código que sabe que no puede tener éxito.
T" es pasar el tipo explícitamente — generalmente como un parámetro Class<T> y clazz.getDeclaredConstructor().newInstance(), o una fábrica Supplier<T>. La información que el borrado eliminó debe ser proporcionada por quien hace la llamada; el compilador no puede reconstruirla.Métodos puente — la contabilidad oculta del borrado
Hay una sutileza donde el borrado interactúa con la sobreescritura. Supón que tienes:
interface Container<T> {
void put(T value);
}
class IntContainer implements Container<Integer> {
public void put(Integer value) { ... }
}A nivel de fuente, IntContainer.put(Integer) sobreescribe Container.put(T). Pero después del borrado, el método de la interfaz tiene la firma put(Object) — y IntContainer solo tiene put(Integer). ¿Cómo sigue funcionando el polimorfismo cuando alguien llama a put a través de una referencia Container?
El compilador genera un método puente en IntContainer:
// Generated by the compiler, invisible in source:
public void put(Object value) {
put((Integer) value); // delegate to the real one
}Ese método puente es el que se llama cuando el despacho polimórfico cae en la firma borrada. Tú no lo escribes, no lo ves, pero javap te lo mostrará. Es el pegamento que hace que el borrado funcione con el despacho virtual.
Borrado y sobrecarga
Una consecuencia directa del borrado: no puedes sobrecargar dos métodos si sus firmas difieren únicamente en sus parámetros genéricos, porque después del borrado tienen la misma firma:
public void process(List<String> list) { ... }
public void process(List<Integer> list) { ... }
// ❌ both erase to process(List) — compile errorEste es el mismo problema latente con las sobreescrituras. Si dos métodos se borrarían a la misma firma, el compilador se niega a compilarlos. No hay solución alternativa a nivel del lenguaje — necesitarías nombres de método diferentes, o un parámetro que sea realmente diferente después del borrado.
Un ejemplo práctico: el borrado en acción
El programa demuestra las cosas que resultan del borrado — igualdad en tiempo de ejecución de getClass, un instanceof en forma cruda funcional, y el cast sin verificación que el bytecode está realizando por ti en cada lectura.
Las líneas de getClass() confirman que el tiempo de ejecución no puede distinguir Box<Integer> de Box<String> — solo hay una clase Box. El truco del List en forma cruda es la historia clásica del borrado: el add(99) incorrecto pasa la verificación de tipo, y el fallo aparece en la siguiente lectura porque ahí es donde vive el cast insertado por el compilador. El mensaje de la excepción incluso te dice cuál fue el cast: intentó convertir Integer a String.
Qué sigue
El borrado no es un detalle académico — es la razón detrás de casi todos los "no puedes hacer eso" que el compilador te lanzará cuando intentes escribir código genérico complejo. El último capítulo de esta parte cataloga la lista completa de esas restricciones y explica cada una en términos de lo que el borrado impide. Continúa en Restricciones de los Generics de Java.