Guarda il potere del polimorfismo parametrico

Supponi di voler implementare una classe list in Java. Si inizia con una classe astratta List, e due sottoclassi Emptye Cons, che rappresentano rispettivamente elenchi vuoti e non vuoti. Poiché si prevede di estendere la funzionalità di questi elenchi, si progetta ListVisitorun'interfaccia e si forniscono accept(...)hook per ListVisitors in ciascuna delle sottoclassi. Inoltre, la tua Consclasse ha due campi firste rest, con i metodi di accesso corrispondenti.

Quali saranno i tipi di questi campi? Chiaramente, restdovrebbe essere di tipo List. Se sai in anticipo che le tue liste conterranno sempre elementi di una data classe, il compito di codificare sarà notevolmente più semplice a questo punto. Se sai che gli elementi della tua lista saranno tutti integers, ad esempio, puoi assegnare firstad essere di tipo integer.

Tuttavia, se, come spesso accade, non conosci queste informazioni in anticipo, devi accontentarti della superclasse meno comune che ha tutti gli elementi possibili contenuti nelle tue liste, che tipicamente è il tipo di riferimento universale Object. Pertanto, il codice per elenchi di elementi di tipo variabile ha la seguente forma:

abstract class List {public abstract Object accetta (ListVisitor that); } interfaccia ListVisitor {public Object _case (Empty that); public Object _case (Contro quello); } la classe Empty estende List {public Object accept (ListVisitor that) {return that._case (this); }} la classe Cons estende List {private Object first; elenco privato resto; Contro (Object _first, List _rest) {first = _first; rest = _rest; } public Object first () {return first;} public List rest () {return rest;} public Object accept (ListVisitor that) {return that._case (this); }}

Sebbene i programmatori Java utilizzino spesso la superclasse meno comune per un campo in questo modo, l'approccio ha i suoi svantaggi. Supponiamo di creare un ListVisitorche aggiunge tutti gli elementi di un elenco di se Integerrestituisce il risultato, come illustrato di seguito:

la classe AddVisitor implementa ListVisitor {private Integer zero = new Integer (0); public Object _case (Empty that) {return zero;} public Object _case (Cons that) {return new Integer (((Integer) that.first ()). intValue () + ((Integer) that.rest (). accept (questo)). intValue ()); }}

Notare i cast espliciti a Integernel secondo _case(...)metodo. Stai eseguendo ripetutamente test di runtime per controllare le proprietà dei dati; idealmente, il compilatore dovrebbe eseguire questi test per te come parte del controllo del tipo di programma. Ma poiché non è garantito che AddVisitorvenga applicato solo a Lists of Integers, il controllo del tipo Java non può confermare che si stanno effettivamente aggiungendo due Integers a meno che i cast non siano presenti.

