MokaByte 92 - Gennaio 2005
Il JDK 1.5 ed i generics
II parte: programmare con i generics
di
Andrea Gini
Il mese scorso abbiamo introdotto l'utilizzo client dei Generics.Come si è visto, al di la della novità sintattica, si tratta di un costrutto molto utile e facile da usare. Nell'articolo di questo mese impareremo come si scrive una classe che faccia uso dei Generics, ed approfondiremo alcu-ni aspetti tralasciati il mese scorso dal momento che la loro semantica può essere realmente compresa solo conoscendo i relativi costrutti di programmazione

Un primo esempio
Per rompere il ghiaccio, non c'é niente di meglio che un esempio pratico. Il seguente esempio è tratto direttamente dal tutorial di Pizza [1], un'estensione di Java che già nel '96 permetteva l'uso dei Generics in Java:

public class Pair<A,B> {
  private A a;
  private B b;

  public Pair(A a, B b) {
    this.a = a;
    this.b = b;
  }
  public A getA() {
    return a;
  }
  public B getB() {
    return b;
  }
}

Si osservi il frammento di codice: se si escludono i simboli racchiusi tra parentesi angolari, dovrebbe risultare piuttosto familiare. Gli identificatori <A,B> presenti nella dichiarazione della classe sono i Tipi Parametrici che verranno usati all'interno della classe. Questi identificatori denotano tipi invece che variabili: all'interno della classe Pair tali simboli compaiano nelle dichiarazioni di variabili, tra i parametri del costruttore e come valori di ritorno sempre ed esclusivamente al posto di un normale identificatore di tipo.

Una classe come Pair può tornare utile in tutti quei casi in cui si desidera che un metodo restituisca una coppia di valori, invece che uno solo:

public class Lotto {

  public static Pair<Integer,Integer> getAmbo() {
    int primoEstratto = (int)(1+89*Math.random());
    int secondoEstratto = (int)(1+89*Math.random());

    return new Pair<Integer,Integer>(primoEstratto,secondoEstratto);
  }

}

Si noti che nel precedente metodo è stata utilizzata la nuova funzionalità di Autoboxing di tipi primitivi: sebbene i parametri formali della Pair fossero Integer, al costruttore sono stati passati due numeri interi, dal momento che il nuovo compilatore si occupa automaticamente della conversione tra tipi interi e Wrapper Type (per approfondimenti cfr [2]). Ecco un esempio di utilizzo del metodo precedente:

Pair<Integer,Integer> a = Lotto.getAmbo();
System.out.println("I numeri estratti sono: " +a.getA()+" e "+a.getB());

Dopo questa breve introduzione, è il momento di passare ad una trattazione più formale e completa.

 

Cenni sull'implementazione
Una breve digressione sulla reale implementazione dei Generics in Java può aiutare a capire meglio l'argomento. In C++, una classe generica (detta in questo caso "Template") veniva riscritta e compilata ad hoc per ogni sua realizzazione concreta. Questo meccanismo dava luogo ad un fenomeno detto "Code Bloat" (esplosione di codice) che fu la principale ragione per cui il polimorfismo parametrico venne abbandonato dai progettisti di Java.
In Java 5, i Generics vengono implementati con un meccanismo di espansione macro detto "Erasure and Translation" (Cancellazione e Traduzione). Dopo aver effettuato gli opportuni controlli di consistenza tra tipi, il compilatore cancellata (Erasure) tutte le informazioni relative ai tipi parametrici, e sostituisce le relative occorrenze con il tipo generico Object. La classe Pair vista nel paragrafo precedente verrebbe riscritta più o meno come segue:

public class Pair {
  private Object a;
  private Object b;

  public Pair(Object a, Object b) {
    this.a = a;
    this.b = b;
  }

  public Object getA() {
    return a;
  }

   public Object getB() {
    return b;
  }
}

A questo punto il compilatore genera il bytecode relativo a questa classe. Nel contempo, tutte le chiamate ai metodi di una classe parametrica presenti in codice che utilizza tale classe come client vengono tradotti (Translated) mediante l'inserimento di un'operazione di casting:

