|
Dr. Carlo Pescio Programmazione ad Oggetti e Programmazione Generica |
Pubblicato su Computer Programming No. 62
L'idea di scrivere un articolo "di confronto" tra programmazione
ad oggetti e programmazione generica nasce in gran parte dalla
lettura dell'intervista ad Alexander Stepanov [LoR97] in forma
preliminare, e da una successiva, lunga discussione sia con Graziano
Lo Russo che con lo stesso Stepanov. In particolare, durante la
discussione è emerso chiaramente che il rapporto tra i
due stili di programmazione, i pro ed i contro di ogni approccio,
l'interazione tra paradigma e linguaggio sono decisamente poco
chiari anche tra gli esperti. Anche perché la maggior parte
degli articoli che affrontano l'argomento sono di stampo decisamente
accademico, e discutono molto bene i temi dal punto di vista astratto
(teoria dei tipi) ma danno ben pochi appigli per chi non è
abituato a masticare algebre, o voglia una discussione ancorata
su elementi concreti. Qualcosa, insomma, di reale utilità
per il progettista o il programmatore che voglia compiere delle
scelte oculate, anziché farsi guidare dalla moda del momento.
Partendo dalla radice comune della programmazione ad oggetti e
della programmazione generica (il polimorfismo), in quanto segue
vedremo come i due approcci si differenzino nella soluzione proposta,
e quali siano i vantaggi e gli svantaggi di entrambi. Nel fare
questo cercherò di rimanere il più possibile vicino
ai linguaggi di programmazione, facendo riferimenti concreti al
C++ e Java. Vedremo ad esempio che alcune delle accuse mosse da
Stepanov alla programmazione ad oggetti sono invece da imputare
a limitazioni del C++ o di Java, mentre linguaggi come Eiffel
ne sono immuni. E ancora, che alcuni tratti pericolosi della programmazione
generica sono presenti solo in C++, mentre Ada o ML non ne risentono.
Vedremo cosa comporta la mancanza dei template in Java, e come
scegliere tra template o polimorfismo ad oggetti in C++.
L'articolo è un po' lungo se confrontato con i miei canoni
abituali, ma d'altra parte l'argomento è molto importante
e distillandolo eccessivamente si rischierebbe di vanificare
lo sforzo di chiarificazione. Agli impazienti consiglio di leggere
le prime due parti (genesi e polimorfismo) e passare direttamente
all'ultima (confronto finale), tornando eventualmente alle parti
saltate per una migliore comprensione o un approfondimento.
Genesi
Cosa significa scrivere del buon codice? Per alcuni, significa
spingere il sistema al suo limite: limare ogni ciclo di clock,
arrivare là dove nessun uomo è mai giunto prima.
Per altri, significa fornire le migliori funzionalità all'utente,
nel minor tempo ed al minor costo possibile, indipendentemente
da come si arriva al risultato. Per altri ancora, significa raggiungere
il miglior equilibrio di molte forze: la soddisfazione dell'utente,
la facilità di estensione del programma, la comprensibilità
delle soluzioni usate ma anche la necessità di usare tecniche
sofisticate per gestire la crescente complessità delle
applicazioni, e così via. Per poter raggiungere questo
punto di equilibrio, è necessario che il linguaggio a nostra
disposizione sia sufficientemente potente e completo da permetterci
di scegliere tra diverse soluzioni alternative: dobbiamo essere
noi a compiere ogni scelta, non il linguaggio o il tool che utilizziamo.
Due esigenze primarie nello sviluppo professionale del codice
sono il riuso di parti già realizzate e la possibilità
di estendere un programma senza doverlo modificare troppo.
Entrambe sono in parte motivate dalla maggiore rapidità
di sviluppo, ma in larga misura anche dalla qualità
del prodotto risultante: riusare un componente già utilizzato
con successo significa diminuire i rischi di malfunzionamento;
minimizzare il numero di modifiche significa ridurre il rischio
che l'estensione di funzionalità si rifletta in un malfunzionamento
di parti già implementate.
Scrivere codice riusabile ed estendibile non è affatto
semplice, e sicuramente costa di più rispetto alla scrittura
di codice custom, utilizzabile in una sola evenienza. Il polimorfismo,
nelle sue diverse incarnazioni, nasce per rendere più agevole
la scrittura di codice riusabile; sotto opportune condizioni,
permette anche di scrivere codice facilmente estendibile.
Polimorfismo
I linguaggi di programmazione tradizionali (Pascal, C, ecc) sono
basati sull'idea che le funzioni, e quindi i loro parametri, abbiano
un solo tipo. Se vogliamo scambiare due interi in ANSI C, dobbiamo
scrivere una funzione swapInt come segue:
void swapInt( int* a, int* b )
{
int t = *a ;
*a = *b ;
*b = t ;
}
provando ad utilizzare swapInt con parametri di tipo diverso da
int* il compilatore segnalerà un errore: questo è
il ruolo principale dei tipi nella pratica della programmazione,
ovvero la prevenzione di una categoria di errori molto comuni.
Purtroppo la prevenzione degli errori ha spesso un costo nascosto,
di norma in termini di ridotta flessibilità del linguaggio.
In questo caso particolare, un linguaggio monomorfo ci
costringerà a riscrivere funzioni comuni, come swap, sort,
find, eccetera, per ogni struttura dati. Analogamente, ci costringerà
a riscrivere strutture dati comuni (liste, alberi, tabelle) per
ogni tipo di dato utilizzato. Ci impedirà anche di dare
nomi uguali a funzioni che sono concettualmente uguali
(lo Show di un bottone non è diverso dallo Show di una
listbox dal punto di vista concettuale) ma che sono implementate
in modo diverso. I programmatori C conoscono ovviamente le scappatoie
introdotte dal linguaggio: le macro e l'uso dei cast. Entrambi
i meccanismi saltano il controllo dei tipi, ma ci consentono di
scrivere una funzione come qsort che può essere riutilizzata
su strutture dati diverse con uno sforzo contenuto, o una macro
come max che può essere usata con tipi diversi. L'idea
del polimorfismo nasce proprio come bilanciamento di queste
due esigenze: controllo stretto dei tipi per scoprire eventuali
errori a tempo di compilazione, ma un rilassamento di alcune restrizioni
per non essere costretti a scavalcare il meccanismo dei tipi.
Altri linguaggi prendono una strada diversa, rinunciando del tutto
al controllo sui tipi a tempo di compilazione e spostandolo a
run-time. Ovviamente, in quel caso rinunciamo alla possibilità
di trovare eventuali errori di chiamata al momento della compilazione.
La classificazione data da Cardelli e Wegner è riportata in figura 1: il polimorfismo è dapprima diviso in due categorie (universale e ad-hoc) e poi in quattro sottocategorie. L'idea del polimorfismo universale è quella a cui siamo più abituati: funzioni che possono operare su un numero infinito di tipi, purché questi rispettino alcune proprietà. Il modo in cui definiamo queste proprietà porta alla distinzione tra polimorfismo parametrico (quello della programmazione generica) e polimorfismo per inclusione (quello dell'OOP). Prima di proseguire sul polimorfismo universale, può essere interessante soffermarci un istante sul polimorfismo ad-hoc: è qualcosa che tutti abbiamo usato, anche in C o in Pascal, spesso senza rendercene pienamente conto. Quando scriviamo (in Pascal):
write( 42 ) ; write( "pippo" ) ;
oppure (in C):
int x = 4 + 2 ; double y = 2.1 + 2.1 ;sfruttiamo un particolare tipo di polimorfismo, ovvero l'overloading. Infatti la procedura write del Pascal è polimorfa (possiamo chiamarla con parametri di tipo differente) ed anche l'operatore + in C come in Pascal è polimorfo: non solo il + tra interi e quello tra double hanno tipo diverso, ma anche il codice associato è decisamente diverso. Ciò che non possiamo fare, in C o in Pascal, è definire nuove funzioni overloaded: si tratta di una caratteristica ristretta ad alcuni elementi built-in del linguaggio. L'overloading non pretende di "catturare" proprietà universali di un insieme infinito di tipi: più modestamente, si accontenta di definire una funzione su un certo tipo, dandole lo stesso nome già usato per altri tipi. Il risultato è di avere lo stesso nome di funzione associato ad un numero finito di tipi. Spesso si dice che il dominio delle funzioni overloaded è definito in forma estensionale, ovvero per enumerazione dei suoi elementi, mentre quello delle funzioni a polimorfismo universale è definito in forma intensionale, ovvero indicando le proprietà degli elementi, ma non gli elementi stessi.
double y = 4 + 2.0 ;In C non esiste nessun operatore + che prenda un intero ed un double e restituisca un double. In questo caso, però, il parametro di tipo intero viene "promosso" a double prima di essere sommato. Questo consente di usare l'operatore +(double,double) su un insieme di tipi più grande di quello identificato dalla sua signature.Come vedremo più avanti, sia l'overloading che la coercion giocano un ruolo molto importante nello stile di programmazione generica adottato comunemente in C++, mentre ciò non avviene in altri linguaggi, come Ada ed ML.
Polimorfismo parametrico
Riprendiamo l'esempio della funzione swapInt. È evidente
che potremmo riscriverla per scambiare (ad esempio) due elementi
di tipo char semplicemente sostituendo tutte le occorrenze di
int con char. L'idea del polimorfismo parametrico (che sta alla
base dei template del C++, dei generic package di Ada, e delle
generic class di Eiffel) sta proprio nel riconoscere che il tipo
di alcuni parametri può essere a sua volta un parametro.
In C++ scriveremo quindi:
template< class T >
void swap( T* a, T* b )
{
T t = *a ;
*a = *b ;
*b = t ;
}
per indicare una famiglia di funzioni, parametrizzate sul tipo
T, che possono scambiare elementi di tipo T. Spesso, tuttavia,
una funzione non può realmente operare su parametri di
tipo qualunque. Ad esempio, una funzione max( T a, T b
) deve supporre che sul tipo T esista una relazione d'ordine.
Questo tipo di genericità si chiama di solito "genericità
con vincoli", in quanto si impongono dei vincoli sul tipo
T. Come possiamo esprimere questi vincoli? Non esiste un approccio
che venga generalmente riconosciuto come superiore, ma esistono
due grandi scuole di pensiero, quella dei vincoli espliciti
(rappresentata da Ada ed ML) e dei vincoli impliciti (rappresentata
dal C++). Vale la pena di vedere la differenza, perché
si tratta a mio avviso di uno dei punti più subdoli del
C++, che rende l'uso dei template più complicato e soggetto
ad errori di quanto si voglia normalmente ammettere.
generic(
type T,
function "<"(u,v : T)
return BOOLEAN )
function max( a, b : T )
return T is
begin
if( a < b ) then
return( b ) ;
else
return( a ) ;
end max ;
Commentiamola riga per riga, dato che Ada non è certamente
il linguaggio più popolare tra i lettori. La funzione max
è una funzione generica. I parametri di max sono a e b,
entrambi di tipo T, così come il risultato della funzione.
T è un parametro di tipoma non tutti i tipi vanno
bene per T. Richiediamo quindi come parametro una funzione
"<", che prende due parametri di tipo T e restituisce
un booleano. Naturalmente, non siamo in grado di imporre che "<"
obbedisca realmente alle proprietà desiderate (potremmo
comunque aggiungere delle asserzioni), ma in ogni caso rendiamo
noto esplicitamente cosa ci aspettiamo che sia disponibile
per il tipo T.
template< class T >
T max( const T& a, const T&b )
{
if( a < b )
return( a ) ;
else
return( b ) ;
}
La principale differenza (al di là del passaggio per const
reference) è che il parametro "<" sparisce,
o meglio diventa implicito: è comunque necessario
che valori di tipo T siano confrontabili tramite "<".
Potremmo pensare che tutto si risolva "guardando l'implementazione",
ma le cose non sono così semplici. Innanzitutto, potrebbero
esserci molti parametri impliciti: non tutte le funzioni sono
di poche righe come max. Se max chiama altre funzioni generiche,
a loro volta con parametri impliciti su T, è abbastanza
semplice perdere la percezione esatta dei vincoli che stiamo imponendo
al parametro. Ma ancora peggio (molto peggio), ora entrano in
gioco anche le regole di name lookup, l'overloading e la coercion,
rendendo il quadro decisamente preoccupante. Vediamo un esempio
molto semplice:
class String
{
public :
String( const char* s ) ;
operator const char*() const ;
// ... NON definisco "<"
} ;
String s1 = "a" ;
String s2 = "b" ;
String s3 = max( s1, s2 ) ;
Supponiamo di non aver implementato l'operatore "<"
per la classe String. In teoria, non potremmo quindi chiamare
max con parametri di tipo String. Invece il codice di cui sopra
compila allegramente, ed il risultato sarà ovviamente molto
diverso da quello che ci potremmo aspettare. L'esempio dato qui
è molto banale, e capire la causa dell'errore è
di conseguenza molto semplice. Pensate però ad una situazione
reale, dove le funzioni e classi generiche sono decisamente più
complesse, le righe decine di migliaia, i file almeno diverse
decine, i parametri parecchi di più. Trovare questo tipo
di errori diventa tutt'altro che facile, e si perde una buona
parte dei vantaggi che avevamo acquisito introducendo il controllo
statico dei tipi, il cui ruolo era proprio di impedirci chiamate
insensate come quella sopra.
Polimorfismo per inclusione
L'approccio object-oriented al polimorfismo è molto diverso,
e trova le sue radici nell'intelligenza artificiale, le reti semantiche,
le tassonomie. L'idea di fondo è molto semplice: anziché
definire una funzione come max rendendola parametrica rispetto
ai tipi trattati, definiamo la categoria (classe) degli elementi
su cui ha senso applicare la funzione max. Quindi, mentre nel
polimorfismo parametrico partiamo dalle funzioni e potenzialmente
lasciamo le proprietà degli elementi implicite, nel polimorfismo
ad oggetti cerchiamo immediatamente di identificare le proprietà
degli elementi cui possiamo applicare le funzioni. Una volta definita
tale classe, tutte le classi da essa derivate ne erediteranno
le funzioni, con la possibilità di ridefinirle. Questo
è il punto centrale del polimorfismo per inclusione:
gli oggetti appartenenti ad una classe derivata appartengono anche
alla classe base (quindi la classe derivata è inclusa
nella classe base), e ne hanno tutte le proprietà e funzioni,
che vengono in questo modo riutilizzate per tipi diversi. L'ereditarietà
ha quindi un ruolo centrale nel polimorfismo per inclusione;
in realtà lo stesso ruolo potrebbe essere coperto dalla
delegation o dall'aggregation (come nel modello COM alla base
di OLE2) ma in quanto segue farò riferimento alla sola
ereditarietà.
Prima di passare ai casi un po' più problematici cui si
riferiva Stepanov, vediamo un semplice esempio spesso utilizzato
per dimostrare i benefici dell'OOP,. Supponiamo di voler implementare
un piccolo CAD bidimensionale: avremo la possibilità di
disegnare diversi tipi di figure elementari e di manipolarle in
vari modi. Vogliamo poter aggiungere nuovi tipi di figura senza
dover variare il resto del codice (estendibilità) e naturalmente
riusare parti comuni senza doverle riscrivere. Una semplice gerarchia
di classi potrebbe essere la seguente:
class Shape
{
public :
virtual void Show() = 0 ;
virtual void Move( int x, int y ) = 0 ;
// ecc
} ;
class Rectangle :
public Shape
{
} ;
class Circle :
public Shape
{
} ;
// ecc
La classe Shape definisce l'interfaccia comune a tutte le figure;
eventualmente potrebbe anche implementare funzionalità
comuni. L'applicazione CAD permetterà di creare oggetti
di classe derivata da Shape e li memorizzerà in qualche
struttura dati come puntatori a Shape. Estendere l'applicazione
significa sostanzialmente aggiungere una nuova classe derivata.
Tutto il codice che fa riferimento ai vari elementi attraverso
la sola interfaccia di Shape non dovrà neppure essere ricompilato:
le nuove figure potrebbero anche essere delle DLL aggiuntive.
La stessa tecnica è utilizzabile in molte altre situazioni:
pensiamo ad una gerarchia di classi per l'I/O su dispositivi diversi,
eccetera.
class Ordered
{
public :
virtual bool
LessThan( Ordered& o ) = 0 ;
Ordered& Max( Ordered& o )
{
if( o.LessThan( *this ) )
return( *this ) ;
else
return( o ) ;
}
} ;
Se vogliamo che la funzione Max sia applicabile agli elementi
della nostra classe, dobbiamo derivarla da Ordered e fornirla
di una funzione di comparazione LessThan. Ovviamente questo crea
subito qualche problema in un linguaggio come il C++, dove alcuni
tipi (come int, double, ecc) non fanno parte del type system object
oriented. Quindi non solo non derivano da Ordered, ma non possiamo
neppure derivare una classe IntOrdered da int e da Ordered: dobbiamo
introdurre una classe intermedia Int che incapsuli il tipo int
e ci permetta di riusarlo per ereditarietà. Se anche fossimo
disposti a passare sopra al problema, che è legato ad un
particolare linguaggio e non alla programmazione ad oggetti, ci
troveremmo comunque di fronte ad altri problemi. Supponiamo di
voler definire ex-novo la nostra classe di "interi ordinati":
probabilmente scriveremmo qualcosa di simile:
class IntOrdered :
public Ordered
{
public :
virtual bool
LessThan( IntOrdered& o ) ;
// altro...
private :
int x ;
} ;
Ma questo non va bene in un linguaggio come il C++ (o Java).
La funzione LessThan, che abbiamo scritto così perché
deve confrontare due IntOrdered, non ha la stessa signature della
funzione LessThan dichiarata in Ordered, quindi anziché
ridefinire Ordered::LessThan stiamo introducendo una nuova funzione.
In C++ siamo dunque costretti a definire IntOrdered come segue:
class IntOrdered :
public Ordered
{
public :
virtual bool
LessThan( Ordered& o ) ;
// altro...
private :
int x ;
} ;
Osserviamo che ora IntOrdered::LessThan riceve un parametro di
tipo Ordered, non IntOrdered. Siccome per eseguire il confronto
dobbiamo accedere al campo o.x, potremmo essere tentati di risolvere
tutto con un cast:
bool IntOrdered ::
LessThan( Ordered& o )
{
return( x <
(IntOrdered&)o.x ) ;
}
Facendo così, tuttavia, indeboliamo il controllo statico
dei tipi. Supponiamo di avere un'altra classe derivata da Ordered:
class PippoOrdered :
public Ordered
{
// ...
} ;
Potremmo tranquillamente chiedere il massimo tra un IntOrdered
ed un PippoOrdered:
IntOrdered o ; PippoOrdered p ; o.Max( p ) ;Il codice verrebbe compilato senza problemi, ma ovviamente il risultato a run-time sarebbe indefinito, dato che il cast converte un PippoOrdered ad un IntOrdered. Per evitare il comportamento indefinito, dovremmo passare ad un dynamic_cast:
bool IntOrdered ::
LessThan( Ordered& o )
{
return( x <
dynamic_cast<IntOrdered&>(o).x ) ;
}
In questo caso, il codice "errato" di cui sopra continuerebbe
ad essere compilato, ma genererebbe un'eccezione bad_cast
a run-time; ovviamente, tutto girerebbe a dovere se eseguissimo
solo confronti leciti. Pur funzionando perfettamente, questa soluzione
lascia molto a desiderare: stiamo utilizzando il C++ come se fosse
Smalltalk, rimandando il controllo sui tipi a run-time. È
anche poco efficiente, in quanto dynamic_cast ha un overhead non
nullo in fase di esecuzione. Prendendo lo spunto da un esempio
semplice come Max, è facile arrivare alla conclusione di
Stepanov: se l'OOP non funziona neppure con Max, come possiamo
pensare che funzioni su problemi più complessi? Purtroppo
questa affermazione rivela una comprensione molto parziale dell'OOP,
unita ad una confusione tra la programmazione ad oggetti e la
sua implementazione in C++. Per capire meglio il problema è
necessario fare un passo indietro e riconsiderare il problema
sganciato da un singolo linguaggio di programmazione.
Controllo dei tipi ed OOP
Torniamo per un momento al primo tentativo di implementare la
classe IntOrdered in C++. Il tentativo è fallito perché
la signature di IntOrdered::LessThan (che ci avrebbe evitato il
cast) non risultava compatibile con quella di Ordered::LessThan.
Lo stesso avverrebbe in Java, che come vedremo è ancora
più restrittivo del C++. Ma questo non significa
che si tratti di un limite intrinseco dell'OOP: altri linguaggi,
come Eiffel o Transframe, ne sono del tutto immuni. Capire l'origine
del problema ci aiuterà non solo a comprendere meglio le
basi dell'OOP, ma anche a identificare al volo le situazioni che
possono creare problemi in C++ o Java.
La maggior parte dei linguaggi ad oggetti è basata sull'idea
che una sottoclasse equivalga ad un sottotipo. Questa idea è
spesso ricordata con il nome di Principio di Sostituibilità
di Liskov [Lis88], che si propone di chiarire cosa significhi
"derivare" un sottotipo (o ammettendo l'equivalenza,
una sottoclasse), concetto troppo spesso lasciato ad un laconico
"B is-a A". La definizione data è comportamentale:
B è un sottotipo di A se e solo se, per ogni programma
che usi oggetti di classe A, posso utilizzare al loro posto oggetti
di classe B e lasciare immutato il comportamento [logico] del
programma. Il principio di sostituibilità è molto
più restrittivo di quanto appaia a prima vista, ed ha aperto
la strada ad una serie di paradossi apparenti. L'esempio più
famoso è quello del Quadrato-Rettangolo: definita una classe
Rettangolo che permetta di variare i lati in modo indipendente,
non possiamo derivare da essa una classe Quadrato, perché
in un Quadrato non possiamo modificare un lato lasciando immutato
l'altro. In generale, se vogliamo rispettare il principio di sostituibilità
dobbiamo usare l'ereditarietà (pubblica) solo quando estendiamo
una classe o quando ridefiniamo l'implementazione di alcune
funzioni, ma non l'operazione astratta associata a tale funzione.
Non possiamo utilizzarla per restringere la classe base
(come nel caso del Quadrato, più restrittivo di un Rettangolo),
altrimenti non potremmo usare la classe derivata (più ristretta)
ovunque si possa usare la classe base (più generale).
Rispettando questa visione dell'ereditarietà, linguaggi
come il C++ o Java impongono delle restrizioni sulla ridefinizione
delle funzioni nelle classi derivate. Più precisamente,
se abbiamo una classe Base definita come segue:
class Base
{
public :
virtual
A f( A1, ..., An ) ;
} ;
e da questa deriviamo una classe Derived, possiamo ridefinire
f solo se il tipo del risultato e dei parametri rispetta
alcune regole, altrimenti introduciamo una nuova funzione chiamata
f, non una ridefinizione di Base::f. In Java, la regola è
che f deve avere esattamente lo stesso tipo sia per il
risultato che per i parametri. Il C++ è più potente,
e ci permette di ridefinire f purché i parametri siano
dello stesso tipo, ed il risultato sia dello stesso tipo oppure,
se si tratta di puntatore o reference, di un puntatore o reference
ad una classe derivata da quella specificata in Base::f.
Questa regola viene normalmente detta controvarianza, perché
nella sua formulazione più generale (non implementata in
C++), i parametri di f potrebbero invece variare nel senso opposto,
ovvero fare riferimento a classi base rispetto a quelle
specificate in Base::f. Limitare il tipo dei parametri secondo
la regola della controvarianza ci consente di verificare facilmente
a tempo di compilazione che il principio di sostituibilità
venga rispettato, almeno per quanto riguarda la signature delle
funzioni. Per questa ragione è così popolare tra
i linguaggi con type checking statico, e bisogna dire che si comporta
benissimo in molte occasioni: ad esempio, la gerarchia di oggetti
grafici vista inizialmente si sviluppa in perfetta armonia con
la regola di controvarianza, ed anche con la più restrittiva
convenzione imposta da Java.
Confronto finale
In un linguaggio come Eiffel, la programmazione generica è
semplicemente un mezzo più efficace per ottenere alcuni
risultati: non a caso, il supporto per le classi generiche in
Eiffel è molto ridotto rispetto al C++, e sostanzialmente
consente di utilizzare il polimorfismo parametrico solo quando
non esistono vincoli sui parametri, scavalcando a pié
pari tutto il dibattito tra vincoli espliciti o impliciti. Siccome,
però, non si può dire che Eiffel sia un linguaggio
molto diffuso tra i programmatori professionisti, è importante
capire le conseguenze di quanto sopra rispetto alla programmazione
di ogni giorno.
Il polimorfismo parametrico si basa sul concetto di classe/funzione
generica che viene poi istanziata. Il processo di istanziazione
fissa il tipo dei parametri al momento della compilazione
(static binding). Questo significa che non dovremo mai utilizzare
dei cast per "recuperare" il tipo dei parametri come
avveniva in IntOrdered, perché usando solo i template
non abbiamo alcuna limitazione dovuta alla regola di controvarianza
(che ricordiamo, è legata al concetto di sottoclasse-sottotipo,
assente nella programmazione generica).
Tuttavia, proprio la necessità di istanziare il codice
generico ci impedisce di estendere il codice
senza ricompilazioni più o meno massicce. Ci impedisce
anche di trattare in modo uniforme oggetti di classe diversa,
ma che implementano la stessa interfaccia: con la sola
programmazione generica, non potremmo mai memorizzare figure geometriche
diverse in una sola lista e manipolarle attraverso una classe
base Shape. Dovremmo ricompilare tutto il codice che usa le figure
ogni volta che aggiungiamo un tipo di figura. Dovremmo utilizzare
liste diverse per ogni tipo di figura, dove ogni lista è
un'instanza di una classe Lista generica: un difetto enorme il
cui unico rimedio type-safe è la reintroduzione del polimorfismo
ad oggetti.
Viceversa, la programmazione ad oggetti ci consente con un minimo
di cautela di creare codice facilmente estendibile. In alcuni
casi il riuso per ereditarietà e più complesso del
dovuto in linguaggi come Java o C++, quando il problema richieda
la covarianza per essere risolto al meglio.
Se programmiamo in Java, la scelta del polimorfismo ad oggetti
è obbligata. In questo caso, i binary method sono dei segnali
di allarme: parti di codice da testare con più attenzione
perché il comportamento a run-time può riservare
delle sorprese.
In un linguaggio come il C++, che offre sia i template che il
polimorfismo ad oggetti, il mio consiglio è di usare i
template solo quando sono coinvolti i binary method, o quando
si rischia in modo analogo di perdere informazioni sui tipi per
la mancanza di covarianza nel linguaggio. Ad esempio, le classi
contenitore si implementano molto meglio con i template che con
il polimorfismo ad oggetti. Ovunque si voglia lasciare la porta
aperta alle estensioni, tuttavia, è necessario fare uso
del polimorfismo ad oggetti, pagando se necessario il costo di
un controllo run-time. I template del C++, dal mio punto di vista,
sono principalmente un utile strumento di implementazione, estremamente
flessibile ma potenzialmente insicuro e limitato rispetto al polimorfismo
ad oggetti, e come tale da nascondere sotto uno strato ad oggetti.
Si tratta naturalmente di una opinione personale, seppure motivata
dall'esperienza e condivisa da altri esperti [LK97]. In ogni caso,
prima di adottare una soluzione o l'altra fermatevi a riflettere
su cosa volete realmente ottenere, e quale delle due soluzioni
si avvicina maggiormente a quella ideale: una volta compresi i
meccanismi di fondo, vedrete che la scelta è spesso più
semplice di quanto possa sembrare.
Bibliografia:
[BCCLP95] Kim Bruce, Luca Cardelli, Giuseppe Castagna, Gary T.
Leavens, Benjamin Pierce, "On Binary Methods", Technical
Report, Ecole Normale Supérieure, Paris, 1995.
[Cas94] Giuseppe Castagna, "Covariance and Contravariance:
Conflict without a Cause", Technical Report, Ecole Normale
Supérieure, Paris, 1994.
[CW85] Luca Cardelli, Peter Wegner, "On Understanding Types,
Data Abstraction, and Polymorphism", ACM Computer Surveys,
December 1985.
[Koe97a] Andrew Koenig, "Comparison and Orders-Based Containers",
C++ Report, June 1997.
[Koe97b] Andrew Koenig, "Null Iterators", C++ Report,
February 1997.
[Lis88] Barbara Liskov, "Data Abstraction and Hierarchy",
ACM SIGPLAN Notices, May 1988.
[LK97] Angelika Langer, Klaus Kreft, "Combining Object-Oriented
Design and Generic Programming", C++ Report, March 1997.
[LoR97] Graziano Lo Russo, "Intervista ad Alexander Stepanov",
Computer Programming No. 60, luglio/agosto 1997.
[Mey86] Bertrand Meyer, "Genericity versus Inheritance",
Proceedings of OOPSLA '86.
Biografia
Carlo Pescio (pescio@acm.org) svolge attività di consulenza in ambito internazionale nel campo
delle tecnologie Object Oriented. Ha svolto la funzione di Software Architect in grandi progetti per
importanti aziende europee e statunitensi. È incaricato della valutazione dei progetti dal
Direttorato Generale della Comunità Europea come Esperto nei settori di Telematica e Biomedicina.
È laureato in Scienze dell'Informazione ed è membro dell'ACM, dell'IEEE e della New York Academy
of Sciences.