In questo articolo parlerò di una delle funzionalità offerte dal C++ più oscure e potenti, ovvero l’ereditarietà multipla.
Volendo riassumere le potenzialità dell’ereditarietà multipla potremmo dire che
L’ereditarietà multipla è un bisturi ad energia atomica. Ti permette interventi precisissimi, ma sbaglia di pochissimo e salta tutto in aria.
Disclaimer: in questo articolo darò per scontato che sappiate cosa sia l’ereditarietà nella programmazione ad oggetti e come utilizzarla in C++, quindi se questi concetti non vi sono familiari vi consiglio di documentarvi in tal senso per poi tornare qui.
Il caso banale
Iniziamo con un caso banale. Non presenta problematiche particolari e il suo unico scopo è mostrare la sintassi che sta alla base dell’ereditarietà multipla.
Non ho dubbi che qualcuno più smaliziato abbia già intuito quale sia la sintassi per sfruttare tale caratteristica, tuttavia è mio preciso dovere essere il più chiaro ed esplicito possibile.
Immaginiamo una semplice situazione in cui si hanno le seguenti due classi:
class A { public: A(std::string key): key_{std::move(key)} private: const std::string key_; } class B { public: B(std::string val): val_{std::move(val)} private: const std::string val_; }
Ipotiziamo di voler creare una classe C
che erediti da entrambe. Banalmente la sintassi sarebbe la seguente
class C : public A, public B { public: C(std::string key, std::string val) : A(std::move(key)), B(std::move(val)) }
Fin qui niente di eclatante. Abbiamo la nostra classe C
che tramite polimorfismo può essere referenziata come A
o B
.
Membri omonimi
Complichiamo un po’ le cose. Supponiamo che sia A
che B
abbiano una funzione membro con firma std::string GetMember()
. Dal momento che C
eredita da entrambi, si ritroverebbe con due implementazioni di tale funzione. Due implementazioni che fanno cose diverse. Come fa il compilatore a scegliere quale usare?
Semplice, non può!
Pare evidente che è compito dello sviluppatore risolvere l’ambiguità, e per riuscire nell’intento sono possibili due strade. Una è quella di implementare una funzione membro con la stessa firma in C
, ammesso che tale funzione sia virtual
, oppure sfruttare una delle varie accezioni della parola chiave using
.
class C : public A, public B { public: C(std::string key, std::string val) : A(std::move(key)), B(std::move(val)) using A::GetMember; }
In questo modo il compilatore saprà che è intenzione dello sviluppatore usare l’implementazione definita in A
. Per essere più precisi, la restrizione sulla scelta della funzione da esporre riguarda solo l’interfaccia di A
verso il mondo esterno. Nell’implementazione delle sue funzioni siamo liberi di usare a piacimento sia A::GetMember()
che B::GetMember()
, ovvero specificando esplicitamente di quale classe base si sta usando l’implementazione.
Il problema del diamante
Quanto detto finora sono quisquiglie che non si avvicinano nemmeno da lontano al famigerato problema del diamante. Questo è il vero grattacapo dell’ereditarietà multipla e il modo in cui il C++ lo risolve non è affatto banale da afferrare al volo, tuttavia col senno di poi ci si rende conto che è davvero potente e ben strutturato.
Prima di procede oltre, è utile dare uno sguardo all’immagine sottostante per farsi un’idea di cosa il problema del diamante sia.
Trasformando il class diagram in codice avremmo una situazione molto simile a questa:
class A { public: A(std::string val) : val_{std::move(val)} {} private: std::string val_; } class B : public A { public: B(std::string val) : A(std::move(val)) {} } class C : public A { public: C(std::string val) : A(std::move(val)) {} } class D : public B, public C { public: D(std::string val) : B(val), C(val) {} }
Questo codice non compila per una motivazione banale: B
e C
ereditano da due istanze differenti di A
, quindi con due copie distinte di val_
. Di conseguenza se da D
si volesse far riferimento ad A
, il compilatore non saprebbe quale strada prendere per risolvere la richiesta: B->A
o C->A
?
Ereditarietà virtuale
A dispetto di quanto ci si aspetterebbe arrivati a questo punto, in C++ non esiste un meccanismo per dire al compilatore “scegli il percorso B->A
“. Imporre tali informazioni al compilatore porterebbe ad una serie di complicazioni che renderebbero l’ereditarietà multipla semplicemente inutilizzabile. Pertanto, in C++ è stata adottata l’idea di eredita virtuale, che può essere riassunta in questo modo: il costruttore di una classe padre viene invocato da quello della classe figlia solo se si tratta di ereditarietà non virtuale o, nel caso di ereditarietà virtuale, solo quando classe figlia viene istanziata direttamente e non, a sua volta, tramite una sua figlia.
Capisco bene che questa frase vuol dire tutto e nulla, pertanto iniziamo col modificare l’esempio precedente inserendo l’ereditarietà multipla.
class B : public virtual A { public: B(std::string val) : A(std::move(val)) {} } class C : public virtual A { public: C(std::string val) : A(std::move(val)) {} }
La parola chiave virtual
fa comportare i costruttori di B
e C
in modo differente in base a come vengono invocati.
- Se si ha un codice del tipo
new B(val)
allora si tratta di istanziazione diretta e quindi il costruttore diA
viene invocato, - Se ti ha un codice del tipo
new D(val)
alloraB
è stato istanziato tramite ereditarietà e quindi non invocherà il costruttore diA
(eventuale altro codice verrebbe comunque eseguito come di prassi).
Da quanto detto, ne segue che così facendo, è responsabilità di D
invocare il costruttore di A
. In caso ciò non avvenisse, il compilatore lancerebbe errore. Si rende quindi necessario ristrutturare D
come segue:
class D : public B, public C { public: D(std::string val) : B(""), // questo valore viene ignorato, si potrebbe anche pensare di C(""), // aggiungere un costruttore protetto che non prende argomenti A(std::move(val)) {} }
Così facendo esiste una sola istanza di A
, D
(e le sue classi padre) sanno correttamente dove recuperarne il valore e noi siamo felici perché il compilatore non si lamenta.
Conclusioni
Gli scenari in cui l’ereditarietà multipla vada utilizzata sono abbastanza pochi, tuttavia il suo scarso utilizzo non è una motivazione valida per ignorarla. In questo articolo vengono spiegate le basi che permettono di utilizzarla; però nel caso in cui avessi scritto qualche passaggio poco chiaro o qualche inesattezza, non abbiate timore di massacrarmi nei commenti!