W3docs

Java Type Erasure

Come Java implementa i generics tramite type erasure, cosa viene cancellato a runtime e le relative conseguenze.

La type erasure è il modo in cui Java implementa i generics: i parametri di tipo esistono mentre il compilatore è in esecuzione, dopodiché vengono eliminati prima che il bytecode venga scritto. Una List<String> e una List<Integer> arrivano alla JVM come semplice List — intercambiabili a runtime. Il sistema di tipi in fase di compilazione garantisce la sicurezza; a runtime esiste la stessa List che il linguaggio aveva nel 1995. Questa scelta progettuale è il fatto più importante sui generics di Java e spiega ogni strana restrizione che incontrerai nel prossimo capitolo.

Questo capitolo tratta cosa sostituisce la type erasure ai parametri di tipo, perché Java l'ha scelta al posto dei generics reificati, le conseguenze a runtime (getClass, instanceof, new T()), i bridge method generati dal compilatore che mantengono funzionante l'override, e perché la type erasure blocca determinati overload. Se sei alle prime armi con i generics, inizia prima da Java Generics.

Perché Java ha scelto questo approccio

Quando i generics furono aggiunti in Java 5, la libreria standard aveva già un decennio di vita. Ogni List, Map e Comparator esistente era non-generico, e ogni programma esistente nel mondo li usava come tipi raw. Il requisito imprescindibile di Sun era la compatibilità binaria retroattiva: il codice compilato con la libreria standard pre-5 doveva continuare a funzionare con quella nuova senza ricompilazione.

Sul tavolo c'erano due approcci:

  1. Generics reificati — mantenere le informazioni di tipo a runtime, come ha fatto successivamente C#. Più veloci, più espressivi, ma richiedono la rielaborazione di ogni class file esistente nel mondo.
  2. Generics con type erasure — rimuovere le informazioni di tipo in fase di compilazione, lasciando invariata la struttura del bytecode. Più lenti per chiamata (cast aggiuntivi), meno espressivi (nessun new T()), ma ogni vecchio JAR continua a funzionare senza modifiche.

Sun scelse la type erasure. Il prezzo pragmatico dell'aggiornamento fu pagato in flessibilità linguistica a lungo termine. Non è il design che qualcuno sceglierebbe partendo da zero — ma è il design che Java ha, e capirlo fa convergere tutto il resto sui generics.

Cosa fa concretamente la type erasure

Quando il compilatore incontra un tipo generico, esegue due operazioni:

  1. Cancella ogni parametro di tipo sostituendolo con il suo bound più a sinistra — oppure con Object se non è presente alcun bound.
  2. Inserisce cast in ogni punto in cui un valore generico viene letto, in modo che i valori a runtime finiscano negli slot corretti.

Considera questa classe generica:

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; }
}