Potenzialmente potresti ottenere un controllo del tipo più preciso, ma solo sacrificando il polimorfismo e duplicando il codice. Ad esempio, potresti creare una Listclasse speciale (con corrispondenti Conse Emptysottoclassi, nonché Visitorun'interfaccia speciale ) per ogni classe di elemento che memorizzi in un file List. Nell'esempio sopra, creeresti una IntegerListclasse i cui elementi sono tutti Integers. Ma se volessi memorizzare, diciamo, Booleani messaggi di posta elettronica in qualche altro punto del programma, dovresti creare una BooleanListclasse.

Chiaramente, la dimensione di un programma scritto utilizzando questa tecnica aumenterebbe rapidamente. Ci sono anche ulteriori questioni stilistiche; uno dei principi essenziali di una buona ingegneria del software è avere un unico punto di controllo per ogni elemento funzionale del programma e la duplicazione del codice in questo modo copia e incolla viola questo principio. Ciò comporta comunemente costi elevati di sviluppo e manutenzione del software. Per capire perché, si consideri cosa succede quando viene rilevato un bug: il programmatore dovrebbe tornare indietro e correggere quel bug separatamente in ogni copia eseguita. Se il programmatore dimentica di identificare tutti i siti duplicati, verrà introdotto un nuovo bug!

Tuttavia, come illustra l'esempio precedente, sarà difficile mantenere contemporaneamente un singolo punto di controllo e utilizzare controlli di tipo statici per garantire che determinati errori non si verifichino mai durante l'esecuzione del programma. In Java, così come esiste oggi, spesso non hai altra scelta che duplicare il codice se desideri un controllo statico preciso del tipo. A dire il vero, non potresti mai eliminare completamente questo aspetto di Java. Alcuni postulati della teoria degli automi, portati alla loro conclusione logica, implicano che nessun sistema di tipo sonoro può determinare con precisione l'insieme di input (o output) validi per tutti i metodi in un programma. Di conseguenza, ogni sistema di tipi deve trovare un equilibrio tra la propria semplicità e l'espressività del linguaggio risultante; il sistema di tipi Java si appoggia un po 'troppo nella direzione della semplicità. Nel primo esempio,un sistema di tipi leggermente più espressivo avrebbe consentito di mantenere un controllo del tipo preciso senza dover duplicare il codice.

Un tale sistema di tipi espressivo aggiungerebbe tipi generici alla lingua. I tipi generici sono variabili di tipo che possono essere istanziate con un tipo specifico in modo appropriato per ogni istanza di una classe. Ai fini di questo articolo, dichiarerò le variabili di tipo tra parentesi angolari sopra le definizioni di classe o interfaccia. L'ambito di una variabile di tipo sarà quindi costituito dal corpo della definizione in cui è stata dichiarata (esclusa la extendsclausola). In questo ambito, è possibile utilizzare la variabile di tipo ovunque sia possibile utilizzare un tipo normale.

Ad esempio, con i tipi generici, potresti riscrivere la tua Listclasse come segue:

abstract class List {public abstract T accept (ListVisitor that); } interfaccia ListVisitor {public T _case (Empty that); public T _case (Contro quello); } la classe Empty estende List {public T accept (ListVisitor that) {return that._case (this); }} class Cons estende List {private T first; elenco privato resto; Contro (T _first, List _rest) {first = _first; rest = _rest; } public T first () {return first;} public List rest () {return rest;} public T accept (ListVisitor that) {return that._case (this); }}

Ora puoi riscrivere AddVisitorper sfruttare i tipi generici:

la classe AddVisitor implementa ListVisitor {private Integer zero = new Integer (0); public Integer _case (Vuota quello) {return zero;} public Integer _case (Contro quello) {return new Integer ((that.first ()). intValue () + (that.rest (). accept (this)). intValue ()); }}

Si noti che i cast espliciti a Integernon sono più necessari. L'argomento thatdel secondo _case(...)metodo è dichiarato Cons, istanziando la variabile di tipo per la Consclasse con Integer. Pertanto, il controllo di tipo statico può dimostrare che that.first()sarà di tipo Integere che that.rest()sarà di tipo List. Istanze simili verrebbero eseguite ogni volta che viene dichiarata una nuova istanza di Emptyo Cons.

Nell'esempio sopra, le variabili di tipo potrebbero essere istanziate con any Object. È inoltre possibile fornire un limite superiore più specifico a una variabile di tipo. In questi casi, è possibile specificare questo limite nel punto di dichiarazione della variabile di tipo con la seguente sintassi:

  si estende  

Ad esempio, se desideri che i tuoi messaggi Listcontengano solo Comparableoggetti, puoi definire le tue tre classi come segue:

class List {...} class Contro {...} class Empty {...} 

Sebbene l'aggiunta di tipi parametrizzati a Java offra i vantaggi mostrati sopra, non sarebbe utile se ciò significasse sacrificare la compatibilità con il codice legacy nel processo. Fortunatamente, un tale sacrificio non è necessario. È possibile tradurre automaticamente il codice, scritto in un'estensione di Java che ha tipi generici, in bytecode per la JVM esistente. Diversi compilatori lo fanno già: i compilatori Pizza e GJ, scritti da Martin Odersky, sono esempi particolarmente buoni. Pizza era un linguaggio sperimentale che aggiungeva diverse nuove funzionalità a Java, alcune delle quali incorporate in Java 1.2; GJ è un successore di Pizza che aggiunge solo tipi generici. Poiché questa è l'unica caratteristica aggiunta, il compilatore GJ può produrre bytecode che funziona senza problemi con il codice legacy. Compila il codice sorgente in bytecode tramitetype erasure, che sostituisce ogni istanza di ogni variabile di tipo con il limite superiore di quella variabile. Consente inoltre di dichiarare variabili di tipo per metodi specifici, piuttosto che per intere classi. GJ utilizza la stessa sintassi per i tipi generici che uso in questo articolo.

Lavori in corso

Alla Rice University, il gruppo tecnologico dei linguaggi di programmazione in cui lavoro sta implementando un compilatore per una versione compatibile con le versioni successive di GJ, chiamata NextGen. Il linguaggio NextGen è stato sviluppato congiuntamente dal professor Robert Cartwright del dipartimento di informatica della Rice e da Guy Steele della Sun Microsystems; aggiunge a GJ la possibilità di eseguire controlli di runtime delle variabili di tipo.

Un'altra potenziale soluzione a questo problema, chiamata PolyJ, è stata sviluppata al MIT. Si sta estendendo a Cornell. PolyJ utilizza una sintassi leggermente diversa da GJ / NextGen. Inoltre differisce leggermente nell'uso di tipi generici. Ad esempio, non supporta la parametrizzazione del tipo dei singoli metodi e attualmente non supporta le classi interne. Ma a differenza di GJ o NextGen, consente di istanziare le variabili di tipo con tipi primitivi. Inoltre, come NextGen, PolyJ supporta operazioni di runtime su tipi generici.

Sun ha rilasciato una JSR (Java Specification Request) per l'aggiunta di tipi generici alla lingua. Non sorprende che uno degli obiettivi chiave elencati per qualsiasi sottomissione sia il mantenimento della compatibilità con le librerie di classi esistenti. Quando i tipi generici vengono aggiunti a Java, è probabile che una delle proposte discusse sopra servirà da prototipo.

Ci sono alcuni programmatori che si oppongono all'aggiunta di tipi generici in qualsiasi forma, nonostante i loro vantaggi. Farò riferimento a due argomenti comuni di tali oppositori come l'argomento "i modelli sono malvagi" e l'argomento "non è orientato agli oggetti", e affronterò ciascuno di essi a turno.

I modelli sono cattivi?

C ++ utilizza modelliper fornire una forma di tipi generici. I modelli hanno guadagnato una cattiva reputazione tra alcuni sviluppatori C ++ perché le loro definizioni non sono controllate dal tipo in forma parametrizzata. Al contrario, il codice viene replicato a ogni istanza e ogni replica viene controllata separatamente. Il problema con questo approccio è che potrebbero esistere errori di tipo nel codice originale che non vengono visualizzati in nessuna delle istanze iniziali. Questi errori possono manifestarsi in seguito se le revisioni o le estensioni del programma introducono nuove istanze. Immagina la frustrazione di uno sviluppatore che utilizza classi esistenti che controllano il tipo quando vengono compilate da sole, ma non dopo aver aggiunto una nuova sottoclasse perfettamente legittima! Peggio ancora, se il modello non viene ricompilato insieme alle nuove classi, tali errori non verranno rilevati, ma corromperanno invece il programma in esecuzione.

A causa di questi problemi, alcune persone disapprovano riportare i modelli, aspettandosi che gli svantaggi dei modelli in C ++ si applichino a un sistema di tipi generico in Java. Questa analogia è fuorviante, perché le basi semantiche di Java e C ++ sono radicalmente diverse. Il C ++ è un linguaggio non sicuro, in cui il controllo statico del tipo è un processo euristico senza fondamento matematico. Al contrario, Java è un linguaggio sicuro, in cui il controllo di tipo statico dimostra letteralmente che determinati errori non possono verificarsi quando il codice viene eseguito. Di conseguenza, i programmi C ++ che coinvolgono modelli soffrono di una miriade di problemi di sicurezza che non possono verificarsi in Java.

Inoltre, tutte le proposte di spicco per un Java generico eseguono un controllo statico esplicito del tipo delle classi parametrizzate, piuttosto che farlo solo ad ogni istanza della classe. Se sei preoccupato che tale controllo esplicito possa rallentare il controllo del tipo, puoi star certo che, in realtà, è vero il contrario: poiché il controllo del tipo esegue un solo passaggio sul codice parametrizzato, invece di un passaggio per ogni istanziazione del tipi parametrizzati, il processo di controllo del tipo è accelerato. Per questi motivi, le numerose obiezioni ai modelli C ++ non si applicano alle proposte di tipo generico per Java. In effetti, se si guarda oltre a ciò che è stato ampiamente utilizzato nel settore, ci sono molti linguaggi meno popolari ma molto ben progettati, come Objective Caml ed Eiffel, che supportano i tipi parametrizzati con grande vantaggio.

I sistemi di tipo generico sono orientati agli oggetti?

Infine, alcuni programmatori si oppongono a qualsiasi sistema di tipo generico sulla base del fatto che, poiché tali sistemi sono stati originariamente sviluppati per linguaggi funzionali, non sono orientati agli oggetti. Questa obiezione è falsa. I tipi generici si adattano molto naturalmente a un framework orientato agli oggetti, come dimostrano gli esempi e la discussione sopra. Ma sospetto che questa obiezione sia radicata nella mancanza di comprensione di come integrare tipi generici con il polimorfismo dell'ereditarietà di Java. In effetti, tale integrazione è possibile ed è la base per la nostra implementazione di NextGen.