MokaByte Numero 32  -  Luglio Agosto 99 
 
Java e la
programmazione generica
di 
Andrea Giovannini
Il linguaggio Java e le nuove tecniche di programmazione


 

Come molti sanno Java nasce come un C++ semplificato e privato delle caratteristiche considerate più pericolose, come aritmetica dei puntatori, ereditarietà multipla , overloading degli operatori e template. 
Questa opera di semplificazione ha reso Java un linguaggio molto semplice, facile da imparare e col quale è possibile sviluppare programmi in modo molto più agevole rispetto a C++. 
Col tempo però ci si è resi conto che la semplicità di Java rende il linguaggio un po' limitato per lo sviluppo professionale. Ovviamente anche i progettisti di Java sono consapevoli dei limiti del linguaggio e nel sito di Sun viene mantenuta la Bug Parade di Java, ovvero la lista dei problemi del linguaggio in cui gli sviluppatori possono "votare" quelli che vorrebbero vedere risolti prima possibile. 
L'introduzione dei template è una delle modifiche più quotate e ultimamente è stata fatta una proposta da Gilad Bracha, uno dei progettisti di Java, nel contesto del Java Community Process. Come vedremo Bracha è anche parte in causa perchè fa anche parte del team di sviluppo di GJ, un compilatore sperimentale che estende Java con i template. Esamineremo più in dettaglio GJ insieme ad un altra proposta del MIT nel seguito dell'articolo

Introduzione alla programmazione generica

La programmazione generica è uno stile di programmazione basato sull'utilizzo di classi in cui il tipo di alcuni attributi è un parametro. Diversi linguaggi supportano questo paradigma anche se con caratteristiche diverse. Uno di questi è C++ che oltre a supportare classi generiche, chiamate template, permette anche di definire funzioni generiche in cui il tipo degli argomenti o del valore di ritorno è un parametro. Le classi template sono particolarmente utili per definire contenitori di tipi arbitrari non noti durante la compilazione mentre le funzioni template permettono di realizzare algoritmi generici in grado di operare su collezioni di oggetti arbitrari. Ad esempio un algoritmo di ordinamento come il quicksort è un ottimo candidato per essere definito come algoritmo generico in quanto può essere applicato indifferentemente ad un insieme di interi o di stringhe. C++ ha contribuito molto alla diffusione della programmazione generica grazie a STL (Standard Template Library), una libreria di classi contenitore e algoritmi generici basata sui template. Fra gli altri linguaggi che supportano la programmazione generica troviamo Ada, Eiffel e i linguaggi funzionali come ML che sono stati fra i primi a supportare i tipi parametrici.
Vediamo ora un esempio di funzione generica in C++, la funzione max() per il calcolo del massimo fra due elementi
 
template <class T> T max(const T& a, const T& b){
   if (a>b) { return a; }
   else { return b; }
}


Questa funzione può essere utilizzata con una coppia di parametri di tipo arbitrario e garantisce il massimo riuso del codice. Un attento esame della funzione rivela però un problema: nulla garantisce che i due parametri siano confrontabili con l'operatore >. Questo è uno dei problemi che rende la programmazione dei template in C++ decisamente pericolosa. Ritorneremo su questo problema e sulle possibili soluzione nel seguito.
Vediamo ora come è possibile realizzare una funzione di confronto generica in Java. Non avendo a disposizione i template occorre utilizzare una tecnica (o idioma) di programmazione basata sull'utilizzo dell'ereditarietà. Quello che sarebbe un argomento di tipo parametrico diventa quindi un Object, la classe base da cui derivano tutti gli oggetti in Java. L'interfaccia Comparable introdotta con la Collection API definisce per l'appunto un metodo generico per il confronto fra oggetti con la seguente signature

    public abstract int compare(Object o1, Object o2)
