|
Dr. Carlo Pescio Il problema della "fragile base class" in C++ |
Pubblicato su Computer Programming No. 41
Introduzione
Uno dei capisaldi della programmazione object oriented è sempre stato il riuso del software, la creazione di una biblioteca di componenti standard, riutilizzabili senza sforzo e con garanzie di correttezza, efficienza, e flessibilità.
In questo senso, come è facile comprendere analizzando
il mercato, la promessa non è stata mantenuta: il riutilizzo
del codice è comunemente praticato all'interno di un progetto,
ma più difficilmente tra diversi progetti, e l'acquisto
di un componente è relativamente raro rispetto allo sviluppo
di nuovo codice. Più spesso, ci si limita ad utilizzare
la libreria di classi ottenuta con il compilatore, ed un certo
numero di classi generali, o specializzate per il proprio dominio
del problema, che vengono create nello sviluppo di vari progetti;
l'idea di sviluppare un sistema complesso assemblando, in gran
parte, componenti preesistenti, è comunque molto lontana
dalla pratica della programmazione object oriented contemporanea.
A dire il vero, l'idea dei "componenti software" è molto più vecchia di quanto normalmente si pensi: uno dei primi riferimenti [1] è addirittura antecedente alla diffusione della programmazione strutturata; non si tratta quindi di una promessa introdotta con l'OOP, ma di un sogno che la comunità degli sviluppatori insegue da oltre trent'anni, e che sembra sempre riuscire ad eludere gli sforzi tesi a realizzarlo.
Negli ultimi anni, sono state identificate all'interno del paradigma
object oriented diverse cause che frenano la diffusione dei componenti;
alcune di esse sembrano intrinseche nel paradigma object oriented,
altre variano tra i diversi linguaggi. Per quanto riguarda il
C++, i due punti fondamentali sembrano essere, almeno dal punto
di vista tecnico:
Il primo punto è abbastanza noto a chiunque abbia cercato (raramente con successo) di utilizzare con un compilatore codice compilato con un altro. Si tratta di un'operazione normalmente possibile con altri linguaggi (es. il linguaggio C) ma che risulta spesso impossibile per il C++, dove non esiste alcuno standard riguardo il name mangling, il layout degli oggetti, il passaggio di parametri, e così via. Notiamo che la mancanza di uno standard, se da un lato crea problemi di interoperabilità, dall'altro lascia ai produttori dei compilatori maggiore discrezionalità, permettendo un maggiore sviluppo delle tecniche di ottimizzazione del codice.
Il secondo punto, da molti indicato come il maggiore problema
del paradigma object oriented, verrà affrontato in dettaglio
all'interno del presente articolo; come vedremo, si tratta in
effetti di un problema molto serio, che tuttavia può essere
spesso risolto con una progettazione più attenta delle
classi base. Più esattamente, vedremo come il problema
della fragile base class abbia origine in un vincolo, un "contratto"
spesso inespresso tra la classe base e le classi derivate, e come
tale vincolo sia legato solo a certi tipi di funzione. Vedremo
anche come documentando opportunamente tali funzioni, o evitando
di basarsi su alcuni dettagli non documentati durante la derivazione
di nuove classi, sia possibile evitare il problema in modo tutto
sommato semplice ed immediato.
Il problema della fragile base class
Non è semplice dare una definizione astratta del problema
senza cadere in un formalismo piuttosto pesante, ed infatti faremo
spesso riferimento ad un esempio concreto nel corso dell'articolo;
tuttavia, a beneficio di chi non conoscesse il problema in questione,
possiamo dire che esso si manifesta a causa di modifiche, introdotte
in fase di manutenzione all'interno di una classe base. Tali modifiche
apportano dei cambiamenti a dettagli interni della classe base,
che apparentemente non dovrebbero avere conseguenze sul funzionamento
delle classi derivate; tuttavia, il risultato è proprio
una alterazione nel comportamento delle classi derivate. Questa
"fragilità" della derivazione (che è solo
parzialmente corretto attribuire interamente alla classe base)
costituisce ovviamente un grosso limite alla programmazione per
componenti: se acquistate una nuova versione di un componente,
da voi riutilizzato tramite ereditarietà, non avete alcuna
garanzia che il vostro codice continuerà a funzionare,
anche se l'interfaccia del componente è rimasta invariata.
Vediamo un semplice esempio, il cui codice è riportato nel listato 1; la classe Document, di cui è data sia la dichiarazione che una porzione dell'implementazione, rappresenta un semplice documento, diviso in più pagine, con una funzione pubblica per la stampa dell'intero documento e diverse funzioni protette per stampare le singole parti del documento stesso (che sono state identificate, a titolo di esempio, in una intestazione del documento, una intestazione per le singole pagine, ed un certo numero di pagine). Tutti i dati sono privati, ed in sé la classe sembra ben progettata, fornendo un numero limitato di servizi molto vicini tra loro, ed avendo un'interfaccia apparentemente molto sicura.
Vale la pena di analizzare brevemente l'implementazione della
funzione Print(): essa modifica il valore di un membro
privato currentPage, e poi richiama in sequenza le due
funzioni PrintPageHeader() e PrintPage(), che faranno
riferimento a tale membro per ottenere il numero di pagina.
Listato 1
class Document
{
public :
virtual void Print() ;
protected :
virtual void PrintDocumentHeader() ;
virtual void PrintPage() ;
virtual void PrintPageHeader() ;
unsigned GetCurrentPage() ;
private :
Page pages[ MAX_PAGES ] ;
unsigned currentPage ;
unsigned numOfPages ;
} ;
void Document :: Print()
{
PrintDocumentHeader() ;
for( currentPage = 0; currentPage < numOfPages; currentPage++ )
{
PrintPageHeader() ;
PrintPage() ; // entrambe si basano su currentPage
}
}
void Document :: PrintPage()
{
pages[ currentPage ].Print() ;
}
// ...
Supponiamo ora che la classe Document non stampi il numero di pagina, e che sia per noi necessario aggiungere tale funzionalità; tutto ciò che dobbiamo fare è derivare una nuova classe da Document, e ridefinire la funzione PrintPageHeader() in modo che stampi il numero di pagina, e poi richiami la funzione originale di Document.
Come trovare il numero di pagina da stampare? Dobbiamo in effetti
leggere il codice della classe Document per saperlo (o
avere a disposizione una documentazione tecnica estremamente dettagliata
della classe Document, spesso non disponibile, o perlomeno
non al livello di precisione desiderato); in questo caso, il numero
di pagina da stampare si trova nella variabile currentPage,
e possiamo ottenerlo chiamando GetCurrentPage(). In realtà
dobbiamo sommare uno a tale valore, altrimenti il nostro documento
comincerà a pagina zero. La nostra classe derivata è
ora completa e funzionante (vedere listato 2).
Listato 2
class DocWithPageNum : public Document
{
protected :
virtual void PrintPageHeader() ;
} ;
void DocWithPageNum :: PrintPageHeader()
{
// stampa GetCurrentPage() + 1 ;
Document :: PrintPageHeader() ;
}
Notiamo che è già stato riscontrato il comune bisogno
di accedere al sorgente di una classe base per poterla efficacemente
riutilizzare tramite ereditarietà; si tratta di un fattore
che molti di voi avranno sicuramente sperimentato direttamente,
in quanto è estremamente frequente nella programmazione
in C++: così comune, che difficilmente si procede all'acquisto
di una libreria di classi se non viene fornito il sorgente. Naturalmente,
il sorgente dà anche altri vantaggi, come la possibilità
di tracciarne l'esecuzione in un debugger, eliminare eventuali
bug o effettuare un porting su un'altra piattaforma, ma il suo
uso nella derivazione è spesso controproducente. Esso comporta
infatti un accoppiamento troppo elevato tra la classe base e la
classe derivata, peggiorato dal fatto che si tratta di un accoppiamento
concettuale, non visibile nel codice stesso; come vedremo tra
breve, si tratta in realtà di un accoppiamento sul contesto
di chiamata. Le conseguenze di tale accoppiamento sono una eccessiva
dipendenza delle classi derivate dall'implementazione -si badi
bene, non dall'interfaccia- della classe base, cosicché
cambiando l'implementazione della classe base si possono creare
malfunzionamenti nelle classi derivate, anche se la modifica non
ha variato l'interfaccia della classe base.
Vediamo infatti come una modifica innocente al sorgente della
classe base può alterare il funzionamento della nostra
classe derivata: supponiamo che il responsabile della classe Document
decida di modificarne l'implementazione, come mostrato nel listato
3. La modifica è stata di poco conto: currentPage
riflette ora il numero "logico" della pagina, anziché
il suo indice "fisico" nell'array delle pagine; le ragioni
possono essere le più varie, e trattandosi di un esempio
molto semplificato non cercheremo di giustificarle. Tuttavia la
nostra classe derivata non funzionerà più correttamente,
in quanto stamperà i numeri di pagina a partire da due;
un errore molto innocente in questo piccolo esempio, ma potrebbe
essere molto più grave in un caso reale. Ecco quindi spuntare
il problema della "fragile base class".
Listato 3
void Document :: Print()
{
PrintDocumentHeader() ;
for( currentPage = 1; currentPage < numOfPages + 1; currentPage++ )
{
PrintPageHeader() ;
PrintPage() ; // entrambe si basano su pageNum
}
}
void Document :: PrintPage()
{
pages[ currentPage - 1 ].Print() ;
}
// ...
Si può dire molto a proposito del problema stesso; innanzitutto,
la modifica può o meno aver violato l'interfaccia della
classe: dipende da come era stata definita la semantica di GetCurrentPage().
Se era stata definita semplicemente come "restituisce il
valore di currentPage", l'interfaccia della classe base non
è stata violata. Osserviamo che è altamente improbabile
che, senza particolare necessità, la documentazione di
una funzione così semplice sia molto più formale
o precisa; raramente la documentazione a corredo di una libreria
di classi fornisce un elevato grado di dettaglio e di precisione,
ed in effetti sarebbe spesso antieconomico documentare ogni funzione
con il massimo grado di dettaglio. Sarebbe molto più ragionevole
documentare meglio le sole funzioni "a rischio", se
fossimo in grado di individuarle, lasciando la documentazione
delle altre al consueto grado di precisione.
Esempi simili a quello sinora discusso sono spesso usati dai detrattori
dell'object oriented programming, per dimostrare l'inefficacia
del paradigma, o come spunto per proporre soluzioni alternative
all'ereditarietà; il punto interessante è che, tra
i molti articoli che trattano il problema, nessuno sembra essersi
concentrato sulle reali cause del problema stesso, e sulle possibili
soluzioni all'interno del paradigma object oriented.
Possibili soluzioni
Una possibile soluzione, come accennato poco sopra, è di rinunciare all'ereditarità tout-court. Si tratta di una soluzione abbastanza estrema, che nondimeno è stata intrapresa ad esempio da Microsoft per il Component Object Model, alla base della tecnologia di OLE 2 e dei componenti riutilizzabili da essa supportati (vedere [2] per una panoramica sull'argomento). Il Component Object Model si prefigge in effetti di risolvere (tra gli altri) i due problemi discussi in apertura, ovvero la mancanza di uno standard binario ed il problema della fragile base class; il primo definendo uno standard multi-linguaggio, il secondo rinunciando all'inheritance come meccanismo di riutilizzo del codice, in favore di una tecnica diversa detta aggregation.
Essa consiste, semplificando molto, in una forma di contenimento con esposizione diretta dei metodi dell'oggetto contenuto, che tuttavia non possono essere specializzati. Se è necessaria una specializzazione, la classe aggregante dovrà fornire un suo insieme di metodi, eventualmente basati su quelli dell'oggetto contenuto, del quale non saranno comunque noti dettagli implementativi di alcun genere. In particolare, non esiste un meccanismo analogo alle funzioni virtuali.
L'approccio in realtà sposta semplicemente il problema
ad un diverso livello, ovvero sulla progettazione: progettare
un componente COM che sia facilmente specializzabile è
molto più complesso di quanto sembri, e senza dubbio richiede
maggiore attenzione rispetto ad una normale classe specializzabile
attraverso ereditarietà.
Cambiare modello degli oggetti è comunque una soluzione
poco attraente, specialmente considerando che non esiste un linguaggio
che supporti direttamente il COM (ad esempio), e che vi è
stato un enorme investimento di formazione, ricerca e sviluppo
sul C++ e più in generale sul paradigma orientato agli
oggetti. Sembra quindi molto più interessante ricercare
le reali cause del problema, ed individuare se possibile un rimedio
che non richieda modifiche al linguaggio o al paradigma, ma solo
allo stile di programmazione utilizzato.
Funzioni Virtuali
Le funzioni virtuali sono una delle caratteristiche fondamentali
del C++, alla base del meccanismo di binding dinamico. Ogni programmatore
avrà certamente usato le funzioni virtuali in numerose
occasioni, tuttavia vale la pena di ricordare che esistono sostanzialmente
due modelli di utilizzo per le funzioni virtuali:
Un esempio dei due diversi schemi di utilizzo per le funzioni
virtuali è dato nel listato 1, dove Print()
ricalca il modello (1), ovvero è implementata dalla classe
ma non utilizzata dalla classe stessa, mentre ad esempio PrintPage()
ricalca il modello (2), essendo chiamata da Print(). Naturalmente,
il fatto che PrintPage() sia protected e non public è
solo una caratteristica dell'esempio dato, e non ha rilevanza
generale.
Tra i due modelli di utilizzo esiste una sottile differenza, sulla quale anche molti programmatori esperti non si soffermano: le funzioni virtuali di tipo (1) possono essere ridefinite in una classe derivata, modificando quindi la semantica della funzione stessa; ridefinendo però una funzione di tipo (2), non solo modifichiamo la semantica della funzione, ma anche delle altre funzioni della stessa classe che la richiamano.
Questa differenza è all'origine dei più comuni
problemi associati con la derivazione, incluso il problema della
fragile base class: possiamo peraltro verificare immediatamente
che la funzione PrintHeader() da noi ridefinita è
infatti di tipo (2), in quanto è chiamata internamente
a Print().
Osserviamo infatti che per ridefinire una funzione di tipo (1) in modo corretto, è necessario conoscere la specifica iniziale della funzione, e non sono necessari altri dati. Per ridefinire una funzione di tipo (2) in modo corretto e stabile, è necessario conoscere con precisione anche il contesto di chiamata di tale funzione all'interno delle altre funzioni della classe; viceversa, dovremo fare alcune assunzioni, di norma leggendo il codice della classe base, proprio per determinare il contesto di chiamata.
Rileggendo in questa luce i passi che hanno portato al sorgere
del problema nel nostro esempio, otteniamo il seguente percorso:
Il problema della "fragile base class" non è
quindi da ascrivere totalmente alla classe base, o al meccanismo
di derivazione; in questo senso, il nome con cui viene normalmente
identificato non è dei più precisi. Si tratta invece
di un problema legato all'accoppiamento sul contesto di chiamata
nella classe base; notiamo che, per le funzioni di tipo (1), tale
accoppiamento non esiste, proprio perché non vengono chiamate
nella classe base. Non è quindi possibile che manutenzioni
della classe base, che rispettino l'interfaccia della classe stessa,
introducano malfunzionamenti nelle classi derivate che ridefiniscano
solo funzioni di tipo (1).
Avendo chiarito maggiormente le cause del problema, possiamo ora
cercare di identificare delle soluzioni, senza intraprendere la
strada un pò drastica del cambio di paradigma. Possiamo
identificare due strategie per minimizzare la possibilità
di incorrere nel problema, una applicabile al momento del progetto
di classi base, ed una applicabile al momento della derivazione.
Quando progettiamo una classe, dovremmo cercare di evitare le funzioni virtuali di tipo (2); in molti casi, ciò è possibile senza grandi sforzi, perché è abbastanza comune dichiarare funzioni come virtual "giusto nel caso" debbano essere ridefinite. Da notare che in questo senso il C++, che a differenza di altri linguaggi object oriented consente di avere funzioni anche non virtuali, è più adatto a circoscrivere il problema della fragile base class.
Quando non è possibile evitare la creazione di una funzione
virtuale di tipo (2), è opportuno fornire una documentazione
dettagliata sui diversi contesti in cui la funzione viene richiamata
all'interno della classe stessa: ciò ridurrà al
minimo la necessità di leggere il sorgente della classe
per ereditare, e nel contempo costituirà un impegno aggiuntivo,
ovvero una serie di vincoli che ci impegnamo a rispettare nella
classe base. Nel caso precedente, ad esempio, avremmo dovuto specificare
il vincolo che, al momento della chiamata di PrintPage(),
la variabile currentPage contiene il numero logico della
pagina meno uno. In tal senso, la successiva modifica alla classe
base avrebbe violato il vincolo, e pertanto il problema della
classe derivata sarebbe stato conseguenza di una errata modifica
alla classe base. Alternativamente, se tale vincolo non era specificato,
o meglio ancora se era stato esplicitamente indicato come soggetto
a variare in future implementazioni, allora sarebbe stata l'implementazione
della classe derivata ad essere fragile, non la classe base.
Ciò ci porta ad affrontare il problema dall'ottica della
derivazione: se dobbiamo ridefinire una funzione virtuale di tipo
(2), dovremmo prima ricercare la documentazione dei contesti
di chiamata nella classe base. Se tale documentazione è
assente, o segnala esplicitamente alcuni contesti di chiamata
come soggetti a variare senza preavviso, non possiamo fare alcuna
assunzione stabile rispetto alle modifiche nella classe base.
In questo caso, abbiamo due alternative:
La prima alternativa è la più immediata, e differisce
dalla pratica comune solo nell'aggiunta di un opportuno commento.
In questo modo, sapremo che se viene modificata la classe base
è necessario rivedere il contesto di chiamata, ed eventualmente
modificare l'implementazione della classe derivata. Anziché
ignorare il problema, aspettando che esso si manifesti, forniamo
quindi le necessarie informazioni affinché esso venga prevenuto
durante la manutenzione.
La seconda alternativa richiede in genere un maggiore sforzo implementativo,
ma elimina totalmente il problema. Evitare l'assunzione di un
contesto di chiamata significa di norma replicare alcuni dati
all'interno della funzione o classe derivata; a titolo di esempio,
nel listato 4 possiamo vedere una versione di DocWithPageNum
che è immune dal problema della fragile base class, in
quanto il contesto di chiamata che veniva utilizzato nella prima
implementazione è stato qui ricostruito localmente, attraverso
un ulteriore membro dato aggiunto alla classe derivata. Questa
versione di PrintHeader() è peraltro molto simile
a quella che verrebbe imposta da un linguaggio basato su delegation
o su aggregation, che eliminano il problema della fragile base
class ma non la complessità aggiuntiva nel progetto del
codice. Se si è disposti a gestire tale complessità,
è possibile farlo anche restando nel paradigma object oriented.
Listato 4
class DocWithPageNum : public Document
{
public :
virtual void Print() ;
protected :
virtual void PrintPageHeader() ;
private :
unsigned pageNumber ;
} ;
void DocWithPageNum :: Print()
{
pageNumber = 1 ;
Document :: Print() ;
}
void DocWithPageNum :: PrintPageHeader()
{
// stampa numero pagina: pageNumber
pageNumber++ ;
Document :: PrintPageHeader() ;
}
Osserviamo che la conoscenza del meccanismo alla base del problema
ci fornisce anche una guida al momento della documentazione: le
funzioni virtuali di tipo (2) richiedono una documentazione più
dettagliata delle altre, al limite con disclaimer espliciti sui
contesti di chiamata. Analogamente, conoscere il meccanismo ci
consente di concentrarci sulla prevenzione solo al momento della
ridefinizione di una funzione virtuale di tipo (2), senza preoccuparci
di incorrere nel problem negli altri casi.
Conclusioni
Il problema della "fragile base class" può essere
gestito senza rinunciare all'ereditarietà: è sufficiente
una maggiore cautela nel progetto delle classi, fornendo adeguate
informazioni a chi riutilizza il nostro codice, ed esercitando
un controllo al momento della derivazione. Dal punto di vista
della progettazione di classi base, possiamo evidenziare due importanti
raccomandazioni di codifica:
In conclusione, un linguaggio con la flessibilità e la
potenza del C++ richiede spesso uno studio approfondito dei suoi
aspetti più complessi per poterne sfruttare a fondo le
potenzialità in termini di robustezza ed efficienza del
codice. Solo da un giusto connubio di approfondimento teorico
ed esperienza è possibile raggiungere in modo completo
tali obiettivi.
Questo articolo è stato adattato ed ampliato
dal capitolo "Funzioni virtuali" del libro
"C++ Manuale di Stile", Edizioni Infomedia, dello stesso autore.
Bibliografia
Biografia
Carlo Pescio svolge attività di consulenza in ambito internazionale
sulle metodologie di sviluppo Object Oriented e di programmazione
in ambiente Windows. È laureato in Scienze dell'Informazione,
membro dell'Institute of Electrical and Electronics Engineers
Computer Society, dei Comitati Tecnici IEEE su Linguaggi Formali,
Software Engineering e Medicina Computazionale, dell'ACM e dell'Academy
of Sciences di New York. Può essere contattato tramite
la redazione o su Internet come
pescio@programmers.net