Programmazione ad Oggetti e Programmazione Generica

Pubblicato su Computer Programming No. 62

La programmazione generica viene talvolta proposta come alternativa alla programmazione ad oggetti. Entrambe hanno una radice comune, ma si differenziano nella soluzione proposta. In questo articolo vedremo quali sono i vantaggi e gli svantaggi di entrambe, cosa comporta la mancanza dei template in Java, e come scegliere tra template o polimorfismo ad oggetti in C++.


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.
È importante capire bene il legame tra programmazione ad oggetti e polimorfismo: per quanto la tendenza moderna sia di chiamare "polimorfismo" il "polimorfismo per inclusione" tipico della programmazione ad oggetti, il polimorfismo in sé è un concetto molto più generale, che è stato implementato in tanti modi diversi. Alcuni di essi, come vedremo, sono presenti anche in Pascal ed in C: non sono però controllabili dal programmatore. Già alla fine degli anni sessanta si è cercato di dare una classificazione del polimorfismo, ma per arrivare ad una caratterizzazione più completa occorre attendere sino alla metà degli anni 80 [CW85]. La discussione si basava in gran parte su concetti come il lambda-calcolo tipato, e ben poco ha raggiunto il grande pubblico dei programmatori nei dodici anni seguenti; tuttavia, i concetti di fondo sono molto semplici e conoscerli ci aiuterà a capire meglio le analogie e le differenze tra l'approccio ad oggetti e quello generico.

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.
L'ultimo tipo di polimorfismo classificato da Cardelli/Wegner è la coercion; vediamo un semplice esempio in C:
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.
In Ada, possiamo scrivere una funzione generica max come segue:
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.
L'approccio del C++ è diverso. Apparentemente la differenza è minima, ma in realtà è piuttosto profonda. Seguendo lo stile reso popolare da STL, scriveremmo max come segue:
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.
Più in generale, il problema della definizione implicita dei parametri in C++ è che il compilatore semplicemente "prova" ad istanziare il template. Se trova un modo per farlo, l'istanziazione va a buon fine. "Trova il modo per farlo" è una frase decisamente ambigua, che coinvolge le regole con cui il compilatore deve cercare le funzioni membro o globali con la signature giusta o compatibile, eventualmente promuovere i parametri tramite coercion, applicando se il caso uno degli operatori di conversione definiti nella classe o uno dei costruttori con un solo parametro, tenendo conto dei valori di default sia sui parametri delle funzioni che dei template, dell'overloading, eccetera eccetera: la definizione di cosa accada in realtà occupa parecchie pagine dello standard. A completare il quadro, il programmatore non ha idea di come sia stato istanziato il template: sa solo che il compilatore, in qualche modo, è riuscito a farcela. E questo è particolarmente preoccupante se pensiamo che, per le naturali carenze della documentazione, spesso i vincoli impliciti non saranno resi espliciti neanche altrove, e quindi anche il programmatore tenderà "a provarci". Del resto, la mia opinione è che ogni approccio allo sviluppo che richieda una documentazione perfetta ed un programmatore che vi fa costantemente riferimento non è basato sull'esperienza in progetti reali (e neppure realistici).
Ovviamente, un errore simile non potrebbe verificarsi in Ada, dove i vincoli devono essere tutti espliciti. Non potendo passare un operatore di confronto con i tipi corretti come parametro della funzione generica, avremmo un errore di compilazione o, meglio ancora, ci accorgeremmo di non poterlo fare senza neppure compilare. La "scuola esplicita" rivela così la fondatezza dei propri principi; naturalmente devono esservi anche dei vantaggi nella scuola implicita, perché il C++ non è stato progettato da degli sprovveduti. Per vederli, occorre riconsiderare quanto sopra da un altro punto di vista: dove io ho dipinto il quadro di una complessità preoccupante, altri vedono le forme della flessibilità più esaltante. Torniamo per un momento alla definizione di max in Ada: io ho deciso in modo più o meno arbitrario che la funzione "<" doveva avere parametri di tipo T e risultato BOOLEAN. In C++, avrei forse aggiunto che i parametri dovevano essere passati per riferimento (const), e avrei dovuto distinguere tra un operatore globale o membro. In effetti, tutto questo può essere visto come troppo restrittivo. Su un particolare tipo, ad esempio, potrebbe essere più efficiente passare i parametri per valore che per riferimento, ma avendo definito nei vincoli espliciti la signature di "<", non avrei avuto alcuna scelta. L'approccio implicito mi permette di non imporre alcun vincolo aggiuntivo tranne quelli strettamente necessari affinché il codice compili. Nel fare questo, rende il codice generico più flessibile, ma anche potenzialmente più insicuro. Se il trade/off sia positivo o negativo è un problema di valutazione soggettiva, e come tale difficile da affrontare in modo neutro; ad esempio, recentemente Andrew Koenig ha presentato l'approccio del C++ come l'unico ragionevole [Koe97a], per quanto esistano ovviamente soluzioni alternative, anche a metà strada tra quella di Ada e quella del C++, così come la possibilità di scrivere codice C++ che si avvicina molto allo stile di Ada, pur complicandosi parecchio la vita. Dal mio punto di vista (strettamente personale), l'attuale stato della programmazione generica in C++ si è spinto troppo oltre nella ricerca della flessibilità (in gran parte per soddisfare le esigenze di STL) a scapito della sicurezza, intesa sia come "accade solo quello che il programmatore decide consciamente che debba accadere", sia come "il programmatore può facilmente capire cosa sta accadendo". Anche i sostenitori del modello implicito, come Koenig, cominciano già a mettere in guardia sulla difficoltà di testare adeguatamente porzioni di codice generico scritte in tal modo [Koe97b]. La mia opinione è che i seguaci della scuola implicita siano coloro che preferiscono dare i vincoli a parole, per cullarsi nella beata ambiguità del linguaggio naturale, anziché prendersi la responsabilità di dare una specifica il più possibile formale all'interno del linguaggio di programmazione, accettandone le eventuali limitazioni. Gli stessi, insomma, che non vogliono mettere asserzioni, precondizioni e postcondizioni perché "così il codice è più flessibile". Solo l'esperienza dei prossimi anni, quando molti programmatori più o meno esperti si scontreranno con STL, dimostrerà quale delle due scuole è basata sui migliori principi.
La programmazione generica, naturalmente, non si esaurisce nella scrittura di qualche funzione con un parametro di tipo. Innanzitutto anche le classi possono essere parametrizzate (pensiamo alla famosa "lista di oggetti di tipo T" con T qualunque), e pur con i problemi legati al modello implicito del C++, rimane una tecnica di programmazione estremamente flessibile e potente, che vale la pena di conoscere ed utilizzare: vedremo più avanti per quale categoria di problemi sia più adatta del polimorfismo object oriented. Resta ancora in sospeso un punto molto importante, ovvero l'estendibilità del codice (sinora abbiamo visto solo che la programmazione generica ci aiuta a scrivere il codice una volta sola e a riusarlo in molti casi diversi). Questi elementi, però, diverranno più chiari quando avremo esaminato anche l'approccio object-oriented.

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.
Difficile dire che in casi simili l'OOP non funzioni; vi sono situazioni, tuttavia, in cui ci si scontra con qualche difficoltà. Torniamo all'esempio della funzione max: la classe degli elementi su cui ha senso definirla è quella degli elementi con una relazione d'ordine. In prima battuta scriveremmo quindi:
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.
Esistono però dei casi in cui la controvarianza mostra limiti evidenti: il più noto ed importante è quello in cui uno dei parametri della funzione Base::f abbia tipo Base (o puntatore/reference a Base). Queste funzioni vengono generalmente chiamate binary method [BCCLP95], intendendo che la classe Base appare due (o più) volte nella stessa funzione, una volta come "this" implicito, l'altra come parametro o risultato. I lettori più attenti avranno già capito che Ordered::LessThan è un binary method, in quanto prende come parametro un reference a Ordered. In genere, i binary method non si accordano con la regola di controvarianza, perché spesso si vuole ridefinirli in modo che prendano un parametro di classe derivata, proprio come nella nostra prima versione di IntOrdered. Questa possibilità esiste in alcuni linguaggi (tra cui Eiffel), e viene detta covarianza: nelle classi derivate, possiamo ridefinire una funzione e restringere il tipo dei suoi argomenti. In generale, la controvarianza permette di definire sottoclassi che sono sostituibili alla classe base (come le figure geometriche viste sopra), mentre la covarianza consente di definire sottoclassi che restringono la classe base [Cas94]. Tutti i problemi che abbiamo incontrato tentando di definire una gerarchia di classi Ordered in C++ semplicemente spariscono in un linguaggio ad oggetti con covarianza, rivelando l'infondatezza dell'affermazione di Stepanov riferita all'OOP in generale. Anzi, in un articolo fondamentale ripreso anche nel suo libro Object Oriented Software Construction, Bertrand Meyer [Mey86] dimostra che in un linguaggio con covarianza è possibile ricondurre al polimorfismo per inclusione tutti i casi di polimorfismo parametrico: ovvero, che la programmazione generica è un sottoinsieme della programmazione ad oggetti.
Esiste, naturalmente, un rovescio della medaglia: in un linguaggio con covarianza, è necessario un controllo run-time sui tipi o in alternativa un'analisi data-flow sull'intero programma per essere certi che non venga sovvertito il sistema dei tipi. Esistono molti algoritmi per eseguire il controllo a livello globale, ma sono tutti piuttosto complessi e per varie ragioni non sono stati presi in considerazione per il C++. È un vero peccato che la covarianza non sia stata considerata neppure per Java, dove sarebbe stata anche molto più semplice da implementare, e che addirittura abbiano deciso di restringere la parziale controvarianza permessa dal C++.

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.