Introduzione ai Generics in Java
- Generics in Java permettono di creare classi, interfacce e metodi che operano su tipi di dati specificati come parametri, migliorando la sicurezza dei tipi e riducendo la necessità di cast espliciti.
- I generics sono dichiarati usando parentesi angolari
< >
e possono essere usati per creare classi, interfacce e metodi generici che funzionano con diversi tipi di dati senza perdere la sicurezza del tipo. - La cancellazione dei generics rimuove le informazioni sui tipi generici durante la compilazione, permettendo al codice di comportarsi come se fossero state create versioni specifiche delle classi generiche, ma senza effettivamente creare versioni separate per ogni tipo.
Cosa Sono i Generics?
Nella sua essenza, il termine generics significa tipi parametrizzati.
I tipi parametrizzati sono importanti perché consentono di creare classi, interfacce e metodi in cui il tipo di dati su cui operano è specificato come parametro.
Utilizzando i generics, è possibile creare una singola classe, ad esempio, che funziona automaticamente con diversi tipi di dati. Una classe, interfaccia o metodo che opera su un tipo parametrizzato è chiamato generic, come in classe generic o metodo generic.
È importante capire che Java ha sempre fornito la capacità di creare classi, interfacce e metodi generalizzati operando attraverso riferimenti di tipo Object
. Poiché Object
è la superclasse di tutte le altre classi, un riferimento Object
può riferirsi a qualsiasi tipo di oggetto. Pertanto, nel codice scritto prima dell'introduzione dei generics, classi, interfacce e metodi generalizzati utilizzavano riferimenti Object
per operare su vari tipi di oggetti. Il problema era che non potevano farlo in modo robusto.
I generics hanno aggiunto la robustezza sul tipo che mancava. Hanno anche semplificato il processo, perché non è più necessario impiegare esplicitamente cast per tradurre tra Object
e il tipo di dati su cui si sta effettivamente operando. Con i generics, tutti i cast sono automatici e impliciti. Pertanto, i generics hanno espanso la capacità di riutilizzare il codice e consentono di farlo in modo sicuro e facile.
I Generics Non Sono i Template di C++
Un Avvertimento per i Programmatori C++:
Anche se i generics sono simili ai template in C++, non sono la stessa cosa. Ci sono alcune differenze fondamentali tra i due approcci ai tipi generici. Se avete un background in C++, è importante evitare di confondere i due concetti.
Un semplice Esempio di Generic
Iniziamo con un esempio semplice di una classe generica.
Il seguente programma definisce due classi. La prima è la classe generica Gen
, e la seconda è GenDemo
, che usa Gen
.
// Una classe generica semplice.
// Qui, T è un parametro di tipo che
// sarà sostituito da un tipo reale
// quando viene creato un oggetto di tipo Gen.
class Gen<T> {
// Dichiara un oggetto di tipo T
T ogg;
// Passa al costruttore un riferimento a
// un oggetto di tipo T.
Gen(T o) {
ogg = o;
}
// Restituisce ogg.
T getOgg() {
return ogg;
}
// Mostra il tipo di T.
void mostraTipo() {
System.out.println("Il tipo di T è " +
ogg.getClass().getName());
}
}
// Una classe per dimostrare Gen.
class GenDemo {
public static void main(String[] args) {
// Crea un riferimento Gen per Integer.
Gen<Integer> iOgg;
// Crea un oggetto Gen<Integer> e assegna il suo
// riferimento a iOgg. Nota l'uso dell'autoboxing
// per incapsulare il valore 88 dentro un oggetto Integer.
iOgg = new Gen<Integer>(88);
// Mostra il tipo di dati usato da iOgg.
iOgg.mostraTipo();
// Ottiene il valore in iOgg. Nota che
// non è necessario alcun cast.
int v = iOgg.getOgg();
System.out.println("valore: " + v);
System.out.println();
// Crea un oggetto Gen per String.
Gen<String> strOgg = new Gen<String> ("Esempio di Generic");
// Mostra il tipo di dati usato da strOgg.
strOgg.mostraTipo();
// Ottiene il valore di strOgg. Ancora, nota
// che non è necessario alcun cast.
String str = strOgg.getOgg();
System.out.println("valore: " + str);
}
}
L'output prodotto dal programma è mostrato qui:
Il tipo di T è java.lang.Integer
valore: 88
Il tipo di T è java.lang.String
valore: Esempio di Generic
Esaminiamo questo programma attentamente.
Prima, notiamo come Gen
è dichiarata dalla seguente riga:
class Gen<T> {
/* ... */
}
Qui, T
è il nome di un parametro di tipo.
Questo nome è usato come segnaposto per il tipo effettivo che sarà passato a Gen
quando viene creato un oggetto. Quindi, T
è usato dentro Gen
ogni volta che il parametro di tipo è necessario.
Si noti che T
è contenuto tra parentesi angolari < >
. Questa sintassi può essere generalizzata. Ogni volta che un parametro di tipo viene dichiarato, è specificato dentro parentesi angolari. Poiché Gen
usa un parametro di tipo, Gen
è una classe generica, che è anche chiamata tipo parametrizzato.
Nella dichiarazione di Gen
, non c'è alcun significato speciale nel nome T
. Qualsiasi identificatore valido avrebbe potuto essere usato, ma usare T
è prassi comune (perché indica Tipo).
Inoltre, è raccomandato che i nomi dei parametri di tipo siano lettere maiuscole di un singolo carattere. Altri nomi di parametri di tipo comunemente usati sono V
ed E
. Un altro punto sui nomi dei parametri di tipo: A partire da JDK 10, non si può adoperare var
come nome di un parametro di tipo.
Successivamente, T
è usato per dichiarare un oggetto chiamato ogg
, come mostrato qui:
// Dichiara un oggetto di tipo T
T ogg;
Come spiegato, T
è un segnaposto per il tipo effettivo che sarà specificato quando viene creato un oggetto Gen
. Quindi, ogg
sarà un oggetto del tipo passato a T
. Per esempio, se il tipo String
è passato a T
, allora in quell'istanza, ogg
sarà di tipo String
.
Ora consideriamo il costruttore di Gen
:
Gen(T o) {
ogg = o;
}
Si noti che il suo parametro, o
, è di tipo T
. Questo significa che il tipo effettivo di o
è determinato dal tipo passato a T
quando viene creato un oggetto Gen
. Inoltre, poiché sia il parametro o
che la variabile membro ogg
sono di tipo T
, saranno entrambi dello stesso tipo effettivo quando viene creato un oggetto Gen
.
Il parametro di tipo T
può anche essere usato per specificare il tipo di ritorno di un metodo, come nel caso del metodo getOgg()
, mostrato qui:
T getOgg() {
return ogg;
}
Poiché ogg
è anche di tipo T
, il suo tipo è compatibile con il tipo di ritorno specificato da getOgg()
.
Il metodo mostraTipo()
visualizza il tipo di T
chiamando getName()
sull'oggetto Class
restituito dalla chiamata a getClass()
su ogg
. Il metodo getClass()
è definito da Object
ed è quindi un membro di tutti i tipi di classe. Restituisce un oggetto Class
che corrisponde al tipo della classe dell'oggetto su cui è chiamato. Class
definisce il metodo getName()
, che restituisce una rappresentazione stringa del nome della classe.
La classe GenDemo
dimostra la classe generica Gen
. Prima crea una versione di Gen
per integer, come mostrato qui:
Gen<Integer> iOgg;
Si guardi attentamente questa dichiarazione. Prima, si noti che il tipo Integer
è specificato dentro le parentesi angolari dopo Gen
. In questo caso, Integer
è un argomento di tipo che è passato al parametro di tipo di Gen
, T
. Questo crea effettivamente una versione di Gen
in cui tutti i riferimenti a T
sono tradotti in riferimenti a Integer
. Quindi, per questa dichiarazione, ogg
è di tipo Integer
, e il tipo di ritorno di getOgg()
è di tipo Integer
.
Prima di andare avanti, è necessario affermare che il compilatore Java non crea effettivamente versioni diverse di Gen
, o di qualsiasi altra classe generica. Sebbene sia utile pensare in questi termini, non è quello che accade realmente. Invece, il compilatore rimuove tutte le informazioni sui tipi generici, sostituendo i cast necessari, per far comportare il proprio codice come se una versione specifica di Gen
fosse stata creata. Quindi, c'è realmente solo una versione di Gen
che esiste effettivamente nel programma. Il processo di rimozione delle informazioni sui tipi generici è chiamato cancellazione, e torneremo su questo argomento più avanti nelle prossime lezioni.
Generics e Template in C++
Le differenze principali tra i template C++ e i generics Java sono:
- Il meccanismo della cancellazione: In Java, le informazioni sui tipi generici vengono rimosse durante la compilazione, mentre in C++ i template mantengono le informazioni sui tipi fino al momento della compilazione.
- I template C++ vengono risolti al momento della compilazione, mentre i generics Java sono risolti al momento dell'esecuzione.
La riga successiva assegna a iOgg
un riferimento a un'istanza di una versione Integer
della classe Gen
:
iOgg = new Gen<Integer>(88);
Si noti che quando viene chiamato il costruttore Gen
, l'argomento di tipo Integer
è anche specificato. Questo perché il tipo dell'oggetto (in questo caso iOgg
) a cui il riferimento viene assegnato è di tipo Gen<Integer>
. Quindi, il riferimento restituito da new
deve anche essere di tipo Gen<Integer>
. Se non lo è, risulterà un errore di compilazione. Per esempio, la seguente assegnazione causerà un errore di compilazione:
iOgg = new Gen<Double>(88.0); // Errore!
Poiché iOgg
è di tipo Gen<Integer>
, non può essere usato per riferirsi a un oggetto di Gen<Double>
. Questo controllo di tipo è uno dei principali benefici dei generics perché assicura la sicurezza dei tipi.
Come dichiarano i commenti nel programma, l'assegnazione
iOgg = new Gen<Integer>(88);
fa uso dell'autoboxing per incapsulare il valore 88, che è un int
, in un Integer
. Questo funziona perché Gen<Integer>
crea un costruttore che prende un argomento Integer
. Poiché è atteso un Integer
, Java automaticamente inscatolerà 88 dentro uno. Naturalmente, l'assegnazione avrebbe potuto anche essere scritta esplicitamente, così:
iOgg = new Gen<Integer>(Integer.valueOf(88));
Tuttavia, non ci sarebbe alcun beneficio nell'usare questa versione.
Il programma poi visualizza il tipo di ogg
dentro iOgg
, che è Integer
. Successivamente, il programma ottiene il valore di ogg
usando la seguente riga:
int v = iOgg.getOgg();
Poiché il tipo di ritorno di getOgg()
è T
, che è stato sostituito da Integer
quando iOgg
è stato dichiarato, il tipo di ritorno di getOgg()
è anche Integer
, che si scompatta in int
quando assegnato a v
(che è un int
). Quindi, non c'è bisogno di fare il cast del tipo di ritorno di getOgg()
a Integer
.
Naturalmente, non è necessario usare la funzionalità di auto-unboxing. La riga precedente avrebbe potuto essere scritta anche così:
int v = iOgg.getOgg().intValue();
Tuttavia, la funzionalità di auto-unboxing rende il codice più compatto. Successivamente, GenDemo
dichiara un oggetto di tipo Gen<String>
:
Gen<String> strOgg = new Gen<String>("Esempio di Generic");
Poiché l'argomento di tipo è String
, String
è sostituito al posto di T
dentro Gen
. Questo crea (concettualmente) una versione String
di Gen
, come dimostrano le righe rimanenti nel programma.
Generics e Tipi di Riferimento
Quando dichiariamo un'istanza di un tipo generico, l'argomento di tipo passato al parametro di tipo deve essere un tipo di riferimento.
Non possiamo utilizzare un tipo primitivo, come int
o char
. Ad esempio, con Gen
, è possibile passare qualsiasi tipo di classe a T
, ma non possiamo passare un tipo primitivo a un parametro di tipo. Pertanto, la seguente dichiarazione è illegale:
// Errore, non possiamo usare un tipo primitivo
Gen<int> intOb = new Gen<int>(53);
Ovviamente, non essere in grado di specificare un tipo primitivo non è una restrizione seria perché possiamo utilizzare i wrapper di tipo (come ha fatto l'esempio precedente) per incapsulare un tipo primitivo. Inoltre, il meccanismo di autoboxing e auto-unboxing di Java rende l'uso del wrapper di tipo trasparente.
Tipi Generici e Argomenti
Un punto chiave da comprendere sui tipi generici è che un riferimento di una versione specifica di un tipo generico non è compatibile con un'altra versione dello stesso tipo generico.
Ad esempio, assumendo il programma appena mostrato, la seguente riga di codice è errata e non compilerà:
// Errore!
// iOgg e strOgg sono di tipi diversi
iOgg = strOgg;
Anche se sia iOgg
che strOgg
sono di tipo Gen<T>
, essi sono riferimenti a tipi diversi perché i loro argomenti di tipo differiscono.
Questo è parte del modo in cui i generici aggiungono sicurezza dei tipi e prevengono errori.
Generics e Sicurezza dei Tipi
A questo punto, ci si potrebbe porre la seguente domanda: Dato che la stessa funzionalità trovata nella classe generica Gen
può essere ottenuta senza generics, semplicemente specificando Object
come tipo di dato e impiegando i cast appropriati, qual è il vantaggio di rendere Gen
generica?
La risposta è che i generics assicurano automaticamente la sicurezza dei tipi di tutte le operazioni che coinvolgono Gen
. Nel processo, eliminano la necessità di inserire cast e di controllare i tipi del codice manualmente.
Per comprendere i benefici dei generics, consideriamo prima il seguente programma che crea un equivalente non-generico di Gen
:
// NonGen è funzionalmente equivalente a Gen
// ma non usa i generics.
class NonGen {
// ogg è ora di tipo Object
Object ogg;
// Passa al costruttore un riferimento a
// un oggetto di tipo Object
NonGen(Object o) {
ob = o;
}
// Restituisce tipo Object.
Object getOgg() {
return ogg;
}
// Mostra il tipo di ogg.
void mostraTipo() {
System.out.println("Il tipo di ogg è " +
ogg.getClass().getName());
}
}
// Dimostra la classe non-generica.
class NonGenDemo {
public static void main(String[] args) {
NonGen iOgg;
// Crea oggetto NonGen e memorizza
// un Integer in esso. L'autoboxing avviene ancora.
iOgg = new NonGen(88);
// Mostra il tipo di dato usato da iOgg.
iOgg.mostraTipo();
// Ottiene il valore di iOgg.
// Questa volta, un cast è necessario.
int v = (Integer) iOgg.getOgg();
System.out.println("value: " + v);
System.out.println();
// Crea un altro oggetto NonGen e
// memorizza una String in esso.
NonGen strOgg = new NonGen("Esempio di NonGen");
// Mostra il tipo di dato usato da strOgg.
strOgg.mostraTipo();
// Ottiene il valore di strOgg.
// Ancora, notare che un cast è necessario.
String str = (String) strOgg.getOgg();
System.out.println("value: " + str);
// Questa riga compila, ma è concettualmente sbagliato!
iOgg = strOgg;
// Questo causerà un errore di run-time!
v = (Integer) iOgg.getOgg();
}
}
Ci sono diverse cose interessanti in questa versione.
Per prima cosa, si noti che NonGen
sostituisce tutti gli usi di T
con Object
. Questo rende NonGen
capace di memorizzare qualsiasi tipo di oggetto, come può fare la versione generica.
Tuttavia, impedisce anche al compilatore Java di avere una reale conoscenza sul tipo di dato effettivamente memorizzato in NonGen
, il che è negativo per due ragioni. Prima di tutto, dei cast espliciti devono essere impiegati per recuperare i dati memorizzati. Seconda cosa, molti tipi di errori di mancata corrispondenza dei tipi non possono essere trovati fino a che non eseguiamo il nostro programma. Esaminiamo attentamente ogni problema.
Si noti questa riga:
int v = (Integer) iOgg.getOgg();
Poiché il tipo di ritorno di getOgg()
è Object
, il cast a Integer
è necessario per abilitare quel valore ad essere auto-unboxed e memorizzato in v
. Se rimuovete il cast, il programma non compilerà. Con la versione generica, questo cast era implicito. Nella versione non-generica, il cast deve essere esplicito. Questo non è solo un inconveniente, ma anche una potenziale fonte di errore.
Ora, consideriamo la seguente sequenza dalla fine del programma:
// Questa riga compila, ma è concettualmente sbagliato!
iOgg = strOgg;
// Questo causerà un errore di run-time!
v = (Integer) iOgg.getOgg();
Qui, strOgg
è assegnato a iOgg
. Tuttavia, strOgg
si riferisce a un oggetto che contiene una stringa, non un intero.
Questa assegnazione è sintatticamente valida perché tutti i riferimenti NonGen
sono uguali, e qualsiasi riferimento NonGen
può riferirsi a qualsiasi altro oggetto NonGen
. Tuttavia, l'istruzione è semanticamente sbagliata, come mostra la riga successiva. Qui, il tipo di ritorno di getOgg()
è sottoposto a cast a Integer
, e poi viene fatto un tentativo di assegnare questo valore a v
. Il problema è che iOgg
ora si riferisce a un oggetto che memorizza una String
, non un Integer
. Sfortunatamente, senza l'uso dei generics, il compilatore Java non ha modo di saperlo. Invece, si verifica un'eccezione di run-time quando viene tentato il cast a Integer
.
La sequenza precedente non può verificarsi quando vengono usati i generics. Se questa sequenza fosse tentata nella versione generica del programma, il compilatore la catturerebbe e riporterebbe un errore, prevenendo così un bug serio che risulta in un'eccezione di run-time.
L'abilità di creare codice type-safe in cui gli errori di mancata corrispondenza dei tipi sono catturati al compile-time è un vantaggio chiave dei generics. Sebbene usare riferimenti Object
per creare codice "generico" sia sempre stato possibile, quel codice non era type-safe, e il suo uso improprio poteva risultare in eccezioni di run-time. I generics prevengono che questo accada. In essenza, attraverso i generics, gli errori di run-time sono convertiti in errori di compile-time. Questo è un vantaggio importante.