Pair a = Lotto.getAmbo();
System.out.println("I numeri estratti sono: " +(Integer)a.getA()+" e "+(Integer)a.getB());

Questa spiegazione, per quanto semplice e incompleta, non è molto lontana dalla realtà. I Generics eseguono in modo automatico lo stesso lavoro che prima era necessario eseguire manualmente, operando nel contempo i controlli che garantiscono la type safety, senza problemi di code bloat o altre idiosincrasie tipiche dell'implementazione C++.

 

Compromessi implementativi e comportamenti inattesi
L'implementazione dei Generics descritta nel precedente paragrafo non è priva di compromessi, che danno luogo ad alcuni comportamenti inattesi. Il primo di questi è che tutte le istanze di una classe generica condividono la stessa classe di base, anche se vengono specializzate in modo differente. Si osservi il seguente frammento di codice:

Pair<Integer,Integer> c1 = new Pair<Integer,Integer>(24,5);
Pair<String,Integer> c2 = new Pair<Integer,Integer>("Ciao",73);
System.out.println(c1.getClass() == c2.getClass());

Intuitivamente ci si aspetterebbe che le classi c1 e c2 differiscano tra loro per tipo;. Nella realtà, dal momento che le due istanze condividono la stessa classe di base, si otterrà come risposta "true".

Il secondo comportamento inatteso è l'impossibilità di assegnare un'istanza di una classe parametrica ad un reference con tipo parametrico più generico:

Vector<String> v1 = new Vector<String>();
Vector<Object> v2 = v1; // errore di compilazione

La ragione di questo comportamento è in realtà abbastanza semplice: se una simile operazione fosse consentita, si otterrebbero due reference di tipo parametrico diverso che puntano allo stesso Vector di String, e pertanto potremmo inserire all'interno di tale vettore oggetti differenti da String usando il reference v2:

v2.add(new Object); // violazione della type safety

Bisogna comunque tener presente che il primo obbiettivo dei Generics è garantire la type safety, pertanto queste limitazioni non vanno interpretate come difetti, ma come normali compromessi di un'implementazione che per tutti gli altri versi appare geniale.

 

Tipo Parametrico Formale e Tipo Parametrico Reale
Quando si parla dei parametri di un metodo si pone l'accento sulla differenza esistente tra i nomi con cui i parametri vengono dichiarati (parametri formali) e i valori con cui vengono invocati in una chiamata (parametri reali),

public static void myMethod(int a, String s) {…} // a e s sono parametric formali
myMethod(10,"pluto"); // 10 e "pluto" sono parametri reali

Allo stesso modo, quando si usano i tipi parametrici, bisogna distinguere tra i due possibili utilizzi degli identificatori presenti tra parentesi angolari. Quelli presenti nella dichiarazione di una classe, come i simboli A e B nel frammento di codice seguente, vengono detti tipi parametrici formali:

public class Pair<A,B> {
....
}

Essi denotano gli identificatori con cui vengono designati i tipi parametrici all'interno della classe. Al contrario, gli identificatori presenti in un'invocazione vengono detti parametri reali, e rappresentano i tipi che verranno effettivamente usati dall'istanza:

Pair<Integer,Integer> = new Pair<Integer,Integer>(12,15);

Nota: la letteratura in lingua Italiana spesso utilizza la definizione "Parametro Attuale" al posto di quella presente in questa trattazione ("Parametro Reale"). La ragione è legata ad una traduzione approssimativa del termine inglese "Actual", il cui significato non è, come ci si aspetterebbe, "Attuale", ma perl'appunto "Reale".


Carattere Jolly (Wildcard)
Un'aspetto fino ad ora trascurato dei Generics è l'esistenza di un carattere jolly (wildcard) e le sue importanti implicazioni. Come visto nel precedente paragrafo, se si desidera definire una Collection in grado di accettare qualunque tipo, non possiamo ricorrere alla definizione Vector<Object> che, al contrario, denota una Collection in grado di accettare unicamente oggetti di tipo Object. Per ovviare a questo problema, è stato introdotto il carattere jolly "?" che permette di definire Collection di tipo parametrico sconosciuto:

