Tipi generici: motivazioni

I tipi generici forniscono uno strumento di controllo statico molto espressivo e estremamente sofisticato, difficilmente trattabile in modo esauriente in un corso del primo anno. In questo modulo cercheremo di evidenziare solo alcune caratteristiche principali, necessarie per interpretare correttamente le API di Java 5.0 (e versioni successive).

Un esempio è la classe Vector che abbiamo già introdotto.

Confrontiamo l'uso dei vettori in Java 5.0 e in Java 1.4.

/*******   JAVA 5.0   *******/

Vector<Integer> v = new Vector<Integer>();
v.add(5);   // autoboxing 
v.add("3");   // errore 
/*******   JAVA 1.4   *******/

Vector v = new Vector();
v.add(new Integer(5));
v.add("3");   // ok 

Si osservi che:

  • in Java 1.4 un vettore può contenere arbitrari oggetti: non vi è nessun controllo sull'omogeneità degli elementi;

  • in Java 5.0 si indica esplicitamente il tipo degli oggetti che il vettore conterrà, e questo consente maggiori controlli a tempo di compilazione: di conseguenza il codice è più dettagliato ma anche più robusto;

  • grazie ad autoboxing/unboxing la sintassi in Java 5.0 è notevolmente semplificata: il codice diviene più leggibile.




Dichiarazione di classi generiche: un esempio

La seguente classe Coppia ha due parametri di tipo, E e F, e le sue istanze sono coppie di oggetti. Si noti in particolare l'uso delle variabili di tipo nel corpo della classe.

Istanze di questa classe possono essere utili se, ad esempio, un metodo deve restituire due valori o se dobbiamo gestire dei record di valori.

Per comodità, non dovendo fornire garanzie sui dati incapsulati, dichiariamo le variabili d'istanza pubbliche (invece che private).

public class Coppia<E,F>{
    public E fst;
    public F snd;

    public Coppia(E fst, F snd){
        this.fst = fst;
        this.snd = snd;
    }

    public String toString(){
        return "("+ fst + "," + snd + ")";
    }
}

Si noti che il nome del file .java e del costruttore della classe generica sono semplicemente Coppia e non Coppia<E,F>.

Notare anche che quando si dichiarano più parametri formali questi vengono tutti racchiusi tra una sola coppia di parentesi angolari e separati da virgole.

public class Coppia<E><F> { ... }  // errore in compilazione




Istanziazione ed estensione di classi generiche

Una volta definita la classe Coppia<E,F> generica, possiamo sfruttarla per rappresentare altre strutture: è sufficiente istanziare opportunamente i parametri formali di tipo.

public class CartaDaGioco extends Coppia<Integer,String> {
    // carta identificata da valore e seme
    ...
}

public class Punto2D extends Coppia<Integer,Integer> {
    // punto identificato da due coordinate
   ...
}

public class Relazione extends java.util.Vector<Coppia<Integer,Integer>> {
    // relazione numerica
   ...
}




Tipi generici: Comparable<T>

L'interfaccia Comparable è un esempio di interfaccia generica.

public interface Comparable<T>{

    int compareTo(T o);

}

Comparable è un'interfaccia generica, poiché ha un parametro di tipo T.

<T> è una dichiarazione di parametro formale di tipo. Dopo la dichiarazione, T può essere usato nel codice della classe come se fosse un tipo noto.


Un'interfaccia generica può essere istanziata come nelle seguenti dichiarazioni di classi.

public class Mela implements Comparable<Mela>{
    private int grammi;
    private String tipo;

    public Mela(String tipo, int grammi){
        this.tipo = tipo;
        this.grammi = grammi;
    }

    public int compareTo(Mela m){
        // si noti che non si usa un cast 
        return grammi - m.grammi;
    }
}

public class Pera implements Comparable<Pera>{
    private int grammi;
    private String tipo;

    public Pera(String tipo, int grammi){
        this.tipo = tipo;
        this.grammi = grammi;
    }

    public int compareTo(Pera p){
        // si noti che non si usa un cast 
        return grammi - p.grammi;
    }
}

public class TestMelaPera{

    public static void main (String [] args){
        Mela mela = new Mela("golden",135);
        Pera pera = new Pera("williams",120);
        int comp = pera.compareTo(mela); // errore di compilazione
    }
}

Si noti che viene segnalato un errore a tempo di compilazione, invece che durante l'esecuzione. L'errore segnalato è

> javac TestMelaPera.java
TestMelaPera.java:6: compareTo(Pera) in Pera cannot be applied to (Mela)
        int comp = pera.compareTo(mela);
                       ^
1 error



Un esempio complesso: coppie confrontabili

Un'utile estensione della classe Coppia può essere quella di fornire un ordinamento lessicografico sulle coppie: prima si confrontano gli elementi nella prima posizione e solo se questi sono uguali si confrontano quelli in seconda posizione. Come esempi di uso si pensi all'ordinamento di nominativi (cognome, nome), oppure di ore espresse come coppia (hh,mm), o di date espresse come terna (aaaa,(mm,gg)).

