Dr. Carlo Pescio
Antimorfismo in C++

Pubblicato su Computer Programming No. 59


Anche un problema apparentemente semplice (fornire un default per le classi derivate diverso da quello della classe base) può fornire lo spunto per parlare di possibilità spesso sottoutilizzate del C++, come l'ereditarietà virtuale e privata, le classi friend, ma anche della progettazione dell'interfaccia di una classe, del partizionamento di responsabilità tra classe base e classi derivate, e così via.

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.

Reader's Map
Molti visitatori che hanno letto
questo articolo hanno letto anche:

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.