Generics
La programmazione generica (in inglese “generics”) è una tecnica di programmazione che permette di definire a compile time delle classi contenitore (tipicamente collezioni, come dizionari o array) e delle classi contenuto. Questo consente di sfruttare il polimorfismo per gestire il comportamento del contenitore in modo indipendente dal contenuto.
Ma partiamo dall’inizio.
Tipi “contenitore”
Poniamo di avere due classi Acqua e Latte.
public interface Liquido {
String info();
}
class Acqua implements Liquido {
int quantita;
public Acqua(int quantita) {
this.quantita = quantita;
}
public String info() {
return this.quantita + " di acqua";
}
}
class Latte implements Liquido {
int quantita;
public Latte(int quantita) {
this.quantita = quantita;
}
public String info() {
return this.quantita + " di latte";
}
}A queste classi associamo una classe “contenitore”, ovvero che ne contiene una istanza:
public class Bicchiere {
private Liquido liquido;
public Bicchiere(Liqudo liquido) {
this.liquido = liquido;
}
public Liquido getLiquido() {
return this.liquido;
}
public String info() {
return "Bicchiere di " + this.liquido.info();
}
}Che possiamo istanziare qui:
Bicchiere bicchiere1 = new Bicchiere(new Latte(50));
Bicchiere bicchiere2 = new Bicchiere(new Acqua(100));Fin qui tutto ok, ma cosa succede se vogliamo accedere al contenuto del bicchiere? Vediamolo:
Latte latte = (Latte) bichiere1.getLiquido();
Acqua acqua = (Acqua) bicchiere2.getLiquido();In pratica dobbiamo eseguire un cast esplicito per avere il contenuto del bicchiere.
Il cast viene eseguito solo a runtime, e quindi se il programmatore commette un errore sbagliando il tipo del cast, questo errore viene rilevato solo in esecuzione. Inoltre si perde tutto il vantaggio dell’utilizzo dell’interfaccia Liquido, perché tanto comunque bisogna conoscere le sottoclassi del contenuto per eseguire il casting. In altri termini, Bicchiere è programmato per accettare liquidi, ma non c’è modo di sapere quale liquido ci sia nel bicchiere perché il bicchiere si appoggia ad una astrazione, il Liquido, e non ad un oggetto concreto.
Sarebbe quindi molto utile avere una classe Bicchiere “generica” che contiene si un Liquido, ma quando viene istanziata l’interfaccia Liquido viene sostituita con la classe concreta.
Questo risultato si ottiene coi Generics.
Generics
I Generics sono classi contenitore che anziché essere associate ad un contenuto specifico, ne definiscono uno generico, che poi verrà sostituito in fase di creazione dell’oggetto con il tipo effettivo.
Definiamo prima di tutto una classe BIcchiere generica.
class Bicchiere<T> {
private T liquido;
public Bicchiere(T liquido) {
this.liquido = liquido;
}
}Osserviamo il codice:
- per definire una classe generica si usano le parentesi angolari e si indica un nome generico di tipo (convenzionalmente si usa una lettera sola maiuscola, di solito T, U, K, ecc.)
- il tipo generico si usa anche all’interno della classe.
Quando poi si utilizza la classe e si crea l’oggetto si va ad indicare esplicitamente il tipo specifico usato.
Bicchiere<Latte> bicchiere1 = new Bicchiere<>(new Latte(200));
Bicchiere<Acqua> bicchiere2 = new Bicchiere<>(new Acqua(100));
Latte latte = bicchiere1.getLiquido();
Acqua acqua = bicchiere2.getLiquido();
Niente più cast esplicito: Java sa il contenuto effettivo del bicchiere e non c’è pericolo di errore a runtime!
Tuttavia in questo modo Bicchiere è un po’ troppo generico, mentre noi vogliamo che il contenuto sia comunque un Liquido. Possiamo però imporre un vincolo perché T non sia una qualsiasi classe, ma una classe che implementa una interfaccia.
public class Bicchiere<T extends Liquido> {
private T liquido;
public Bicchiere(T liquido) {
this.liquido = liquido;
}
public T getLiquido() {
return this.liquido;
}
public String info() {
return "Bicchiere di " + this.liquido.info();
}
}Osservazioni:
- si usa la parola chiave extends anche con le interfacce (è una scelta progettuale voluta, ma discutibile);
- a questo punto Java sa che T è un liquido e possiamo usare le caratteristiche della sua interfaccia.
Bicchiere<Latte> bicchiere1 = new Bicchiere<>(new Latte(200));
Bicchiere<Acqua> bicchiere2 = new Bicchiere<>(new Acqua(100));
System.out.println(bicchiere1.getLiquido().info());
System.out.println(bicchiere2.getLiquido().info());Questo sistema introduce un controllo a compile time che impedisce di avere un T troppo generico.1
E’ possibile creare contenitori Generics con contenuti multipli. Un classico esempio è questo:
class Pair<T,U> {
private T first;
private U second;
public Pair(T first, U second) {
this.first = first;
this.second = second;
}
public T getFirst() { return first; }
public U getSecond() { return second; }
}
const coppia = new Pair<Latte, Acqua>(new Latte(100), new Acqua(150));Questa semplice classe contenitore permette di associare due oggetti diversi sotto lo stesso contenitore. Valgono ovviamente le stesse regole di cui sopra, quindi vincoli e jolly.
Jolly
Ci sono situazioni in cui vogliamo dichiarare un contenitore generico, rimandando ad un secondo momento la sua istanziazione. Ad esempio potremmo voler creare un Bicchiere generico, e poi decidere in un secondo momento se metterci dentro Acqua o Latte. Per risolvere questo problema usiamo il Jolly (?):
Bicchiere<?> bicchiere.Questo consente di dichiarare variabili contenitore generiche, senza indicare il tipo di dato contenuto.Il tipo contenuto sarà poi definito nell’assegnazione.
bicchiere = new Bicchiere<Latte>(new Latte(20));
Il bicchiere generico può essere riutilizzato con altro contenuto:
bicchiere = new Bicchiere<Acqua>(new Acqua(100));Generics nella libreria Java
Con l’introduzione dei Generics è possibile ora usare una delle librerie più importanti di Java, ovvero il Collection Framework. Esso consente l’utilizzo di classi contenitore che contengono insiemi di dati. Le principali sono:
- List<T>: sono le classiche liste ordinate di elementi dello stesso tipo.
- Set<T>: rappresenta insiemi di dati non ordinati ma dove ogni elemento è presente una sola volta.
- Map<K,V>: sono i classici dizionari, cioè coppie di chiavi di tipo T (ad esempio stringhe) associate ad oggetti di tipo U. I dizionari sono
Java fornisce nella sua libreria un insieme di contenitori di largo utilizzo. Vediamo in particolare la gestione delle liste e dei dizionari.
List<T> e ArrayList<T>
L’interfaccia List<T> è un contenitore di liste di oggetti di qualsiasi tipo. I principali metodi sono:
| Metodo | Descrizione |
|---|---|
| add(T value) | aggiunge un elemento in coda |
| T get(int index) | legge l’elemento alla posizione index |
| T remove(int index) | legge e rimuove l’elemento alla posizione index |
| T set(int index, T value) | sostituisce l’elemento alla posizione index (se non esistente lo inserisce) |
| int indexOf(T value) | restituisce la prima posizione dell’elemento value (-1 se non esistente) |
| boolean contains(T value) | verifica se value è presente |
| int size() | restituisce la dimensione della lista |
| boolean isEmpty() | verifica se la lista è vuota |
La più importante classe di questa interfaccia è ArrayList<T> (ma ce ne sono anche altre).
ArrayList<T> è generalmente usata al posto dell’array perchè offre molti vantaggi perché a differenza dell’array che ha dimensione fissa, l’ArrayList è dinamica, può quindi crescere e diminuire a piacimento. Inoltre ha già integrati un insieme di metodi che consentono un accesso facilitato agli elementi della lista
Set<T> e HashSet<T>
L’interfaccia Set<T> è un contenitore di liste di oggetti dello stesso tipo T. Si differenzia dalla lista perché non è ordinata e per il principio di unicità. In altri termini non possono esserci due oggetti che A e B dove A.equals(B) == true.
| Metodo | Descrizione |
|---|---|
| add(T value) | aggiunge un elemento all’insieme. |
| toArray() | restituisce un array con tutti gli elementi |
| T remove(int index) | legge e rimuove l’elemento alla posizione index |
| T set(int index, T value) | sostituisce l’elemento alla posizione index (se non esistente lo inserisce) |
| boolean contains(T value) | verifica se value è presente |
| int size() | restituisce la dimensione della lista |
| boolean isEmpty() | verifica se la lista è vuota |
La più importante classe di questa interfaccia è HashSet<T>.
L’uso di insiemi è fondamentale per garantire unicità in numerose situazioni reali dove bisogna evitare dati duplicati, inserimenti doppi, quando si devono gestire chiavi di dizionari, ed in generale per evitare ridondanza.
Map<K, V> e HashMap<K, V>
L’interfaccia Map<K, V> è un contenitore di tipo dizionario. Un dizionario in informatica è un contenitore di elementi dove ogni elemento è una coppia di dati: la chiave e il valore. La chiave serve per identificare il valore associato. Ad esempio il vocabolario è un dizionario, dove la chiave è la parola cercata, e il valore la sua definizione.
In termini computazionali, il dizionario (se supportato da un algoritmo opportuno) consente di ricercare elementi più velocemente che in una lista. Le Hashmap per esempio utilizzano l’algoritmo di hash, che è possibile approfondire nell’apposita lezione. Le Hashmap più diffuse in Java usano come chiave il tipo String.
Vediamo i principali metodi:
| Metodo | Descrizione |
|---|---|
| V put(K key, V value) | Inserisce una nuova coppia |
| V get(K key) | Restituisce value data key |
| V remove(K key) | Rimuove il valore con chiave key |
| boolean containsKey(K key) | Verifica se esiste un valore per key |
| boolean containsValue(V value) | Verifica se esiste un valore V |
| int size() | restituisce la dimensione della lista |
| boolean isEmpty() | verifica se la lista è vuota |
Esempio concreto di List<T>
Vediamo questo esempio:
interface FiguraGeometrica {
public double area();
}
class Rettangolo extends FiguraGeometrica {
double base;
double altezza;
public Rettangolo(double base, double altezza) {
this.base = base;
this.altezza = altezza;
}
public double area() {
return this.base * this.altezza;
}
}
class Cerchio extends FiguraGeometrica {
double raggio;
public Cerchio(double raggio) {
this.raggio = raggio;
}
public double area() {
return this.raggio * this.raggio * Math.PI;
}
}A partire da queste classi possiamo creare le classi in due modi:
public class Application {
public static void main(String[] args) {
FiguraGeometrica cerchio1 = new Cerchio(4);
FiguraGeometrica rettangolo1 = new Rettangolo(6, 4);
FiguraGeometrica cerchio2 = new Cerchio(6);
FiguraGeometrica rettangolo2 = new Rettangolo(3, 5);
List<FiguraGeometrica> lista1 = new ArrayList<>();
lista1.add(cerchio1);
lista1.add(cerchio2);
lista1.add(rettangolo1);
lista1.add(rettangolo2);
List<Rettangolo> lista2 = new ArrayList <>();
lista2.add(rettangolo1);
lista2.add(rettangolo2);
List<Cerchio> lista3 = new ArrayList <>();
lista3.add(cerchio1);
lista3.add(cerchio2);
}
}Con questo sistema creiamo direttamente gli oggetti, indicando in fase di creazione direttamente il tipo associato alla lista, usando la classe concreta di libreria ArrayList<>.
Estendere ArrayList<T>
In alternativa si possono creare direttamente delle classi che inglobano in fase di definzione il tipo contenuto:
class FigureGeometriche extends ArrayList<FiguraGeometrica> {}
class Cerchi extends ArrayList<Cerchio> {}
class Rettangoli extends ArrayList<Rettangolo> {}
public class FigureGeometriche {
public static void main(String[] args) {
FiguraGeometrica cerchio1 = new Cerchio(4);
FiguraGeometrica rettangolo1 = new Rettangolo(6, 4);
FiguraGeometrica cerchio2 = new Cerchio(6);
FiguraGeometrica rettangolo2 = new Rettangolo(3, 5);
FigureGeometriche lista1 = new FigureGeometriche();
lista1.add(cerchio1);
lista1.add(cerchio2);
lista1.add(rettangolo1);
lista1.add(rettangolo2);
Rettangoli lista2 = new Rettangoli();
lista2.add(rettangolo1);
lista2.add(rettangolo2);
Cerchi lista3 = new Cerchi();
lista1.add(cerchio1);
lista1.add(cerchio2);
}
}Questo meccanismo si usa quando:
- si vuole dare maggiore evidenza del contenuto, creando una classe apposita;
- quando si vogliono aggiungere funzionalità alla classe ArrayList, specificatamente collegate al tipo di dato associato.
Classi Repository generiche
I Generics possono essere usati anche per vere e proprie classi contenitori, come nel pattern Repository, ovvero un tipo di classe contenitore che contiene una lista di oggetti di un determinato tipo e un insieme di funzioni ad esso collegate.
abstract class Repository<T extends FiguraGeometrica> {
private List<T> list;
public biggerArea() {
return list.sort(FiguraGeometrica::getArea).get(0).getArea();
}
}
class FigureGeometriche extends Repository<FiguraGeometrica {}
class Cerchi extends Repository<Cerchio> {}
class Rettangoli extends Repository<Rettangolo> {}
public class FigureGeometriche {
public static void main(String[] args) {
FiguraGeometrica cerchio1 = new Cerchio(4);
FiguraGeometrica rettangolo1 = new Rettangolo(6, 4);
FiguraGeometrica cerchio2 = new Cerchio(6);
FiguraGeometrica rettangolo2 = new Rettangolo(3, 5);
FigureGeometriche lista1 = new FigureGeometriche();
lista1.add(cerchio1);
lista1.add(cerchio2);
lista1.add(rettangolo1);
lista1.add(rettangolo2);
Rettangoli lista2 = new Rettangoli();
lista2.add(rettangolo1);
lista2.add(rettangolo2);
Cerchi lista3 = new Cerchi();
lista1.add(cerchio1);
lista1.add(cerchio2);
System.out.printlin(lista1.biggerArea();
System.out.printlin(lista2.biggerArea();
System.out.printlin(lista3.biggerArea();
}
}I Repository sono un modello di programmazione dove si va a creare una classe contenitore che al suo interno contiene non un oggetto, ma una lista di oggetti dello stesso tipo. Di norma il repository definisce esternamente una serie di metodi di accesso come:
- elenco lista oggetti
- ricerca singolo oggetto
- aggiunta
- rimozione
- sostituzione elemento
Esempio Conti correnti con Generics
Se usiamo i Generics possiamo vedere che:
- possiamo creare una interfaccia generica IConto<T>;
- possiamo creare una classe che la implementa generica ContoBancario<T extends Persona>
- non abbiamo più bisogno di creare la classe ContoDeposito, è infatti un ContoBancario<Studente>
- la classe ContoCorrente<T extends Lavoratore> va creata perché invece offre funzioni in più;
- possiamo usare ArrayList<IConto<?>> anziché un array.
Vediamo le nuove classi:
interface IConto<T> {
void setNumeroConto(int numeroConto);
void versa(double quantita);
void preleva(double quantita);
String getInfo();
T getIntestatario();
}
class ContoBancario<T extends Persona> implements IConto<T> {
protected int numeroConto;
protected double saldo;
protected T intestatario;
public ContoBancario(T intestatario) {
this.intestatario = intestatario;
this.saldo = 0;
}
public void setNumeroConto(int numeroConto) {
this.numeroConto = numeroConto;
}
public void versa(double quantita) {
this.saldo += quantita;
}
public void preleva(double quantita) {
this.saldo -= quantita;
}
public double getSaldo() {
return this.saldo;
}
public T getIntestatario() {
return this.intestatario;
}
public String getInfo() {
String result = "Intestatario: " + this.intestatario.getTipo() + " - Numero Conto: " + this.numeroConto + "\n";
result += "Nome: " + this.intestatario.getNominativo() + "\n";
result += "Saldo: " + this.saldo;
return result;
}
}
class ContoCorrente<T extends Lavoratore> extends ContoBancario<T> {
public ContoCorrente(T intestatario) {
super(intestatario);
}
public void bonifico(Banca banca, double quantita, int numeroContoDestinatario) {
this.preleva(quantita);
banca.bonifico(this.numeroConto, numeroContoDestinatario, quantita);
}
}
Conclusioni
- i Generics sono utilizzati per creare classi che usano o contengono oggetti di tipo non ancora conosciuto;
- il tipo effettivo può essere determinato in una sottoclasse che eredita dalla classe generica;
- in alternativa il tipo effettivo può essere determinato quando si crea l’istanza
