Skip to content

18. Generics in Java

Indice


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:

  • Classi
  • Interfacce
  • Metodi (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:

  1. 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)
  2. 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 con Set<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 con Set
  • 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 con Set<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ò restituire ArrayList.
  • 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 Integer e Number) 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 ?, ? extends e ? 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 essere Tipo o 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 sono Tipo o sottoclassi.
  • ? super Tipo → accetta tipi che sono Tipo o superclassi.
  • ? extends consente lettura (estrazione) ma proibisce l’inserimento.
  • ? super consente scrittura (inserimento) ma la lettura restituisce Object.

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

  • Super accetta inserimento
  • extends accetta estrazione.

18.6 Generics ed Ereditarietà

I generics NON partecipano all’ereditarietà.
Un List<String> non è sottotipo di List<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 di List<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;
    }
}