Vector<?> v = new Vector<?>();

Si noti che per definire una Collection di tipo sconosciuto è possibile ricorrere anche alla sintassi tradizionale:

Vector v = new Vector();

Il carattere jolly serve anzitutto in tutte le situazioni in cui i tipi parametrici sono più di uno:

HashMap<?,String> h = new HashMap<?,String>();

In secondo luogo, come verrà mostrato nei prossimi paragrafi, il carattere jolly permette di porre limiti superiori o inferiori al tipo parametrico di una determinata classe.

 

Limiti superiori
Come dobbiamo comportarci se vogliamo definire una Collection in grado di ospitare qualunque componente grafico sottoclasse di JComponent? La sintassi dei Generics prevede un uso specifico della parola chiave extends in unione con il carattere jolly "?":

Vector<? extends JComponent> v = new Vector<? extends JComponent>();

La riga precedente significa letteralmente "crea un Vector in grado di contenere JComponent e qualunque sua sottoclasse". Questo uso del carattere Jolly prende il nome di "limite superiore" (Upper Bound).

Ovviamente possiamo usare i limiti superiori anche nelle dichiarazioni di classe, e porre un limite superiore direttamente ad un parametro formale:

public class MyClass <N extends Number> {
....
}

Una simile dichiarazione richiede che il parametro reale della classe MyClass sia di tipo Number o una sua sottoclasse (Integer, Double, Float e così via).

Nota: In questo caso il processo di Cancellazione e Traduzione descritto nel secondo paragrafo sostituirà tutte le occorrenze del simbolo N con Number, anzichè con Object.

L'uso di limiti superiori all'interno di una dichiarazione di metodo comportano un ulteriore comportamento inaspettato. Si osservi il seguente frammento di codice:

public void addComponent(Vector<? extends JComponent> v) {
  v.add(new JButton("Hello World"); // errore di compilazione!
}

Il motivo è che se da una parte il compilatore si aspetta che il Vector v contenga una qualche sottoclasse di JComponent, non ha alcun indizio di quale sarà usata in pratica). Pertanto è costretto a rifiutare la compilazione.
Alcuni metodi di Collection con il carattere jolly
Una volta introdotti i concetti di carattere jolly e di limite superiore, è possibile spiegare alcuni metodi dell'interfaccia Collection tralasciati nel precedente articolo:

boolean addAll(Collection<? extends E> c)

aggiunge alla Collection un insieme di elementi dello stesso tipo del parametro formale E o di una sua sottoclasse.

boolean containsAll(Collection<?> c)

verifica se gli elementi presenti nella Collection c sono presenti anche nella Collection su cui viene invocato il metodo. Ci si può chiedere come mai questo metodo ricorra al carattere jolly, invece di richiedere esplicitamente una Collection dello stesso tipo di quella su cui si sta lavorando. La ragione è semplice: nessuno vieta di creare una Collection<String> e una Collection<?> piena di String: un confronto tra queste sarebbe assolutamente legale. L'uso di un vincolo diverso da "?" porrebbe in questo caso dei limiti di utilizzo inaccettabili dell'API Collection.

 

Limiti Inferiori
Ci sono casi in cui invece di fissare un limite superiore può risultare comodo fissare un limite inferiore. L'implementazione Java dei Generics prevede anche questa possibilità attraverso un uso della parola chiave super. Un esempio di uso dei limiti inferiori può essere il metodo containsAll() di Collection descritto nel precedente paragrafo, che potrebbe essere riscritto in questo modo:

boolean containsAll(Collection<? Super E> c)

In questo modo esso accetterebbe di verificare la presenza di elementi dello stesso tipo E della collection, o di un suo supertipo (ad esempio Object). Nel prossimo paragrafo vedremo ulteriori applicazioni dei limiti inferiori nei metodi parametrici.

