Dr. Carlo Pescio
Il problema della "fragile base class" in C++

Pubblicato su Computer Programming No. 41


Il problema della "fragile base class" viene spesso ritenuto indissolubilmente legato al paradigma object oriented: in realtà, il programmatore C++ ha un'ampia possibilità di prevenirlo o di eliminarlo.

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:

  1. La funzione virtuale viene definita come parte dell'interfaccia della classe, ed implementata dalla classe stessa; eventuali classi derivate possono ridefinire la funzione. La funzione non è tuttavia utilizzata all'interno della classe stessa: solo il codice che utilizza la classe chiama tale funzione.
  2. A differenza del punto (1), la funzione viene anche chiamata dal codice della classe stessa. Ciò significa che ridefinendo la funzione, una classe derivata può alterare il comportamento anche di altre funzioni della classe base.

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:

  1. Ridefiniamo una funzione di tipo (2), senza accorgimenti particolari.
  2. Per ottenere il numero di pagina, dobbiamo conoscere il contesto nel quale tale funzione viene chiamata all'interno di un altro metodo della classe. Di norma, ciò significa accedere al sorgente della classe.
  3. In questo modo, creiamo un accoppiamento tra la classe derivata ed il contesto di chiamata all'interno della classe base. Il contesto di chiamata è normalmente un dettaglio implementativo, non documentato come parte dell'interfaccia della classe base, e pertanto soggetto a variazioni arbitrarie.
  4. La classe base viene modificata in modo tale da variare il contesto di chiamata, da cui la nostra classe derivata dipende in modo implicito; questo altera la semantica della classe derivata, e di ciò viene fatto carico al meccanismo di ereditarietà.

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:

  1. dedurre il contesto di chiamata dal codice della classe base, e segnalare esplicitamente nel codice della nostra classe derivata il contesto di chiamata che abbiamo assunto.
  2. implementare la funzione che vogliamo ridefinire senza assumere alcun contesto di chiamata.


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:

  1. In ogni classe, limitate il numero di chiamate a funzioni virtuali dichiarate nella classe stessa.
  2. Per ogni funzione virtuale dichiarata in una classe e richiamata nel codice della classe stessa, documentate adeguatamente nel file header della classe ogni contesto di chiamata per tale funzione. Specificate chiaramente quali vincoli sul contesto verranno rispettati in future versioni della classe (e rispettateli!) e quali possono essere variati senza preavviso.

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.


Reader's Map
Molti visitatori che hanno letto
questo articolo hanno letto anche:
Bibliografia
[1] M.D. McIlroy: "Mass Produced Software Components", Software Engineering, NATO Science Committee, Gennaio 1969.
[2] Carlo Pescio: "Evoluzione della programmazione in ambiente Windows", Computer Programming No 33, Febbraio 1995.


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