Abbiamo visto che il modo standard di definire il confronto è quello di implementare l'interfaccia Comparable. Quindi, analogamente alla classe Mela, l'intestazione della nostra classe dovrebbe essere qualcosa come:

public class CoppiaComp<E,F> extends Coppia<E,F>   // cosi' non va bene! 
    implements Comparable<CoppiaComp<E,F>> {
...
}

Però se vogliamo poter confrontare le proiezioni della coppia separatamente, bisogna anche informare il compilatore che tali elementi sono a loro volta Comparable. Vediamo una realizzazione della classe CoppiaComp.

public class CoppiaComp<E extends Comparable<E>,F extends Comparable<F>> 
    extends Coppia<E,F> 
    implements Comparable<CoppiaComp<E,F>> {

    public CoppiaComp(E fst, F snd){
        super(fst, snd);
    }

    public int compareTo(CoppiaComp<E,F> obj){
        int comp = fst.compareTo(obj.fst);
        if (comp != 0) return comp;
        return snd.compareTo(obj.snd);
    }
}

Si noti l'uso della parola chiave extends per dichiarare che E e F sono Comparable.

public class CartaComp1
    extends CoppiaComp<String,Integer> {
    // ordina prima per seme, poi per valore
    ...
}

public class CartaComp2
    extends CoppiaComp<Integer,String> {
    // ordina prima per valore, poi per seme
    ...
}

public class OraComp
    extends CoppiaComp<Integer,Integer> {
    ...
}

Negli esempi sopra sono stati omessi i costruttori.
Classi complete: CartaComp1, CartaComp2 e OraComp.




Coppie confrontabili: applicazioni

L'esempio riportato sotto mostra come sia possibile ordinare array di coppie confrontabili con il metodo statico sort definito in java.utils.Arrays. Per garantire un controllo forte dei tipi, Java 5.0 non permette di creare array il cui tipo base sia generico. È però possibile creare array il cui tipo base estenda un tipo generico, istanziandolo.

Esempio: CartaComp1, CartaComp2 e TestCarte.

import java.util.Arrays;

public class TestCarte {

    public static void main(String[] args) {
        CartaComp1[] mazzoCarte1 = new CartaComp1[40];
        CartaComp2[] mazzoCarte2 = new CartaComp2[40];
        String[] semi = {"spade","coppe","bastoni","denari"};
        for (int i=0; i<=3; i++)
            for (int j=1;j<=10;j++) {
                int k = i*10+j-1;
                mazzoCarte1[k] = new CartaComp1(semi[i],j);
                mazzoCarte2[k] = new CartaComp2(j,semi[i]);
            }

        for (CartaComp1 cc1 : mazzoCarte1)
            System.out.println(cc1);
        System.out.println("----------");
        for (CartaComp2 cc2 : mazzoCarte2)
            System.out.println(cc2);
        System.out.println("==========");

        Arrays.sort(mazzoCarte1);
        Arrays.sort(mazzoCarte2);

        for (CartaComp1 cc1 : mazzoCarte1)
            System.out.println(cc1);
        System.out.println("----------");
        for (CartaComp2 cc2 : mazzoCarte2)
            System.out.println(cc2);
        System.out.println("==========");
    }
}

Attenzione: il seguente metodo non compilerebbe:

    public static void main(String[] args) {
        Coppia<Integer,String>[] mazzoCarte = new Coppia<Integer,String>[40];
        ...
        // non compilerebbe!

}




Erasure e raw type

Si provi a eseguire la classe seguente, prestando attenzione all'output:

public class ClasseUnica {

    public static void main(String[] args) {
        Coppia<String,Integer> c = 
            new Coppia<String,Integer>("bastoni",1);
        System.out.println(c + ":" + c.getClass());

        Coppia<Integer,Integer> mmgg = 
            new Coppia<Integer,Integer>(3,23);
        System.out.println(mmgg + ":" + mmgg.getClass());

    }
}

Esiste una sola classe, chiamata Coppia! (Indipendentemente da come vengono istanziati i suoi parametri di tipo).

I parametri di tipo sono solo annotazioni per il compilatore che può così effettuare dei controlli ferrei sulla compatibilità degli oggetti e delle loro funzionalità. La compilazione trasforma gli oggetti generici nei corrispondenti oggetti non generici. A tempo di esecuzione i tipi generici non sono più disponibili. Questo meccanismo è chiamato erasure.

Per garantire la compatibilità col codice Java 1.4 è possibile usare tipi generici senza istanziarne i parametri, anche se questo non sarebbe appropriato per Java 5.0. Ad esempio possiamo definire e usare dati di tipo Vector, in questo caso si parla di raw type. L'uso di raw type, sconsigliato ma consentito, può facilmente generare dei warning in fase di compilazione.