Dopo la type erasure, il bytecode appare pressappoco così (in equivalente sorgente 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 è sparita. È diventata Number perché era il bound. Se non ci fosse stato alcun bound, sarebbe diventata Object.

E nel punto di chiamata:

Box<Integer> b = new Box<>(42);
int x = b.get();           // source

diventa (dopo la type erasure):

Box b = new Box(42);
int x = (Integer) b.get();   // compiler inserted the cast

Il cast era invisibile nel sorgente. Il compilatore lo aggiunge perché conosce il tipo a livello di sorgente, ovvero Integer, anche se il tipo a livello di bytecode è Number (o Object).

Cosa significa a runtime

Dalla type erasure derivano direttamente diverse conseguenze, che sono la fonte di ogni momento "ma pensavo di poter…" che uno sviluppatore sperimenta con i generics di Java:

Box<Integer> a = new Box<>(1);
Box<Double>  b = new Box<>(1.0);

a.getClass() == b.getClass();   // true — both are Box.class

A runtime non esiste Box<Integer>.classBox<Double>.class — esiste solo Box.class. Le due istanze sono la stessa classe, perché la JVM letteralmente non riesce a distinguerle.

Non puoi chiedere "è questo 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 works

Non puoi scrivere new T():

public class Factory<T> {
  public T create() { return new T(); }    // ❌ — no T at runtime
}

Non puoi catturare un tipo di eccezione generico:

try { ... }
catch (MyException<String> e) { ... }      // ❌

Tutti questi errori di compilazione risalgono allo stesso fatto: la JVM non dispone del parametro di tipo nel momento in cui quel codice dovrebbe essere eseguito. Il compilatore si rifiuta di emettere codice che sa non potrà avere successo.

Nota
La soluzione alternativa standard per "ho bisogno di creare un T" è passare il tipo esplicitamente — di solito tramite un parametro Class<T> e clazz.getDeclaredConstructor().newInstance(), oppure una factory Supplier<T>. Le informazioni che la type erasure ha rimosso devono essere fornite dal chiamante; il compilatore non può ricostruirle.

Bridge method — la contabilità nascosta della type erasure

C'è una sottigliezza relativa all'interazione tra la type erasure e l'override. Supponi di avere:

interface Container<T> {
  void put(T value);
}

class IntContainer implements Container<Integer> {
  public void put(Integer value) { ... }
}

A livello di sorgente, IntContainer.put(Integer) fa override di Container.put(T). Ma dopo la type erasure, il metodo dell'interfaccia ha firma put(Object) — e IntContainer ha solo put(Integer). Come funziona ancora il polimorfismo quando qualcuno chiama put tramite un riferimento a Container?

Il compilatore genera un bridge method in IntContainer:

// Generated by the compiler, invisible in source:
public void put(Object value) {
  put((Integer) value);   // delegate to the real one
}

Quel bridge method è ciò che viene chiamato quando il dispatch polimorfico atterra sulla firma cancellata. Non lo scrivi, non lo vedi, ma javap te lo mostrerà. È la colla che fa funzionare la type erasure con il dispatch virtuale.

Type erasure e overloading

Una conseguenza diretta della type erasure: non puoi sovraccaricare due metodi se le loro firme differiscono solo nei parametri generici, perché dopo la type erasure hanno la stessa firma:

public void process(List<String> list)  { ... }
public void process(List<Integer> list) { ... }
// ❌ both erase to process(List) — compile error

È lo stesso problema latente con gli override. Se due metodi si cancellerebbero nella stessa firma, il compilatore si rifiuta di compilarli. Non esiste una soluzione alternativa a livello di linguaggio — sarebbe necessario usare nomi di metodo diversi, oppure un parametro che sia effettivamente diverso dopo la cancellazione.

Un esempio completo: la type erasure in azione

Il programma dimostra le cose che derivano dalla type erasure — uguaglianza a runtime di getClass, un instanceof in forma raw funzionante, e il cast non verificato che il bytecode esegue per te ad ogni lettura.

java— editable, runs on the server

Le righe con getClass() confermano che il runtime non riesce a distinguere Box<Integer> da Box<String> — esiste una sola classe Box. Il trucco con la List in forma raw è la storia classica della type erasure: il add(99) errato supera il controllo del tipo, e il fallimento emerge alla prossima lettura perché è lì che vive il cast inserito dal compilatore. Il messaggio dell'eccezione indica persino qual era il cast: ha tentato di convertire Integer in String.

Cosa fare dopo

La type erasure non è un dettaglio accademico — è la ragione dietro quasi ogni "non puoi farlo" che il compilatore ti lancerà quando tenti di scrivere codice generico elaborato. L'ultimo capitolo di questa parte cataloga l'elenco completo di tali restrizioni e ne spiega ciascuna in termini di ciò che la type erasure impedisce. Continua con Java Generics Restrictions.

Esercitazione

Pratica
Perché Java rifiuta `public T first() { return new T(); }` all'interno di una classe generica `Foo<T>`?
Perché Java rifiuta `public T first() { return new T(); }` all'interno di una classe generica `Foo<T>`?
Was this page helpful?