Il linguaggio C++ prevede la possibilità di definire molteplici vincoli di ereditarietà per la medesima classe. Ciò significa che una classe può incorporare i comportamenti e i dati definiti in un numero arbitrario di classi base.
Se da un punto di vista pratico questa caratteristica può sembrare allettante, essa richiede un design accorto della nostra gerarchia. Un uso inconsapevole dell'ereditarietà multipla, infatti, comporta spesso più svantaggi che benefici, e rischia di deviare il design del nostro modello dati dal paradigma orientato agli oggetti, a qualcosa di indefinito e difficile da gestire.
Per questo motivo molti linguaggi di alto livello, ad esempio Java, proibiscono a priori l'ereditarietà multipla, e delegano questo meccanismo di aggregazione a costrutti più aderenti ai canoni della programmazione orientata agli oggetti.
La filosofia che guida la definizione del linguaggio C++, invece, anche in questo caso predilige un approccio più aperto, demandando alle regole di buona programmazione la responsabilità di colmare le eventuali lacune o incongruenze sintattiche.
Definizione di ereditarietà multipla
Definire molteplici vincoli di ereditarietà è sintatticamente semplice. Basta infatti apporre più clausole di derivazione alla definizione di una classe in sequenza e separate da virgola.
Il listato seguente mostra un esempio di tale sintassi con la definizione di due classi base, Rect e UIELement, e della una classe Button che deriva da entrambe.
/* Classe base #1 */
class Rect
{
public:
Rect(int w, int h)
{
width = w;
height = h;
}
virtual ~Rect() {}
protected:
int width;
int height;
};
/* Classe base #2 */
class UIElement
{
public:
UIElement(bool onOff)
{
enabled = onOff;
}
virtual ~UIElement() {}
protected:
bool enabled;
};
/* Classe derivata da entrambe */
class Button : public Rect, public UIElement
{
public:
Button(int w, int h, bool onOFF) :
Rect(w,h),
UIElement(onOFF)
{
togglable = false;
}
protected:
bool togglable;
};
Da un punto di vista concettuale, il nostro modello dati è coerente: un pulsante è un elemento interattivo (UIElement) caratterizzato dalla forma tipicamente rettangolare (Rect). Esso inoltre ha funzionalità specifiche, come ad esempio l'indicazione dello stato (il membro togglable
).
Inoltre, la presenza di un distruttore virtuale in entrambe le classi base garantisce che un'istanza di tipo Button possa essere de-allocata correttamente, anche quando essa è referenziata mediante un puntatore ad una delle classi base.
Tuttavia, a discapito della semplicità e intuitività, questo modello cela una serie di criticità che possono inficiare l'usabilità di questa classe, come vedremo nel seguito.
Conflitti di nomi
Il primo problema che si presenta è quello della possibilità di conflitti tra i nomi di metodi e dati membro definiti nelle due (o più) classi base. Ad esempio nel nostro caso sia Rect che UIElement potrebbero includere nella loro definizione un membro protetto di tipo intero di nome color.
Cosa succederebbe in questo caso se provassimo a inizializzare color nel costruttore di Button? Il compilatore ci notificherebbe che il riferimento al membro color è ambiguo, con tanto di elenco di candidati composto da Rect::color e UIElement::color.
Quindi la classe Button avrebbe due membri di nome color, con la stessa valenza semantica, ma facenti parte di sotto-oggetti differenti.
Questo problema può essere risolto mediante l'uso dell'operatore di risoluzione di contesto (scope) ::
, specificando cioè di volta in volta non solo il nome del membro ma anche il nome della classe base, o eliminando l'ambiguità sul riferimento all'uno a all'altro membro derivato mediante la parola chiave using
:
class Button : public Rect, public UIElement
{
public:
Button(int w, int h, bool onOFF) :
Rect(w,h),
UIElement(onOFF)
{
togglable = false;
color = 1; // significa Rect::color
}
using Rect::color; // indica che ogni riferimento a color è in realtà un riferimento a Rect::color
protected:
bool togglable;
};
Sebbene esista una soluzione per questo problema specifico, un inconveniente del genere molto spesso è sintomatico di una sovrapposizione delle funzionalità delle classi base, che ha per effetto la presenza di ridondanze nella classe derivata.
Ciò dovrebbe indurre a riconsiderare il modello dati, ed in definitiva l'uso dell'ereditarietà multipla in questo contesto.
The diamond problem e l'ereditarietà virtuale
Un altro problema ricorrente con l'ereditarietà multipla è la definizione di classi derivate a partire da classi base che condividono un antenato comune.
Si consideri ad esempio il caso in cui sia Rect che UIElement siano derivate dalla classe Shape.
Sebbene, nelle nostre intenzioni, il diagramma delle classi corrispondente ad una situazione simile è quello a forma di diamante etichettato con (A) nella figura seguente, le regole di aggregazione per ereditarietà del C++, in realtà, determinano una situazione meglio rappresentata dal diagramma (B).
In conseguenza di ciò, nella classe Button la classe antenato Shape appare due volte, rispettivamente come sotto-oggetto di Rect e UIElement, come mostrato nella figura seguente dove è rappresentato il layout a strati di un istanza delle classi base e della classe Button.
Anche in questa circostanza, quindi, insorgerebbe un conflitto di nomi per tutti i riferimenti a membri ereditati da Shape all'interno della classe Button.
Tuttavia, in questo caso specifico, la ridondanza nell'inclusione della classe antenato come parte di Button può essere eliminata ricorrendo all'ereditarietà virtuale, in maniera più efficiente rispetto all'uso dell'operatore di risoluzione di scope o la parola riservata using
.
Il listato seguente mostra la dichiarazione delle quattro classi coinvolte nel problema del diamante. Si noti la definizione di Rect e UIElement in cui Shape è definita come base virtuale.
/* Classe antenato */
class Shape
{
// membri e metodi di Shape...
};
/* Classe base #1 */
class Rect : public virtual Shape
{
// membri e metodi di Rect...
};
/* Classe base #2 */
class UIElement : public virtual Shape
{
// membri e metodi di UIElement...
};
/* Classe derivata da entrambe */
class Button : public Rect, public UIElement
{
// membri e metodi di Button...
};
L'effetto della parola chiave virtual
in una clausola di derivazione è quello di forzare il compilatore a includere la base virtuale una sola volta nella definizione degli oggetti derivati, anche se essa appare più volte nella catena di derivazione. In questo modo si ottimizza l'uso delle risorse, e si risolvono a monte eventuali conflitti di nomi.
Attuando questa modifica, l'effetto sul layout delle istanze della classe Button è illustrato nella figura seguente:
Tuttavia, ciò è possibile solo alterando la definizione delle classi base di Button, oppure se, nel caso in cui la definizione di tali classi non fosse di nostra competenza, l'autore ha avuta questa accortezza. In altre parole, l'ereditarietà virtuale non si improvvisa; è qualcosa che deve essere integrata fin da subito nel disegno delle nostre classi.
Buona programmazione: classi interfaccia in C++
L'ereditarietà multipla è un concetto semplice ma, come abbiamo visto, nella pratica, richiede una buona progettualità per essere reso funzionale.
Tuttavia, esso è un costrutto potente che compare in quasi tutti i linguaggi OOP evoluti, in forma più o meno esplicita.
Ad esempio il linguaggio Java, che abbiamo menzionato precedentemente, prevede la possibilità di attribuire ad una classe molteplici interfacce, cioè schemi di comportamento che si estrinsecano mediante la definizione di soli metodi pubblici, senza alterare la definizione dello stato di un'istanza con la definizione di dati membro.
Questo approccio semplifica la gestione di classi aggregate, rispetto alla ereditarietà multipla di C++, poiché disaccoppia lo stato delle istanze (cioè l'insieme dei dati membro) dalla definizione dei suoi comportamenti. Così facendo si risolvono a priori molte delle criticità proprie dell'ereditarietà multipla, che abbiamo citato in precedenza.
Il costrutto di interfaccia, che è assente nello standard del linguaggio C++, può tuttavia essere emulato mediante la definizione di classi astratte che includano esclusivamente metodi virtuali puri, il distruttore virtuale, un solo costruttore privo di argomenti, e siano prive di dati membro non statici.
Sotto queste condizioni, la gestione di vincoli di ereditarietà multipla è più semplice, e solitamente è anche sintomo di una progettualità conforme ai principi della programmazione orientata agli oggetti.
L'alternativa consiste nel progettare la nostra gerarchia di classi di modo che i legami gerarchici in essa definiti la rendano assimilabile alla struttura di albero aciclico, piuttosto che a quella di grafo, epurando, così facendo, il nostro modello dati da vincoli di ereditarietà multipla.