|
Dr. Carlo Pescio Antimorfismo in C++ |
Pubblicato su Computer Programming No. 59
Introduzione
Ogni programmatore C++ ha sicuramente ben chiaro il concetto di
polimorfismo: una classe base dichiara (e spesso implementa) una
determinata funzione virtuale, e le classi derivate possono ridefinire
la funzione, ad esempio per fornire una versione più efficiente
o specializzata della stessa. Invocando la funzione tramite un
reference o un puntatore alla classe base, verrà comunque
chiamata la versione della funzione che corrisponde alla classe
dell'oggetto puntato, non del puntatore. In questo modo, possiamo
avere un'unica interfaccia (data dalla classe base) e molteplici
implementazioni, date dalle classi derivate.
Non di rado, la classe base fornisce una implementazione di default
della funzione, che le classi derivate ridefiniscono solo quando
è realmente necessario. Spesso le funzioni di classe derivata
chiamano comunque la funzione di classe base, limitandosi quindi
ad estenderla più che a rimpiazzarla . L'idea di
fondo è che, essendo la derivazione pubblica una forma
di subtyping, l'implementazione fornita dalla classe base dovrebbe
di norma valere anche per le classi derivate.
Tuttavia, vi sono casi in cui questa assunzione cade: consideriamo
una semplice funzione (predicato) IsDerived, che restituisca true
se l'oggetto su cui la invochiamo è di classe derivata,
e false se di classe base. In questo caso, l'implementazione
della funzione in una classe base restituirà false,
mentre l'implementazione in tutte le classi derivate dovrà
restituire true; l'implementazione della classe base, di
conseguenza, non può essere considerata come default per
le classi derivate, anzi, in questo caso si comporta esattamente
nel modo opposto. In generale, io raggruppo questi casi sotto
il nome di antimorfismo, intendendo che l'implementazione
di default per le classi derivate deve essere necessariamente
diversa da quella della classe base. Esistono diversi metodi
per affrontare il problema, apparentemente molto semplice, ma
la cui migliore soluzione richiede l'uso di tecniche di programmazione
non banali.
In questo articolo analizzeremo diverse implementazioni alternative
dell'antimorfismo, concentrandoci sul semplice caso del predicato
IsDerived; nel fare ciò, vedremo anche alcuni casi di "uso
creativo" di funzionalità spesso sottoutilizzate del
C++, come l'ereditarietà virtuale, l'ereditarietà
privata, le classi friend. Al di là del problema in sé,
questa sarà anche una buona occasione per parlare della
progettazione dell'interfaccia di una classe, del partizionamento
di responsabilità tra classe base e classi derivate, e
così via.
Una soluzione banale
La prima soluzione che viene in mente è con ogni probabiltà
quella banale: lasciare alle classi derivate la responsabilità
di implementare IsDerived nel modo corretto, come nell'esempio
seguente:
class Base
{
public:
virtual bool IsDerived() const
{ return( false ) ; }
} ;
class Derived :
public Base
{
public:
virtual bool IsDerived() const
{ return( true ) ; }
} ;
Questa soluzione ("da libro di testo") lascia parecchio
a desiderare. La conoscenza relativa alla funzione IsDerived non
è affatto localizzata nella classe Base: ogni classe derivata
da Base deve ridefinire la funzione esplicitamente e nel modo
corretto (ed anche se in questo caso è ovvio, ciò
può non essere vero in altri casi) pena l'incoerenza dei
risultati ottenuti. Possiamo pensare a questo come ad un caso
in cui incapsulazione non implica information hiding: anche
se molta letteratura recente confonde spesso i due termini (più
o meno volontariamente), vi sono nondimeno importanti differenze.
Inoltre, non vi è nessuna protezione contro la ridefinizione
(intenzionale o accidentale) della funzione IsDerived: addirittura
una classe derivata da Derived potrebbe pretendere di essere una
classe base, semplicemente ridefinendo IsDerived.
Una soluzione migliore
Possiamo eliminare il primo problema introducendo una classe intermedia,
come nel listato seguente:
class Base
{
public:
virtual bool IsDerived() const
{ return( false ) ; }
} ;
class Middle : public Base
{
public:
virtual bool IsDerived() const
{ return( true ) ; }
} ;
class Derived : public Middle
{
// ...
} ;
In questo modo, le classi derivate non devono più preoccuparsi
di ridefinire IsDerived, in quanto Middle fornisce una implementazione
di default adatta a tutte le classi derivate. Rimane naturalmente
il problema piuttosto grave legato alla possibilità di
ridefinire IsDerived nelle classi derivate: in un programma reale
ciò potrebbe portare a problemi difficili da identificare.
Inoltre, anche trascurando la mancanza di sicurezza sulla ridefinizione,
questa soluzione non è completamente soddisfacente, perché
impone alle classi derivate di ereditare da Middle, non da Base,
altrimenti si ritorna al caso precedente. In genere, sarebbe meglio
confinare eventuali artifici "al di sopra" delle classi
che forniamo agli utilizzatori del nostro codice, non "al
di sotto". Sarebbe quindi preferibile eliminare Middle, magari
derivando Base da qualche altra classe: in tal modo chi usa le
nostre classi potrebbe usare direttamente Base senza dover venire
a conoscenza di artifici vari come l'esistenza di una classe Middle.
Va ora chiarito che, mentre queste preoccupazioni sono probabilmente
eccessive se stiamo scrivendo codice a nostro uso e consumo, sono
sicuramente opportune durante la progettazione di una libreria.
Dedicare un po' più di tempo alla progettazione dell'interfaccia
significa, in molti casi, risparmiare agli utilizzatori della
nostra libreria inutili perdite di tempo, o deprimenti esplorazioni
del codice sorgente: chi ha usato qualche libreria commerciale
le cui interfacce erano tutt'altro che ben progettate capirà
sicuramente di cosa sto parlando. Con ogni probabilità,
la soluzione vista sopra si può invece considerare più
che soddisfacente nel caso di classi specializzate, poco soggette
a riuso e, quindi, all'abuso.
Ereditarietà virtuale
Prima di introdurre una versione migliore di IsDerived, è
opportuno richiamare alcuni concetti relativi all'ereditarietà
virtuale in C++.
Di norma, un oggetto di classe derivata eredita tutte le funzioni
e tutti i dati delle classi base; tuttavia, con l'introduzione
dell'ereditarietà multipla, questa semplice regola può
non corrispondere alle intenzioni reali del programmatore. Vediamo
un semplice esempio, adattato da [1]:
class Person
{
// ...
} ;
class Student :
public Person
{
//...
} ;
class Employee :
public Person
{
// ...
} ;
class StudentEmployee :
public Student,
public Employee
{
// ...
} ;
Sia lo studente che il lavoratore sono persone, e
lo studente-lavoratore è sia uno studente che un lavoratore
(trascuriamo il fatto che esistono modelli migliori basati su
"classi ruolo", in quanto poco interessanti ai fini
della discussione). Purtroppo, così come è stata
presentata, l'implementazione dello studente-lavoratore non è
corretta, in quanto sia Student che Employee ereditano tutti i
dati di Person, e quindi un oggetto di classe StudentEmployee
ha i dati di due sotto-oggetti Person.
L'ereditarietà virtuale è stata introdotta proprio
per consentire alle classi derivate di contenere un solo
sotto-oggetto di classe base, anche in situazioni di ereditarietà
multipla fork-join come quella dell'esempio precedente, che andrebbe
quindi riscritto come:
class Person
{
// ...
} ;
class Student :
public virtual Person
{
//...
} ;
class Employee :
public virtual Person
{
// ...
} ;
class StudentEmployee :
public Student,
public Employee
{
// ...
} ;
Da queste ed altre considerazioni, sempre in [1] viene derivata
la raccomandazione no. 126, "L'ereditarietà fork-join
accessibie deve sempre essere virtuale".
Va notato che l'ereditarietà virtuale ha un ulteriore effetto
(spesso ignorato dai programmatori) oltre alla condivisione del
sotto-oggetto di classe base; proprio questo effetto collaterale
sarà alla base dell'ulteriore versione di IsDerived che
vedremo tra breve. Il C++ richiede infatti che, quando viene usata
l'ereditarietà virtuale, il costruttore delle classi più
derivate (dette anche classi foglia) chiami direttamente
il costruttore della classe base. Notiamo che ciò vale
anche nel caso dell'ereditarietà singola (purché
virtuale) e che non è sufficiente che una classe intermedia
richiami il costruttore della classe base: deve essere la classe
foglia a richiamarlo; nuovamente, vediamo un esempio adattato
da [1] per illustrare il problema:
class Base
{
public :
Base( int d ) ;
} ;
class Derived :
public virtual Base
{
public :
// default constructor
Derived() :
Base( 10 )
{}
} ;
class DoubleDerived :
public Derived
{
} ;
// Errore!
DoubleDerived dd ;
Nella situazione data non possiamo creare un oggetto di classe DoubleDerived, perché anche se Derived (da cui DoubleDerived è a sua volta derivata) fornisce un costruttore di default, che richiama il costruttore di Base con un parametro fittizio, avendo usato l'ereditarietà virtuale dovremmo richiamare il costruttore di Base nella classe foglia (DoubleDerived), in modo esplicito. Possiamo sfruttare questa possibilità in modo "creativo" per distinguere tra classe base e classi derivate, come vedremo di seguito.
Miglioriamo IsDerived
Siamo ora pronti ad implementare una versione migliore di IsDerived,
che elimina quasi ogni difetto delle precedenti; l'unico rimasto
verrà preso in considerazione fra breve. L'idea di fondo
è di creare una classe base speciale, da cui derivare in
modo virtuale la vera classe Base. Il costruttore della nuova
classe base avrà un valore di default adatto alle classi
derivate, mentre la sola Base userà un parametro
differente. Vediamo l'implementazione prima di discutere ulteriori
dettagli:
class VirtualBase
{
public:
VirtualBase( bool d = true )
{ derived = d ; }
bool IsDerived() const
{ return( derived ) ; }
private:
bool derived ;
} ;
class Base :
virtual public VirtualBase
{
public:
Base() :
VirtualBase( false )
{}
} ;
class Derived :
public Base
{
// ...
} ;
Veniamo ora ad una discussione più approfondita. Innanzitutto,
IsDerived non è neppure più virtuale, ma si limita
a restituire un valore inizializzato al momento della costruzione.
La classe Base inizializza tale valore a false, in quanto
dal punto di vista del problema originale, Base è la vera
classe base. Le classi derivate non devono essere a conoscenza
di una classe intermedia fittizia (Middle) come accadeva nell'implementazione
precendente, ma derivano da Base come ci si aspetta. Notiamo che
le classi derivate non devono fare assolutamente nulla: siccome
Base è derivata in modo virtuale, il costruttore di VirtualBase
deve essere richiamato dalle classi foglia, quindi la chiamata
VirtualBase( false ) nel costruttore di Base viene ignorata quando
un oggetto di classe Derived viene costruito. Invece, poiché
esiste un parametro di default per il costruttore di VirtualBase,
questo viene richiamato come costruttore di default, usando quindi
true come valore del parametro. Notate la differenza rispetto
all'esempio di DoubleDerived: in quel caso, il valore di default
era fornito dalla classe intermedia, non dalla classe base virtuale,
e ciò impediva la compilazione.
In questo modo, abbiamo ottenuto una interfaccia molto naturale
per la classe Base: le classi derivate possono ereditare direttamente
da essa, e non devono preoccuparsi di ridefinire alcuna funzione
o di chiamare un costruttore specificando valori particolari.
Rimane un ultimo problema, che nuovamente considereremo nell'ottica
della progettazione di interfacce il più possibile robuste;
nulla impedisce infatti ad una classe derivata di "pretendere"
di essere una classe base:
class FakeDerived :
public Base
{
public :
FakeDerived() :
VirtualBase( false )
{}
} ;
Per eliminare il problema, dobbiamo impedire alle classi diverse da Base di accedere alla versione non-default del costruttore di VirtualBase; per ottenere ciò, potremmo fare uso delle classi friend, o cercare di utilizzare l'ereditarietà privata.
Ereditarietà privata
Si tratta forse della funzionalità meno utilizzata del
C++, e non certo per mancanza di utilità. L'ereditarietà
pubblica in C++ serve a modellare una relazione Is-A (è-un)
tra classe base e classe derivata; l'ereditarietà privata
serve a modellare una relazione del tipo Is-Implemented-Using
(è-implementato-usando), ed è per molti versi simile
al contenimento. Pur rimandando nuovamente ad [1] per un approfondimento
sulle differenze tra ereditarietà pubblica, privata e contenimento,
va ricordato che una classe derivata in modo privato non
consente l'accesso a dati e funzioni pubbliche della sua classe
base, né alle classi che la utilizzano né alle classi
da essa derivate.
Provate a pensare quante volte vi è capitato, scrivendo
del codice od utilizzando una libreria, di osservare che certe
funzioni erano lecite per la classe base, ma non dovevano essere
chiamate (sotto la responsabilità del programmatore) per
le classi derivate: questo è il classico caso in cui chi
ha implementato la classe derivata avrebbe dovuto utilizzare l'ereditarietà
privata, non quella pubblica.
Nel nostro caso, potremmo tentare di far uso dell'ereditarietà
privata per impedire che le classi derivate da Base possano accedere
in modo scorretto a VirtualBase: purtroppo, ciò è
meno semplice di quanto sembri.
Un tentativo fallito
Non è mia abitudine presentare codice che non funziona
correttamente, ma in questo caso ho deciso di fare un'eccezione.
Le ragioni del suo mancato funzionamento sono infatti piuttosto
complesse, al punto che alcuni compilatori (es. Visual C++ 4.0
e 5.0) lo considerano (erroneamente) corretto, ed ho visto codice
simile girare per internet in tempi recenti. Vale quindi la pena
di esaminarlo più in dettaglio: in fondo, si può
imparare qualcosa anche da codice non funzionante.
Nuovamente, cerchiamo di chiarire subito il principio di fondo,
per poi passare all'implementazione prima di ogni approfondimento.
Se "spezziamo" l'unico costruttore di VirtualBase in
due costruttori, di cui uno protetto, e deriviamo Base da VirtualBase
in modo privato, le classi derivate da Base non avranno accesso
al costruttore protetto di VirtualBase, e non potranno quindi
abusarne.
class VirtualBase
{
public:
VirtualBase()
{ derived = true ; }
bool IsDerived() const
{ return( derived ) ; }
protected :
VirtualBase( int /* dummy */ )
{ derived = false ; }
private:
bool derived ;
} ;
class Base :
virtual private VirtualBase
{
public:
Base() :
VirtualBase( 1 )
{}
// espone la funzione
VirtualBase :: IsDerived ;
} ;
class Derived :
public Base
{
// ...
} ;
// Errore
Derived d ;
Perché un errore di compilazione? Perché cercando
di impedire ogni abuso del costruttore protetto, abbiamo reso
anche il costruttore pubblico non accessibile. Ricordiamo infatti
ancora una volta che ereditando in modo privato, gli elementi
pubblici di VirtualBase diventano privati in Base. Di conseguenza,
non solo il costruttore protetto diventa inaccessibile in Derived,
ma anche il costruttore pubblico.
Viene da chiedersi come mai il Visual C++, anche nelle sue versioni
più recenti, consideri il codice di cui sopra perfettamente
legale; probabilmente, ciò è dovuto ad una leggera
ambiguità nei documenti ANSI/ISO, uniti alla indubbia complessità
del linguaggio (che emerge proprio quando si comincia a sfruttarlo
più a fondo). Il punto 6 del paragrafo 12.6.2 del draft
ANSI/ISO del febbraio 1997 recita infatti: "All sub-objects
representing virtual base classes are initialized by the constructor
of the most derived class [...] If V does not have an accessible
default constructor, the initialization is ill-formed".
Non è però chiarissimo cosa significhi "costruttore
di default accessibile", in quanto l'accessibilità
va riferita ad un contesto. Nel contesto di VirtualBase, il costruttore
di default è accessibile (e questo deve aver tratto in
inganno i programmatori Microsoft), ma nel contesto di Derived(),
VirtualBase() è inaccessibile. Sia il punto 2 che 7 dello
stesso paragrafo 12.6.2 chiariscono che il contesto di name-lookup
e di valutazione è quello del costruttore di Derived, ma
non definiscono con precisione quale sia il contesto di accessibilità,
lasciando spazio a possibili interpretazioni errate.
In conclusione, l'uso dell'ereditarietà privata e virtuale
si rivela fallimentare; del resto, la raccomandazione semplificata
no. 129 in [1] avrebbe potuto metterci in guardia sin dall'inizio:
"l'ereditarietà privata dovrebbe sempre essere non
virtual", proprio per le ragioni di cui sopra.
La versione finale
Scartata l'idea di usare l'ereditarietà privata, rimangono
le classi friend. Ricordiamo che una classe B dichiarata friend
di A ha accesso anche agli elementi privati di A, e che la relazione
friend non è transitiva, quindi i figli di B non avranno
alcun privilegio nei confronti di A. Possiamo quindi rendere il
costruttore non-default privato, ereditare in modo pubblico e
virtuale, e dichiarare Base come friend di VirtualBase:
class VirtualBase
{
public:
VirtualBase()
{ derived = true ; }
bool IsDerived() const
{ return( derived ) ; }
private:
friend class Base ;
VirtualBase( int /* dummy */ )
{ derived = false ; }
bool derived ;
} ;
class Base :
virtual public VirtualBase
{
public:
Base() :
VirtualBase( 1 )
{}
} ;
class Derived :
public Base
{
// ...
} ;
Come nel caso precedente, Derived può semplicemente ereditare
Base, senza doversi preoccupare di alcunché. Inoltre, a
differenza dei casi visti sinora, Derived non può pretendere
di essere una classe base, né accidentalmente né
intenzionalmente; ovviamente, con una buona dose di cast ciò
sarebbe possibile, ma nelle intenzioni di Stroustrup, il C++ deve
proteggere da Murphy, non da Machiavelli.
Osserviamo che l'uso del friend, in questo caso, è assolutamente
"pulito". VirtualBase è un artificio di implementazione,
non una classe del dominio del problema o un elemento di primo
piano del dominio della soluzione. Non stiamo quindi indebolendo
l'incapsulazione di alcuna classe primaria, anzi, rispetto a soluzioni
alternative l'uso del friend ci consente di aumentare l'incapsulazione,
impedendo che Derived abbia accesso al costruttore privato.
Tuttavia, rispetto alla versione basata su ereditarietà
privata, quest'ultima soluzione ha un difetto: VirtualBase "conosce"
Base, quindi se abbiamo tante classi base, dovremmo dichiararle
tutte friend di VirtualBase, creando forse qualche problema di
manutenzione. Del resto, se pensiamo che le classi Base della
nostra ipotetica libreria siano propense ad aumentare nel tempo,
esiste comunque un'ulteriore possibilità: trasformare VirtualBase
in un template, avente Base come parametro. In questo modo elimineremo
l'accoppiamento statico tra VirtualBase e Base, e non vi sarà
nessun problema di manutenzione (probabilmente a scapito di una
dimensione leggermente maggiore dell'eseguibile).
Conclusioni
Inizialmente, avrei voluto impostare la discussione dell'antimorfismo
in forma di pattern di codifica, per uno "Speciale Pattern"
che non ha poi visto la luce. A posteriori, credo che una presentazione
più discorsiva e meno schematizzata sia risultata più
leggibile.
Al di là dell'antimorfismo, è importante osservare
che per ogni problema, il C++ consente di trovare molte soluzioni
diverse. Anche se da un lato ciò rende più difficile
raggiungere la completa padronanza del linguaggio, dall'altro
ci permette di trovare la soluzione che risponde meglio non solo
al problema in sé, ma anche ai vari elementi al contorno,
come la semplicità di utilizzo, la sicurezza dell'interfaccia,
oppure la semplicità realizzativa, e così via. Tutto
questo richiede naturalmente uno studio molto attento del linguaggio,
e la volontà di progettare le nostre classi nel migliore
modo possibile.
Bibliografia:
[1] Carlo Pescio, "C++ Manuale Di Stile", Edizioni Infomedia,
1995.
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.