ATTENZIONE: usare un raw type è diverso da istanziare i parametri con la superclasse Object (es. Vector e Vector<Object> non sono intercambiabili)!




Metodi generici

Analogamente a classi e interfacce generiche, in Java 5.0 è possibile definire metodi generici, ovvero parametrici rispetto ad uno o più tipi.

Esempio: MaxGenerico.

import java.util.Vector;
public class MaxGenerico {
    public static <T extends Comparable<T>> T max (Vector<T> elenco){
        if (elenco == nullreturn null;
        T max = null;
        for (T e : elenco){
            if (max == null || e.compareTo(max) > 0)
                max = e;
        }
        return max;
    }
}

Nell'esempio:

  • la classe non ha parametri di tipo;
  • la dichiarazione di tipo è <T extends Comparable<T>>, immediatamente successiva ai modificatori;
  • il tipo del metodo è T;
  • la firma del metodo è max(Vector<T>).

Nota: quando si invoca un metodo generico non si devono specificare i parametri di tipo, infatti il compilatore inferisce questi parametri (nel modo più specifico possibile) sulla base degli argomenti attuali della chiamata.




Wildcards

In alcuni casi il parametro di tipo potrebbe essere dichiarato ma mai usato (ad esempio se fosse utilizzato col solo scopo di restringere gli argomenti che possono essere passati al metodo sfruttando il controllo statico del compilatore). Invece di introdurre un parametro con nome, possiamo usare la wildcard ?.

Esempio: Anagrafe.

import java.util.Vector;
public class Anagrafe {

    public static void stampa1(Vector<Persona> v) {
        for(Persona p : v) System.out.println(p);
    }

    public static <E extends Persona> void stampa2(Vector<E> v) {
        for(Persona p : v) System.out.println(p);
    }

    public static void stampa3(Vector<? extends Persona> v) {
        for(Persona p : v) System.out.println(p);
    }
}

Nell'esempio:

  • il metodo stampa1 accetta esclusivamente un parametro di tipo Vector<Persona>: il passaggio di un argomento di tipo Vector<Studente> viene vietato dal compilatore!

  • il metodo stampa2 accetta esclusivamente parametro di tipo Vector<E> dove E può essere una qualsiasi sottoclasse di Persona: in questo caso il compilatore accetta il passaggio di argomenti sia di tipo Vector<Persona> che Vector<Studente>!

  • il metodo stampa3 si comporta esattamente come stampa2: l'unica differenza risiede nell'uso della wildcard.

A completamento dell'esempio, menzioniamo che, analogamente alla dichiarazione E extends Persona che restringe il parametro di tipo E alle sottoclassi di Persona, è anche possibile restringere il parametro di tipo alle superclassi di una data classe: in questo caso la sintassi è E super Persona.




Note su generici e ereditarietà

Per concludere la panoramica sui tipi generici evidenziamo alcuni aspetti riguardanti la relazione di ereditarietà che potrebbero risultare controintuitivi:

  • Non è permesso usare instanceof e cast esplicito su tipi generici, perché sono operazioni che riguardano solo il run-time, quando le informazioni sui generici non sono più disponibili a causa dell'erasure.

  • In generale, se una classe A ha una sottoclasse B e G è un tipo generico, le classi G<A> e G<B> non sono relazionate tra loro.

    Per esempio, se Mela estende Frutto NON È VERO che Vector<Mela> estende Vector<Frutto> (e neppure il viceversa)

  • Come facile conseguenza della precedente osservazione, una variabile di tipo G<Object> non può contenere riferimenti a oggetti di tipo G<A> quando A è una qualsiasi classe diversa da Object

    Invece, la notazione G<?> (detto tipo wildcard) denota il supertipo di tutte le possibili istanze di G.

    Per esempio, se Mela e Pera estendono Frutto, una variabile dichiarata di tipo Vector<Frutto> può contenere riferimenti solo a oggetti di tipo Vector<Frutto>, mentre una variabile dichiarata di tipo Vector<?> può contenere riferimenti a oggetti di tipo Vector<Frutto>, Vector<Mela>, Vector<Pera>... MA ANCHE Vector<String>, Vector<Object>.

  • Il tipo wildcard può essere combinato con le parole chiave extends e super.

    Per esempio, se Mela e Pera estendono Frutto, una variabile dichiarata di tipo Vector<? extends Frutto> può contenere riferimenti a oggetti di tipo Vector<Frutto>, Vector<Mela>, Vector<Pera>... MA NON Vector<String>, Vector<Object>.

  • I tipi wildcard possono limitare l'usabilità degli oggetti.

    Per esempio, dichiando la variabile v di tipo Vector<?> le istruzioni v.add(new Object()) e v.add("prova") provocherebbero errori di compilazione.