Questo metodo può essere quindi ridefinito nelle classi che implementano Comparable con una logica di confronto arbitraria ma i parametri del metodo devono essere necessariamente Object. Siamo quindi costretti a ricorrere a dei cast e in questo modo perdiamo i vantaggi del controllo statico sui tipi durante la compilazione. Abbiamo quindi trovato un forte limite di Java: e gli altri linguaggi? In C++ la situazione è analoga mentre ad esempio in Eiffel viene proposta una soluzione molto elegante. E' infatti possibile restringere il tipo degli argomenti dei metodi ridefiniti in overloading eliminando così la necessità dei cast e in buona misura anche dei template. Questa forma di polimorfismo viene detta covarianza.
Programmazione generica e programmazione OO
Come abbiamo visto l'utilizzo dei template introduce uno stile di programmazione diverso dalla programmazione a oggetti. In particolare la programmazione generica favorisce il riuso di classi e funzioni template ma non fornisce alcun meccanismo per garantire l'estendibilità del codice. L'utilizzo della programmazione ad oggetti permette invece di sviluppare codice estendibile attraverso l'uso dell'ereditarietà.
La programmazione generica implementa il cosiddetto polimorfismo parametrico mentre quella OO è basata sul polimorfismo per inclusione basato sulla ridefinizione dei metodi nelle classi derivate. In [1] è possibile trovare un confronto molto approfondito fra questi due stili di programmazione. In questa sede vogliamo soltanto porre in evidenza che 
  •  l'utilizzo di classi e metodi con tipi parametrici dovrebbe poter essere combinato con un meccanismo che permetta di stabilire dei vincoli sui tipi;
  • implementazioni avanzate del paradigma OO che supportino il polimorfismo con covarianza possono sostituirsi alla programmazione generica.
Dopo aver chiarito i concetti di base della programmazione per template esamineremo ora due proposte per estendere Java in tal senso: GJ e PolyJ.
 
 
 

Generic Java (GJ)

GJ (Generic Java) è un linguaggio sviluppato da un gruppo di ricercatori fra i quali anche David Stoutamire e Gilad Bracha, entrambi di JavaSoft. Occorre comunque precisare che Sun e JavaSoft non hanno nulla a che fare con il progetto GJ.
Le origini di GJ sono da ricercare in Pizza, un precedente linguaggio sviluppato dallo stesso team che estendeva Java con i template e altre caratteristiche tipiche dei linguaggi funzionali. GJ si limita invece al supporto ai template con una maggiore attenzione alla compatibilità con i programmi esistenti scritti in Java.
Un tipo parametrico in GJ viene indicato fra parentesi acute <> come in C++. Consideriamo ad esempio la seguente classe Foo
 
class Foo<T> {
....
}


I metodi dichiarati all'interno della classe potranno quindi usare T come un tipo standard. Quando si istanzierà un oggetto di classe Foo occorrerà dichiarare il tipo con cui sostituire il parametro

    Foo<String>  stringFoo;
    Foo<Integer> integerFoo;
E' importante osservare che anche se integerFoo e stringFoo sono istanze dello stesso template si tratta comunque di oggetti di tipo diverso. Una limitazione del linguaggio è data dal fatto che non è possibile usare tipo primitivi per istanziare i template.
Una importante caratteristica del linguaggio, che come abbiamo visto è assente in C++, è la possibilità di vincolare esplicitamente ogni tipo parametrico richiedendo che estenda una particolare classe o implementi una data interfaccia. Consideriamo ad esempio il seguente codice
 
class Item<T implements java.io.Serializable> {
  ....
}


