18. Generics in Java
Indice
- 18.1 Basi dei Tipi Generici
- 18.2 Perché Esistono i Generics
- 18.3 Metodi Generici
- 18.4 Type Erasure
- 18.4.1 Come Funziona la Type Erasure
- 18.4.2 Erasure dei Parametri di Tipo Senza Bound
- 18.4.3 Erasure dei Parametri di Tipo con Bound
- 18.4.4 Bound Multipli: Il Primo Bound Determina l’Erasure
- 18.4.5 Perché Solo il Primo Bound Diventa il Tipo a Runtime
- 18.4.6 Un Esempio Più Complesso
- 18.4.7 Override e Generics
- 18.4.7.1 Come il compilatore valida un override
- 18.4.7.2 Parametri generici e override
- 18.4.7.3 Override valido — Eliminazione della specificità generica
- 18.4.7.4 Override non valido — Aggiunta di specificità generica
- 18.4.7.5 Override valido — Parametrizzazione identica
- 18.4.7.6 Override non valido — Modifica dell’argomento generico
- 18.4.7.7 Perché esiste questa regola
- 18.4.7.8 Modello mentale
- 18.4.7.9 Ritorni covarianti e Generics
- 18.4.7.10 Regole riassuntive
- 18.4.8 Overloading di un Metodo Generico — Perché Alcuni Overload Sono Impossibili
- 18.4.9 Overloading di un Metodo Generico Ereditato da una Classe Parent
- 18.4.10 Restituire Tipi Generici — Regole e Restrizioni
- 18.4.11 Riepilogo delle Regole di Erasure
- 18.5 Bound sui Parametri di Tipo
- 18.6 Generics ed Ereditarietà
- 18.7 Type Inference (Operatore Diamond)
- 18.8 Raw Types (Compatibilità Legacy)
- 18.9 Array Generici (Non Permessi)
- 18.10 Bounded Type Inference
- 18.11 Wildcard vs Parametri di Tipo
- 18.12 Regola PECS (Producer Extends, Consumer Super)
- 18.13 Errori Comuni
- 18.14 Tabella Riassuntiva delle Wildcard
- 18.15 Riepilogo dei Concetti
- 18.16 Esempio Completo
Java Generics permettono di creare classi, interfacce e metodi che lavorano con tipi specificati dall’utente, garantendo che vengano usati solo oggetti del tipo corretto.
Tutti i controlli di tipo vengono eseguiti dal compilatore a compile-time.
Durante la compilazione, il compilatore verifica i tipi e poi rimuove le informazioni generiche (processo identificato come type erasure), sostituendole con i tipi reali o con Object quando necessario.
Il bytecode risultante non contiene generics: contiene solo i tipi concreti e, se serve, cast inseriti automaticamente dal compilatore.
In questo modo, gli errori di tipo vengono intercettati prima dell’esecuzione, rendendo il codice più sicuro, leggibile e riutilizzabile.
I Generics si applicano a:
ClassiInterfacceMetodi(metodi generici)Costruttori
18.1 Basi dei Tipi Generici
Una classe o interfaccia generica introduce uno o più parametri di tipo, racchiusi tra parentesi angolari.
class Box<T> {
private T value;
void set(T v) { value = v; }
T get() { return value; }
}
Box<String> b = new Box<>();
b.set("hello");
String x = b.get(); // nessun cast necessario
Sono permessi più parametri di tipo:
class Pair<K, V> {
K key;
V value;
}
18.2 Perché Esistono i Generics
List list = new ArrayList(); // pre-generics
list.add("hi");
Integer x = (Integer) list.get(0); // ClassCastException a runtime
Con i generics:
List<String> list = new ArrayList<>();
list.add("hi");
String x = list.get(0); // type-safe, nessun cast
18.3 Metodi Generici
Un metodo generico introduce i propri parametri di tipo, indipendenti dalla classe.
class Util {
static <T> T pick(T a, T b) { return a; }
}
String s = Util.<String>pick("A", "B"); // esplicito
String t = Util.pick("A", "B"); // l’inferenza funziona
18.4 Type Erasure
La Type erasure è il processo attraverso cui il compilatore Java rimuove tutte le informazioni sui tipi generici prima di generare il bytecode.
Questo garantisce compatibilità con le JVM precedenti a Java 5.
A compile time, i generics sono completamente controllati: bound sui tipi, varianza, overloading di metodi generici, ecc.
Tuttavia, a runtime, tutte le informazioni generiche scompaiono.
18.4.1 Come Funziona la Type Erasure
- Sostituire tutte le variabili di tipo (come
T) con il loro tipo erasure. - Inserire cast dove necessario.
- Rimuovere tutti gli argomenti di tipo generico (es.
List<String>→List).
18.4.2 Erasure dei Parametri di Tipo Senza Bound
Se una variabile di tipo non ha bound:
class Box<T> {
T value;
T get() { return value; }
}
L’erasure di T è Object.
class Box {
Object value;
Object get() { return value; }
}
18.4.3 Erasure dei Parametri di Tipo con Bound
Se il parametro di tipo ha bound:
class TaskRunner<T extends Runnable> {
void run(T task) { task.run(); }
}
Allora l’erasure di T è il primo bound trovato dal compilatore: in questo specifico caso Runnable.
class TaskRunner {
void run(Runnable task) { task.run(); }
}
18.4.4 Bound Multipli: Il Primo Bound Determina l’Erasure
Java permette bound multipli:
<T extends Runnable & Serializable & Cloneable>
Important
L’erasure di T è sempre il primo bound, che deve essere una classe o interfaccia.
Poiché Runnable è il primo bound, il compilatore effettua l’erasure di T a Runnable.
- Esempio con Bound Multipli (Completamente Espanso)
public static <T extends Runnable & Serializable & Cloneable>
void runAll(List<T> list) {
for (T t : list) {
t.run();
}
}
Versione con Erasure:
public static void runAll(List list) {
for (Object obj : list) {
Runnable t = (Runnable) obj; // cast inserito dal compilatore
t.run();
}
}
Cosa succede agli altri bound (Serializable, Cloneable)?
- Sono applicati solo a compile time.
- NON compaiono nel bytecode.
- Nessuna interfaccia aggiuntiva viene associata al tipo con erasure.
18.4.5 Perché Solo il Primo Bound Diventa il Tipo a Runtime?
Perché la JVM deve operare usando un singolo tipo di riferimento concreto per ogni variabile o parametro.
Le istruzioni bytecode a runtime come invokevirtual richiedono una singola classe o interfaccia, non un tipo composto come “Runnable & Serializable & Cloneable”.
Note
Java seleziona il primo bound come tipo a runtime, e usa i bound restanti solo per la validazione a compile-time.
18.4.6 Un Esempio Più Complesso
interface A { void a(); }
interface B { void b(); }
class C implements A, B {
public void a() {}
public void b() {}
}
class Demo<T extends A & B> {
void test(T value) {
value.a();
value.b();
}
}
Versione con Erasure:
class Demo {
void test(A value) {
value.a();
// value.b(); // ❌ non disponibile dopo l’erasure: il tipo è A, non B
}
}
Note
Il compilatore può inserire cast aggiuntivi o metodi bridge in scenari di ereditarietà più complessi, ma l’erasure usa sempre solo il primo bound (A in questo caso).
18.4.7 Override e Generics
Quando i generics interagiscono con l’ereditarietà, è fondamentale comprendere chiaramente due regole:
Important
L’override viene verificato dopo la type erasure.
La compatibilità dei tipi viene verificata prima della type erasure.
Questi due passaggi spiegano perché alcuni metodi effettuano correttamente l’override mentre altri producono errori di compilazione.
18.4.7.1 Come il compilatore valida un override
Quando una sottoclasse dichiara un metodo che potrebbe effettuare l’override di un metodo della superclasse, il compilatore esegue due controlli:
-
Prima della erasure
-
Il metodo deve essere compatibile a livello di tipo con quello della classe padre:
- Stesso nome del metodo
- Stessi tipi dei parametri (inclusi gli argomenti generici)
- Tipo di ritorno compatibile (covarianza ammessa)
-
-
Dopo la erasure
-
Le firme erase devono coincidere esattamente.
- Entrambe le condizioni devono essere soddisfatte.
-
18.4.7.2 Parametri generici e override
Gli argomenti di tipo generico fanno parte della firma del metodo a compile-time, ma scompaiono dopo la erasure.
Per questo motivo:
- È consentito eliminare l’informazione generica nel metodo che effettua override
- Non è consentito aggiungere nuova specificità generica
- Se entrambi i metodi dichiarano tipi parametrizzati, devono coincidere esattamente
18.4.7.3 Override valido — Eliminazione della specificità generica
class Parent {
void process(Set<Integer> data) {}
}
class Child extends Parent {
@Override
void process(Set data) {} // ✔ consentito (raw type)
}
Spiegazione:
- Prima della erasure:
Setè assignment-compatible conSet<Integer> - Dopo la erasure: entrambi diventano
Set
✔ Override valido.
18.4.7.4 Override non valido — Aggiunta di specificità generica
class Parent {
void process(Set data) {}
}
class Child extends Parent {
void process(Set<Integer> data) {} // ❌ errore di compilazione
}
Spiegazione:
- Prima della erasure:
Set<Integer>NON è assignment-compatible conSet - Il compilatore lo rifiuta prima ancora di considerare la erasure
18.4.7.5 Override valido — Parametrizzazione identica
class Parent {
void process(Set<Integer> data) {}
}
class Child extends Parent {
@Override
void process(Set<Integer> data) {} // ✔ corrispondenza esatta
}
Entrambi i controlli sono soddisfatti:
- Compatibilità prima della erasure
- Firma identica dopo la erasure
18.4.7.6 Override non valido — Modifica dell’argomento generico
class Parent {
void process(Set<Integer> data) {}
}
class Child extends Parent {
void process(Set<String> data) {} // ❌ errore di compilazione
}
Spiegazione:
- Prima della erasure:
Set<String>non è compatibile conSet<Integer> - Dopo la erasure: entrambi diventerebbero
Set - Collisione + incompatibilità → errore di compilazione
18.4.7.7 Perché esiste questa regola
Java deve garantire:
- Type safety a compile-time
- Polimorfismo a runtime dopo la erasure
Poiché i generics scompaiono a runtime, la JVM vede solo le firme erase. Il compilatore deve quindi garantire compatibilità prima della erasure e coerenza dopo la erasure.
18.4.7.8 Modello mentale
Considera l’override con generics come un controllo in due fasi:
Fase 1 → I tipi a livello di sorgente sono compatibili?
Fase 2 → Le firme erase coincidono?
Se una delle due fasi fallisce → errore di compilazione.
18.4.7.9 Ritorni Covarianti e Generics
Un metodo che effettua l’override (cioè un metodo dichiarato in una sottoclasse) è autorizzato a restituire un sottotipo del tipo di ritorno dichiarato nel metodo sovrascritto (cioè nel metodo della superclasse).
Questa è nota come regola dei ritorni covarianti.
Il primo passo nella validazione di un override consiste quindi nel:
- Verificare se il tipo di ritorno del metodo nella sottoclasse è un sottotipo del tipo di ritorno dichiarato nella superclasse.
Important
- Se il metodo sovrascritto restituisce
List, il metodo nella sottoclasse può restituireArrayList. - Non può restituire
Object, poichéObjectè un supertipo e non un sottotipo.
Quando entrano in gioco i generics, la validazione del tipo di ritorno diventa più delicata.
Occorre valutare le relazioni di sottotipizzazione utilizzando le regole della gerarchia dei tipi generici.
Si assuma che S sia un sottotipo di T.
Esistono due importanti gerarchie generiche da ricordare.
Gerarchia 1 (wildcard con limite superiore):
A<S> è un sottotipo di A<? extends S> che a sua volta è un sottotipo di A<? extends T>
- Esempio:
Poiché Integer è un sottotipo di Number:
List<Integer> <<< List<? extends Integer>List<? extends Integer> <<< List<? extends Number>
Pertanto, se un metodo sovrascritto restituisce:
List<? extends Integer>
il metodo che effettua l’override può restituire:
List<Integer>
ma non può restituire:
List<Number>List<? extends Number>
Gerarchia 2 (wildcard con limite inferiore):
A<T> è un sottotipo di A<? super T> che a sua volta è un sottotipo di A<? super S>
-
Esempio:
-
List<Number> <<< List<? super Number> List<? super Number> <<< List<? super Integer>
Pertanto, se un metodo sovrascritto restituisce:
List<? super Number>
il metodo che effettua l’override può restituire:
List<Number>
ma non può restituire:
List<Integer>List<? super Integer>
Un punto fondamentale da ricordare:
Anche se Integer è un sottotipo di Number,
List<Integer> non è un sottotipo di List<Number>.
I tipi generici in Java sono invarianti, salvo l’uso di wildcard.
Queste regole spiegano perché alcuni metodi che sembrano compatibili vengono rifiutati dal compilatore.
La specificità generica deve rispettare le gerarchie formali di sottotipizzazione prima che la validazione dell’override passi ai controlli basati sull’erasure (vedi 18.4.7.10).
18.4.7.10 Regole riassuntive
- L’override è validato dopo la erasure
- La compatibilità è validata prima della erasure
- È possibile eliminare informazione generica nella sottoclasse
- Non è possibile aggiungere nuova specificità generica
- Se entrambi i metodi sono parametrizzati, gli argomenti devono coincidere esattamente
- Dopo la erasure, le firme devono essere identiche
- I tipi di ritorno covarianti richiedono che il tipo di ritorno del metodo che effettua l’override sia un vero sottotipo.
- Con i generics, le relazioni di sottotipizzazione devono rispettare le regole della gerarchia dei wildcard.
- Le relazioni logiche apparenti tra argomenti di tipo (ad esempio
IntegereNumber) non si traducono automaticamente in relazioni di sottotipizzazione tra tipi parametrizzati.
Questo spiega perché alcuni metodi che sembrano semplici overload vengono rifiutati: dopo la erasure entrano in collisione e, se non costituiscono un override valido, il compilatore li blocca.
18.4.8 Overloading di un Metodo Generico — Perché Alcuni Overload Sono Impossibili
Quando Java compila codice generico, applica la type erasure: i parametri di tipo come T vengono rimossi, e il compilatore li sostituisce con il loro tipo erasure (di solito Object o il primo bound).
Per questo motivo, due metodi che sembrano diversi a livello di sorgente possono diventare identici dopo l’erasure.
Se le signature con erasure sono uguali, Java non può distinguerli, quindi il codice non compila.
- Esempio: Due Metodi che Collassano sulla Stessa
Signature
public class Demo {
public void testInput(List<Object> inputParam) {}
// public void testInput(List<String> inputParam) {} // ❌ Errore di compilazione: dopo l’erasure, entrambi diventano testInput(List)
}
Spiegazione
List<Object> e List<String> vengono entrambi cancellati a List.
A runtime entrambi i metodi apparirebbero come:
void testInput(List inputParam)
Java non permette due metodi con signature identiche nella stessa classe, quindi l’overload viene rifiutato a compile time.
18.4.9 Overloading di un Metodo Generico Ereditato da una Classe Parent
La stessa regola si applica quando una subclass tenta di introdurre un metodo che, dopo erasure, ha la stessa signature di uno nella superclass.
public class SubDemo extends Demo {
public void testInput(List<Integer> inputParam) {}
// ❌ Errore di compilazione: erasure → testInput(List), uguale al parent
}
Ancora una volta, il compilatore rifiuta l’overload perché le signature con erasure collidono.
Quando l’Overloading Funziona
L’erasure rimuove solo i parametri generici, non la classe reale usata come parametro del metodo.
Quindi, se due parametri differiscono nel tipo raw (non generico), l’overload è legale.
public class Demo {
public void testInput(List<Object> inputParam) {}
public void testInput(ArrayList<String> inputParam) {} // ✔ Compila
}
Perché funziona
Anche se ArrayList<String> diventa ArrayList, e List<Object> diventa List, queste sono classi diverse (ArrayList vs List), quindi le signature restano distinte:
void testInput(List inputParam)
void testInput(ArrayList inputParam)
Nessuna collisione → overloading legale.
18.4.10 Restituire Tipi Generici — Regole e Restrizioni
Quando si restituisce un valore da un metodo, Java segue una regola rigida:
Il tipo di ritorno di un metodo in overriding deve essere un sottotipo del tipo di ritorno del parent, e qualsiasi argomento generico deve rimanere type-compatible (anche se viene cancellato a runtime).
Questo spesso confonde i programmatori, perché i generics nei tipi di ritorno causano conflitti simili a quelli dei parametri.
Punti Chiave:
- La covarianza del tipo di ritorno si applica solo al tipo raw, non agli argomenti generici.
- Gli argomenti generici devono restare compatibili dopo l’erasure (devono coincidere).
- Due metodi non possono differire solo per il parametro generico nel tipo di ritorno.
Esempio: sostituzione Illegale del Tipo di Ritorno a Causa di Incompatibilità Generica
class A {
List<String> getData() { return null; }
}
class B extends A {
// List<Integer> non è un tipo di ritorno covariante di List<String>
// ❌ Errore di compilazione
List<Integer> getData() { return null; }
}
Spiegazione:
Anche se i generics vengono cancellati, Java impone comunque type safety a livello di sorgente:
List<Integer> non è un sottotipo di List<String>.
Entrambi diventano List, ma Java rifiuta l’override che rompe la compatibilità di tipo.
- Esempio: Tipo di Ritorno Covariante Legale
class A {
Collection<String> getData() { return null; }
}
class B extends A {
List<String> getData() { return null; } // ✔ List è sottotipo di Collection
}
Questo è permesso perché:
- I tipi raw sono covarianti (List estende Collection).
-
Gli argomenti generici coincidono (String vs String).
-
Esempio: Overload Illegale Basato Solo sul Tipo di Ritorno
class Demo {
List<String> getList() { return null; }
// List<Integer> getList() { return null; }
// ❌ Errore di compilazione: il tipo di ritorno da solo non distingue i metodi
}
Java non usa il tipo di ritorno per distinguere metodi in overload.
18.4.11 Riepilogo delle Regole di Erasure
T senza bound→ erasure a Object.T extends X→ erasure a X.T extends X & Y & Z→ erasure a X.- Tutti i parametri generici vengono cancellati nelle signature dei metodi.
- Vengono inseriti cast per preservare la tipizzazione a compile-time.
- Possono essere generati metodi bridge per preservare il polimorfismo.
18.5 Bound sui Parametri di Tipo
Questa sezione introduce i vincoli sui parametri di tipo e i wildcard nei generics di Java.
I vincoli limitano l’insieme dei tipi che possono essere utilizzati con un parametro di tipo generico o con un wildcard.
Sono utilizzati per imporre vincoli di tipo e per esprimere relazioni tra tipi nel codice generico.
I vincoli compaiono principalmente in due forme:
- Vincoli sui parametri di tipo usando
extends - Vincoli sui wildcard usando
?,? extendse? super
Questi meccanismi permettono alle API generiche di specificare quali tipi sono accettabili e quali operazioni sono sicure dal punto di vista del sistema di tipi.
Regole
T extends Tipo→ il parametro di tipo deve essereTipoo una sottoclasse.T extends Classe & Interface1 & Interface2→ sono consentiti vincoli multipli.- Nei vincoli multipli, la classe deve apparire per prima.
?rappresenta un tipo sconosciuto.? extends Tipo→ accetta tipi che sonoTipoo sottoclassi.? super Tipo→ accetta tipi che sonoTipoo superclassi.? extendsconsente lettura (estrazione) ma proibisce l’inserimento.? superconsente scrittura (inserimento) ma la lettura restituisceObject.
Tabella riassuntiva
| Sintassi | Significato | Compatibilità di assegnazione | Lettura | Scrittura |
|---|---|---|---|---|
<T extends Number> |
Il parametro di tipo deve essere Number o una sottoclasse |
Vincolo nella dichiarazione generica | T |
T |
<T extends Classe & Interface> |
Vincoli multipli | Vincolo nella dichiarazione generica | T |
T |
List<?> |
Tipo di elemento sconosciuto | Qualsiasi List<T> |
Object |
❌ |
List<? extends Number> |
Sottotipo sconosciuto di Number |
List<Integer>, List<Double>, ecc. |
Number |
❌ |
List<? super Integer> |
Integer o supertipo |
List<Integer>, List<Number>, List<Object> |
Object |
Integer |
18.5.1 Upper Bounds: extends
<T extends Number> significa che T deve essere Number o una sottoclasse.
class Stats<T extends Number> {
T num;
Stats(T num) { this.num = num; }
}
18.5.2 Bound Multipli
Sintassi: T extends Classe & Interface1 & Interface2 ...
La classe deve apparire per prima.
class C<T extends Number & Comparable<T>> { }
18.5.3 Wildcard: ?, ? extends, ? super
18.5.3.1 Wildcard Non Limitata ?
Usare quando si vuole accettare una lista di tipo sconosciuto:
void printAll(List<?> list) { ... }
18.5.3.2 Wildcard con Upper Bound ? extends
List<? extends Number> nums = List.of(1, 2, 3);
Number n = nums.get(0); // OK
// nums.add(5); // ❌ impossibile aggiungere: sicurezza di tipo
Non è possibile aggiungere elementi (eccetto null) a ? extends perché non si conosce il sottotipo esatto.
18.5.3.3 Wildcard con Lower Bound ? super
<? super Integer> significa che il tipo deve essere Integer o una sua superclasse.
List<? super Integer> list = new ArrayList<Number>();
list.add(10); // OK
Object o = list.get(0); // restituisce Object (supertipo comune minimo)
Important
Superaccetta inserimentoextendsaccetta estrazione.
18.6 Generics ed Ereditarietà
I generics NON partecipano all’ereditarietà.
UnList<String>non è sottotipo diList<Object>; i tipi parametrizzati sono invarianti.
List<String> ls = new ArrayList<>();
List<Object> lo = ls; // ❌ errore di compilazione
Invece:
List<? extends Object> ok = ls; // funziona
18.7 Type Inference (Operatore Diamond)
Map<String, List<Integer>> map = new HashMap<>();
Il compilatore deduce gli argomenti generici dall’assegnazione.
18.8 Raw Types (Compatibilità Legacy)
Un raw type disabilita i generics, reintroducendo comportamenti non sicuri.
List raw = new ArrayList();
raw.add("x");
raw.add(10); // permesso, ma non sicuro
I raw types dovrebbero essere evitati.
18.9 Array Generici (Non Permessi)
Non puoi creare array di tipi parametrizzati:
List<String>[] arr = new List<String>[10]; // ❌ errore di compilazione
Perché gli array applicano type safety a runtime mentre i generics si basano solo su controlli a compile-time.
18.10 Bounded Type Inference
static <T extends Number> T identity(T x) { return x; }
int v = identity(10); // OK
// String s = identity("x"); // ❌ non è un Number
18.11 Wildcard vs Parametri di Tipo
Usa le wildcard quando ti serve flessibilità nei parametri. Usa i parametri di tipo quando il metodo deve restituire o mantenere informazioni di tipo.
- Esempio — wildcard troppo debole:
List<?> copy(List<?> list) {
return list; // perde informazioni di tipo
}
Meglio:
<T> List<T> copy(List<T> list) {
return list;
}
18.12 Regola PECS (Producer Extends, Consumer Super)
Usa ? extends quando il parametro produce valori. Usa ? super quando il parametro consuma valori.
List<? extends Number> listExtends = List.of(1, 2, 3);
List<? super Integer> listSuper = new ArrayList<Number>();
// ? extends → lettura sicura
Number n = listExtends.get(0);
// ? super → scrittura sicura
listSuper.add(10);
18.13 Errori Comuni
- Ordinare liste con wildcard:
List<? extends Number>non può accettare inserimenti. - Fraintendere che
List<Object>NON è un supertype diList<String>. - Dimenticare che gli array generici sono illegali.
- Pensare che i tipi generici siano preservati a runtime (vengono cancellati).
- Provare a fare overload di metodi usando solo parametri di tipo diversi.
18.14 Tabella Riassuntiva delle Wildcard
| Sintassi | Significato |
|---|---|
? |
tipo sconosciuto (sola lettura eccetto metodi Object) |
? extends T |
leggere T in sicurezza, non si può aggiungere (eccetto null) |
? super T |
si può aggiungere T, la lettura restituisce Object |
18.15 Riepilogo dei Concetti
Generics = type safety a compile-time
Bound = limitano i tipi legali
Wildcard = flessibilità nei parametri
Type Inference = il compilatore deduce i tipi
Type Erasure = i generics scompaiono a runtime
Bridge Methods = mantengono il polimorfismo
18.16 Esempio Completo
class Repository<T extends Number> {
private final List<T> store = new ArrayList<>();
void add(T value) { store.add(value); }
T first() { return store.isEmpty() ? null : store.get(0); }
// metodo generico con wildcard
static double sum(List<? extends Number> list) {
double total = 0;
for (Number n : list) total += n.doubleValue();
return total;
}
}