Nota: Il motivo per cui il metodo containsAll() di Collection non utilizza questa possibilità è la necessità di rendere la libreria retro compatibile: il metodo infatti deve funzionare anche con Collection di un tipo qualunque, anche se scorrelato dal parametro reale del contenitore. In casi come questo, la chiamata è assolutamente legale, e il metodo si limita a restituire false.

Un altro esempio preso dal framework Collection è il metodo comparator() dell'interfaccia SortedMap, che denota una famiglia di mappe hash in cui le chiavi sono oridinate in direzione ascendente secondo i criteri stabiliti da un opportuno Comparator:

Comparator<? super K> comparator()

Questo metodo restituisce il Comparator associate alla SortedMap, che è a sua volta un'interfaccia parametrica, il cui tipo deve, in questo caso, corrispondere al tipo parametrico K dell'interfaccia Map o ad una sua superclasse.

 

Metodi Generici
Come si è visto nel precedente articolo, i tipi parametrici possono essere usati anche nelle firme di metodi, anche all'interno di classi non parametriche. I metodi generici permettono di usare i tipi parametrici per esprimere una dipendenza tra il tipo degli argomenti di un metodo e il suo tipo di ritorno, come si è già visto nell'esempio del metodo toArray() dell'interfaccia Collection:

public <T> T[] toArray(T[] a);

I metodi generici servono anche a sottolineare la dipendenza tra il tipo di un argomento e quello di un'altro; il seguente metodo, preso dalla classe Collections, è un esempio di questo secondo tipo di uso:

static <T> void fill(List<? super T> list, T obj)

Questo metodo riempie la lista passata come parametro con l'elemento specificato nel secondo parametro. Si noti la necessità di porre un limite inferiore al primo parametro del metodo: dal momento che il tipo parametrico reale deve essere dedotto in modo automatico dal tipo dei parametri reali, come già sottolineato nel precedente articolo, è necessario limitare il tipo parametrico della List, dal momento che il tipo parametrico reale è quello dell'oggetto obj, non quello della Collection, che in questo caso può essere anche di tipo più generico (ad esempio Object).

 

Convenzioni di Naming
Un ultimo argomento, la cui importanza non va sottovalutata, sono le convenzioni di naming degli identificatori di tipo presenti nelle classi parametriche. Come si è visto in tutti gli esempi, si preferisce usare nomi composti da un singolo carattere maiuscolo, Qualora si usi più di un carattere, si raccomanda di non usarne più di 2 o 3, tutti maiuscoli in modo che sia possibile distinguere ad occhio gli identificatori di tipo da quelli di variabile. Infine i nomi, per quanto sintetici, devono sempre esser
e evocativi, come si è visto negli esempi: E per Element, T per Type, K per Key e così via.

 

Conclusioni
Questo mese abbiamo portato a termine lo studio sui tipi generici. Dapprima abbiamo imparato come creare una semplice classe parametrica; quindi abbiamo accennato a come i Generics vengono implementati dal compilatore; dopo abbiamo appreso l'esistenza di un carattere jolly "?" e dei suoi possibili usi; infine abbiamo approfondito la conoscenza dei metodi parametrici, già introdotti il mese scorso. Il mese prossimo impareremo ad usare le enum, un altro nuovo costrutto di Java 5.

 

Bibliografia
[1] Pizza Tutorial:
http://pizzacompiler.sourceforge.net/doc/tutorial.html
[2] Andrea Gini, "Java2 Standard Edition 1.5 beta 1", Mokabyte 3/2004
http://www.mokabyte.it/2004/03/jdk1_5.htm

MokaByte® è un marchio registrato da MokaByte s.r.l. 
Java®, Jini® e tutti i nomi derivati sono marchi registrati da Sun Microsystems.
Tutti i diritti riservati. E' vietata la riproduzione anche parziale.
Per comunicazioni inviare una mail a info@mokabyte.it