Possiamo osservare che il parametro T non può essere una classe qualunque ma deve necessariamente implementare l'interfaccia Serializable. Come abbiamo osservato nella precedente discussione sui template questa possibilità di dichiarare vincoli espliciti è estremamente importante per garantire la robustezza del codice e GJ offre anche il vantaggio di utilizzare la sintassi di Java, ovvero le keyword extends e implements. Anche la classe o interfaccia di vincolo può essere a sua volta parametrizzata. Un parametro senza vincoli è implicitamente vincolato dalla classe Object.
Approfondiamo ora gli aspetti di compilazione del linguaggio. Come abbiamo visto in precedenza uno dei principali obiettivi del progetto è la completa compatibilità con il codice esistente scritto in Java. GJ è infatti un sovrainsieme di Java ed è possibile utilizzare classi scritte in Java da GJ e viceversa. Il compilatore di GJ è scritto nello stesso GJ e può compilareanche qualsiasi programma Java. Il codice prodotto è inoltre compatibile con la JVM. Per quanto riguarda la traduzione del codice viene introdotto il concetto di erasure. Ogni tipo viene infatti sostituito dalla sua erasure così definita

  • la erasure di un tipo parametrico si ottiene eliminando il parametro (es. MyClass<T> diventa MyClass);
  • la erasure di un tipo non parametrico è data dal tipo stesso;
  • la erasure di un parametro è data dalla erasure del suo vincolo (considerando l'esempio della precedente classe Item si ha che il parametro T diventa Serializable). Un parametro senza vincoli diventa quindi Object.


Il compilatore introduce inoltre degli opportuni cast nelle invocazioni di metodi template e nell'accesso alle istanze delle classi template. Vengono poi aggiunti degli opportuni metodi di bridge. Ad esempio il seguente codice GJ
 

interface Checker<T> {
   public int check(T item);
}

class CheckedClass implements Checker<String> {
   private String value;
   ....
   public int check(String item) { .... }
}

diventa
 
interface Checker {
  public int check(Object item);
}
    class CheckedClass implements Checker {
      private String value;
      ....
      public int check(String item) { .... }
      public int check(Object item) {
        return this.check((String)item)
      }
    }
L'overloading dei metodi ci permette di mantenere entrambi i metodi check() nel codice precedente.
Il compilatore utilizza la tecnica del bridging anche per supportare l'overriding covariante dei metodi sul valore di ritorno. Ad esempio
 
class A {
   public A foo() { .... }
}

class B extends A {
   public B foo() { .... }
}


Il codice precedente è perfettamente legale in GJ e viene tradotto introducendo un metodo di bridge nella seconda classe.
Per il controllo di tipo GJ utilizza un algoritmo di type-inference che data una chiamata ad un metodo template determina il tipo dei parametri scegliendo il 'minimo' super-tipo comune. Ad esempio
 

class Test {
  public <T> void inferenceDemo(T a, T b) { .... }
  public static void main(String[] args) {
     Test test = new Test()
     test.inferenceDemo(new Integer(1), new Float(3.5));
   }
}


In questo caso il compilatore determina che per il metodo inferenceDemo il parametro T è Number. Se invece di Integer e Float avessimo avuto Integer e String si sarebbe verificato un errore di compilazione poiché non esiste un super-tipo minimo comune.
Come abbiamo osservato in precedenza istanze diverse di uno stesso template sono tipi diversi. Questo è vero anche se esiste una relazione di ereditarietà fra i tipi usati per istanziare i template, quindi ad esempio List<Object> e List<String> rimangono comunque tipi distinti. Questo deriva dal fatto che i parametri di tipo nei template non sono disponibili a run time in GJ, essendo stati eliminati dal processo di erasure durante la compilazione, e causa qualche limitazione nel linguaggio. Ad esempio se T è un parametro di tipo l'espressione new T[n] è illegale in GJ. Sono inoltre illegali alcuni cast e test di istanza come nel seguente esempio

 
List<String> downCast(Object o) {
     if (o instanceof List<String>) { // ERROR
        return (List<String>)o; // ERROR
     } else { .... }
}
I due statement precedenti son illegali perchè il compilatore non ha alcun modo di verificare il parametro String.
Ai precedenti problemi si contrappone un più semplice design di GJ, una maggiore efficienza e una migliore compatibilità con il codice esistente. NextGen è un esempio di linguaggio che estende Java con i template mantenendo l'informazione sui tipi a run time e si può considerare un sovrainsieme di GJ.
Per quanto riguarda l'interoperabilità con il codice Java esistente GJ permette di
  • fare riferimento ad un tipo parametrico (es. List<T>) senza considerare il parametro; in questo caso il tipo viene chiamato raw;
  • aggiungere il supporto per i tipi generici a classi Java esistenti attraverso la procedura di retrofitting. Il compilatore provvede infatti a modificare il byte code a partire da un file .java contenente la signature dei metodi generici. La distribuzione di GJ contiene ad esempio una versione generica della Collection API ottenuta attraverso questo meccanismo.

PolyJ

PolyJ è un'estensione di Java sviluppata al MIT che supporta classi con tipi parametrici. Il compilatore è implementato in C++ come estensione di GuavaC, un compilatore free per Java diffuso sui sistemi Linux. PolyJ è compatibile all'indietro con il codice Java esistente.
La prima caratteristica del linguaggio che colpisce è la sintassi per la dichiarazione dei parametri template che vanno racchiusi fra parentesi quadre []. La scelta è orientata a ottenere una semplificazione per il parsing del codice ma è senz'altro discutibile per quanto riguarda la leggibilità.
A differenza di GJ è possibile utilizzare come parametri di tipo anche tipi primitivi e array. I parametri possono essere vincolati con una sintassi particolare, le clausole di where. Consideriamo il seguente esempio
 
class SortedList[T] where T { int compare(T); }{
   ....
}


Nella classe SortedList si richiede che l'interfaccia del parametro T supporti il metodo compare(). In generale l'istanziazione di una classe parametrica ha successo solo se i tipi indicati per sostituire i parametri hanno tutti i metodi elencati nella clausola di where del corrispondente parametro. In caso contrario si avrà un errore di compilazione.
Per poter utilizzare il precedente meccanismo di vincolo anche per i tipi primitivi e gli array PolyJ aggiunge a questi tipi dei metodi aggiuntivi. Ad esempio
 

class Foo[T] where T { T add(T); }{
    ....
}


La classe precedente può essere istanziata anche con int che in PolyJ supporta il metodo add().
Ogni parametro di tipo T ha ad esso associato un particolare valore T.default per indicare il valore di default per quel tipo. Nel caso in cui T sia sostituito con un tipo non primitivo allora T.default vale null, altrimenti nel caso dei tipi primitivi vale il default per quel tipo (es. 0 per int).
In PolyJ è possibile definire delle relazioni di subtyping fra i tipi parametrici. Ad esempio
 

class SortedList[T] where { .... }
      extends BaseList[T] {
      ....
    }
è una dichiarazione legale se i metodi definiti nella clausola di where per T in BaseList sono gli stessi o un sottoinsieme di quelli definiti per T in SortedList. A questo punto dato un tipo attuale Foo che possa essere utilizzato per istanziare SortedList si ha che SortedList[Foo] è un sottotipo di BaseList[Foo]. Questa relazione non è più valida se si utilizzano tipo diversi per istanziare SortedList e BaseList.
L'utilizzo delle clausole di where per definire i vincoli è analogo all'uso delle interfacce. A differenza di queste nelle clausole è possibile definire costruttori e metodi statici. Esse permettono inoltre di avere una maggiore granularità nella definizione dei vincoli senza ricorrere a relazioni di ereditarietà. Se consideriamo il fatto che una classe parametrica deve poter essere compilata separatamente da quella usata come parametro possiamo comprendere come sia importante mantenere il massimo disaccoppiamento. L'utilizzo di interfacce o di esplicite relazioni di ereditarietà potrebbe non essere sempre possibile e a volte anche non auspicabile se il loro numero diventa considerevole. Le clausole di where permettono anche di limitare la generazione di codice per i tipi parametrici.
Relativamente agli aspetti di compilazione viene generata una classe Java di riferimento per ogni classe parametrica mentre per ogni istanza si genera una classe wrapper che definisce i metodi con i tipi corretti ed esegue i cast opportuni per richiamare i corrispondenti metodi nella classe di riferimento.
 
 

Valutazioni

Dopo aver esaminato i due linguaggi possiamo trarre alcune conclusioni.
Il compilatore meglio supportato è sicuramente GJ. E' infatti aggiornato alla versione 1.2 di Java mentre PolyJ ha invece la grossa limitazione di supportare al momento solo Java 1.0. Il fatto che PolyJ sia un progetto sviluppato in un laboratorio di ricerca lo rende principalmente una dimostrazione di concetti interessanti (es. le clausole di where) ma non un linguaggio pensato per un utilizzo in ambienti di produzione. Un altro vantaggio di GJ è dato dal supporto ai metodi template polimorfi.
Entrambi i linguaggi permettono di definire dei vincoli sui parametri di un template e PolyJ permette al programmatore una maggiore libertà espressiva.
 

Conclusioni

In questo articolo abbiamo esaminato i vantaggi e i problemi della programmazione generica al fine di comprendere le reali necessità di una sua introduzione in Java. Abbiamo quindi visto le due proposte più interessanti in tal senso, GJ e PolyJ. Possiamo senz'altro concludere con la speranza di leggere al più presto su MokaByte un articolo sui template come nuova caratteristica di Java!!
Riferimenti  

  
 

MokaByte rivista web su Java

MokaByte ricerca nuovi collaboratori
Chi volesse mettersi in contatto con noi può farlo scrivendo a mokainfo@mokabyte.it